758 lines
34 KiB
HTML
758 lines
34 KiB
HTML
{% extends "layouts/base.html" %}
|
|
{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
|
|
{% from "components/ui/button.html" import button, button_link %}
|
|
{% from "components/ui/alert.html" import alert, alert_title, alert_description %}
|
|
{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
|
|
|
|
{% block title %}DMARQ - Dashboard{% endblock %}
|
|
|
|
{% block page_title %}Dashboard{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="grid gap-4 md:gap-8 py-4" x-data="dashboardApp()">
|
|
<!-- Overview Stats -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<!-- Total Domains Card -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
{% call card_title() %}Total Domains{% endcall %}
|
|
<div class="p-2 bg-primary/10 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary"><circle cx="12" cy="12" r="10"></circle><line x1="2" x2="22" y1="12" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="stat-card">
|
|
<div id="total-domains" class="stat-value">0</div>
|
|
<p class="stat-description">Active domains being monitored</p>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- Emails Analyzed Card -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
{% call card_title() %}Emails Analyzed{% endcall %}
|
|
<div class="p-2 bg-secondary/10 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-secondary"><rect width="20" height="16" x="2" y="4" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg>
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="stat-card">
|
|
<div id="total-emails" class="stat-value">0</div>
|
|
<p class="stat-description">Total emails processed</p>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- Pass Rate Card -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
{% call card_title() %}Pass Rate{% endcall %}
|
|
<div class="p-2 bg-green-500/10 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-green-500"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="stat-card">
|
|
<div id="overall-pass-rate" class="stat-value text-green-500">0%</div>
|
|
<p class="stat-description">Overall DMARC compliance</p>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- Reports Card -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
{% call card_title() %}Reports Processed{% endcall %}
|
|
<div class="p-2 bg-accent/10 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="stat-card">
|
|
<div id="reports-processed" class="stat-value">0</div>
|
|
<p class="stat-description">DMARC reports received</p>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</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>
|
|
|
|
<!-- What Changed -->
|
|
<div x-show="hasDomainData" x-cloak>
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}What Changed{% endcall %}
|
|
{% call card_description() %}
|
|
New sending sources and sharp compliance changes from the last 30 days
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div id="change-summary-list" class="space-y-3">
|
|
<!-- Change summaries will be populated here via JavaScript -->
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</div>
|
|
|
|
<!-- Top Sending Sources -->
|
|
<div x-show="hasDomainData" x-cloak>
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Top Sending Sources{% endcall %}
|
|
{% call card_description() %}
|
|
Sender IPs with authentication pass and fail breakdowns
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="overflow-x-auto">
|
|
{% call table() %}
|
|
{% call thead() %}
|
|
{% call tr() %}
|
|
{% call th() %}Source{% endcall %}
|
|
{% call th() %}Messages{% endcall %}
|
|
{% call th() %}DMARC{% endcall %}
|
|
{% call th() %}SPF{% endcall %}
|
|
{% call th() %}DKIM{% endcall %}
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call tbody() %}
|
|
<tbody id="top-sources-table-body">
|
|
<!-- Source data will be populated here via JavaScript -->
|
|
</tbody>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</div>
|
|
|
|
<!-- Domain Compliance Table -->
|
|
<div x-show="hasDomainData" x-cloak>
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
{% call card_title() %}Domain Compliance{% endcall %}
|
|
{% call button(variant="outline", size="sm") %}
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>
|
|
Refresh
|
|
{% endcall %}
|
|
</div>
|
|
{% call card_description() %}
|
|
Overview of domains and their DMARC compliance status
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
{% call table() %}
|
|
{% call thead() %}
|
|
{% call tr() %}
|
|
{% call th() %}Domain{% endcall %}
|
|
{% call th() %}Emails{% endcall %}
|
|
{% call th() %}Pass Rate{% endcall %}
|
|
{% call th() %}Failed{% endcall %}
|
|
{% call th() %}Reports{% endcall %}
|
|
{% call th("text-right") %}Actions{% endcall %}
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call tbody() %}
|
|
<tbody id="domains-table-body">
|
|
<!-- Domain data will be populated here via JavaScript -->
|
|
</tbody>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</div>
|
|
|
|
<!-- No Data Message -->
|
|
<div x-show="!hasDomainData" x-cloak>
|
|
{% call card() %}
|
|
{% call card_content() %}
|
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
<div class="rounded-full p-4 bg-muted mb-4">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><circle cx="12" cy="12" r="10"></circle><path d="m8 12 4 0"></path><path d="m16 12 0 .01"></path><path d="M12 16v-4"></path></svg>
|
|
</div>
|
|
<h3 class="text-lg font-semibold mb-2">No DMARC reports have been uploaded yet</h3>
|
|
<p class="text-muted-foreground mb-4 max-w-md">Upload a report or configure IMAP integration to see statistics and gain insights into your domain's email authentication.</p>
|
|
<div class="flex gap-2">
|
|
{% call button_link(href="/upload") %}
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" x2="12" y1="3" y2="15"></line></svg>
|
|
Upload Report
|
|
{% endcall %}
|
|
{% call button_link(href="/settings", variant="outline") %}
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
|
Configure IMAP
|
|
{% endcall %}
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</div>
|
|
|
|
<!-- IMAP Status -->
|
|
<div>
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
{% call card_title() %}IMAP Integration Status{% endcall %}
|
|
{% call button_link(href="/api/v1/admin/trigger-poll", variant="outline", size="sm") %}
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="m3 2 2 4h8L7 14h2l-4 8 1-6H4l2-8H3z"></path></svg>
|
|
Trigger Poll Now
|
|
{% endcall %}
|
|
</div>
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex items-center">
|
|
<span class="w-40 text-sm text-muted-foreground">Status:</span>
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<span id="imap-status-icon" class="h-2.5 w-2.5 rounded-full bg-green-500"></span>
|
|
<span id="imap-status-text">Running</span>
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<span class="w-40 text-sm text-muted-foreground">Last Check:</span>
|
|
<span id="imap-last-check">Never</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<span class="w-40 text-sm text-muted-foreground">Mailbox:</span>
|
|
<span id="imap-mailbox">dmarc-reports@hosterra.net</span>
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script src="/static/js/chart.umd.min.js"></script>
|
|
<script>
|
|
function dashboardApp() {
|
|
return {
|
|
hasDomainData: false,
|
|
volumeTrendChart: null,
|
|
complianceTrendChart: null,
|
|
|
|
init() {
|
|
// Fetch domain summary on page load
|
|
this.fetchDomainSummary();
|
|
|
|
// Check IMAP status
|
|
this.getImapStatus();
|
|
},
|
|
|
|
async getImapStatus() {
|
|
try {
|
|
const response = await fetch('/api/v1/poll-status');
|
|
if (!response.ok) {
|
|
console.error('Error checking IMAP status:', response.status);
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
|
|
const statusIcon = document.getElementById('imap-status-icon');
|
|
const statusText = document.getElementById('imap-status-text');
|
|
const lastCheck = document.getElementById('imap-last-check');
|
|
|
|
if (data.is_running) {
|
|
statusIcon.classList.remove('bg-red-500');
|
|
statusIcon.classList.add('bg-green-500');
|
|
statusText.textContent = 'Running';
|
|
} else {
|
|
statusIcon.classList.remove('bg-green-500');
|
|
statusIcon.classList.add('bg-red-500');
|
|
statusText.textContent = 'Stopped';
|
|
}
|
|
|
|
if (data.last_check) {
|
|
lastCheck.textContent = new Date(data.last_check).toLocaleString();
|
|
} else {
|
|
lastCheck.textContent = 'Never';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking IMAP status:', error);
|
|
}
|
|
},
|
|
|
|
async fetchDomainSummary() {
|
|
try {
|
|
const response = await fetch('/api/v1/domains/summary');
|
|
const data = await response.json();
|
|
|
|
if (data && data.domains && data.domains.length > 0) {
|
|
this.hasDomainData = true;
|
|
this.updateDashboardStats(data);
|
|
this.populateDomainsTable(data.domains);
|
|
this.$nextTick(() => this.fetchDashboardStats());
|
|
} else {
|
|
this.hasDomainData = false;
|
|
this.clearDashboardCharts();
|
|
this.populateChangeSummary([]);
|
|
this.populateTopSources([]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching domain summary:', error);
|
|
this.hasDomainData = false;
|
|
this.clearDashboardCharts();
|
|
this.populateChangeSummary([]);
|
|
this.populateTopSources([]);
|
|
}
|
|
},
|
|
|
|
async fetchDashboardStats() {
|
|
try {
|
|
const response = await fetch('/api/v1/stats/dashboard?period_days=30');
|
|
if (!response.ok) {
|
|
console.error('Error fetching dashboard stats:', response.status);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.renderDashboardCharts(data.compliance_trend || []);
|
|
this.populateChangeSummary(data.change_summary || []);
|
|
this.populateTopSources(data.top_sources || []);
|
|
} catch (error) {
|
|
console.error('Error fetching dashboard stats:', error);
|
|
}
|
|
},
|
|
|
|
updateDashboardStats(data) {
|
|
if (!data) return;
|
|
|
|
const totalDomains = document.getElementById('total-domains');
|
|
const totalEmails = document.getElementById('total-emails');
|
|
const passRate = document.getElementById('overall-pass-rate');
|
|
const reportsProcessed = document.getElementById('reports-processed');
|
|
|
|
if (totalDomains) totalDomains.textContent = data.total_domains || 0;
|
|
if (totalEmails) totalEmails.textContent = data.total_emails || 0;
|
|
if (passRate) passRate.textContent = `${data.overall_pass_rate || 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);
|
|
},
|
|
|
|
populateChangeSummary(changes) {
|
|
const list = document.getElementById('change-summary-list');
|
|
if (!list) return;
|
|
|
|
list.textContent = '';
|
|
|
|
if (!changes || !changes.length) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'text-sm text-muted-foreground';
|
|
empty.textContent = 'No major sender or compliance changes detected.';
|
|
list.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
changes.forEach(change => {
|
|
const item = document.createElement('div');
|
|
item.className = 'rounded-md border border-base-300 bg-base-100 p-3';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'flex items-start gap-2';
|
|
|
|
const dot = document.createElement('span');
|
|
dot.className = `mt-1 inline-flex h-2.5 w-2.5 flex-none rounded-full ${this.changeSeverityClass(change.severity)}`;
|
|
header.appendChild(dot);
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'min-w-0';
|
|
|
|
const title = document.createElement('p');
|
|
title.className = 'text-sm font-semibold';
|
|
title.textContent = change.title || 'Change detected';
|
|
content.appendChild(title);
|
|
|
|
const detail = document.createElement('p');
|
|
detail.className = 'mt-1 text-sm text-muted-foreground';
|
|
detail.textContent = change.detail || '';
|
|
content.appendChild(detail);
|
|
|
|
const action = document.createElement('p');
|
|
action.className = 'mt-1 text-sm';
|
|
action.textContent = change.action || '';
|
|
content.appendChild(action);
|
|
|
|
header.appendChild(content);
|
|
item.appendChild(header);
|
|
list.appendChild(item);
|
|
});
|
|
},
|
|
|
|
changeSeverityClass(severity) {
|
|
if (severity === 'error') return 'bg-red-500';
|
|
if (severity === 'warning') return 'bg-yellow-500';
|
|
return 'bg-blue-500';
|
|
},
|
|
|
|
populateTopSources(sources) {
|
|
const tableBody = document.getElementById('top-sources-table-body');
|
|
if (!tableBody) return;
|
|
|
|
tableBody.textContent = '';
|
|
|
|
if (!sources || !sources.length) {
|
|
const emptyRow = document.createElement('tr');
|
|
emptyRow.className = 'table-row';
|
|
const emptyCell = document.createElement('td');
|
|
emptyCell.className = 'table-cell text-muted-foreground';
|
|
emptyCell.colSpan = 5;
|
|
emptyCell.textContent = 'No sending source data available';
|
|
emptyRow.appendChild(emptyCell);
|
|
tableBody.appendChild(emptyRow);
|
|
return;
|
|
}
|
|
|
|
sources.forEach(source => {
|
|
const row = document.createElement('tr');
|
|
row.className = 'table-row';
|
|
|
|
row.appendChild(this.createTextCell(source.ip || 'Unknown', 'font-mono text-sm'));
|
|
row.appendChild(this.createTextCell(this.formatLargeNumber(source.count || 0)));
|
|
row.appendChild(this.createAuthCell(
|
|
source.dmarc,
|
|
source.dmarc_pass_count || 0,
|
|
source.dmarc_fail_count || 0
|
|
));
|
|
row.appendChild(this.createAuthCell(
|
|
source.spf,
|
|
source.spf_pass_count || 0,
|
|
source.spf_fail_count || 0
|
|
));
|
|
row.appendChild(this.createAuthCell(
|
|
source.dkim,
|
|
source.dkim_pass_count || 0,
|
|
source.dkim_fail_count || 0
|
|
));
|
|
|
|
tableBody.appendChild(row);
|
|
});
|
|
},
|
|
|
|
createTextCell(value, innerClass = '') {
|
|
const cell = document.createElement('td');
|
|
cell.className = 'table-cell';
|
|
const content = document.createElement('span');
|
|
if (innerClass) content.className = innerClass;
|
|
content.textContent = value;
|
|
cell.appendChild(content);
|
|
return cell;
|
|
},
|
|
|
|
createAuthCell(status, passCount, failCount) {
|
|
const cell = document.createElement('td');
|
|
cell.className = 'table-cell';
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'flex flex-col gap-1';
|
|
wrapper.appendChild(this.createStatusBadge(status));
|
|
|
|
const counts = document.createElement('span');
|
|
counts.className = 'text-xs text-muted-foreground whitespace-nowrap';
|
|
counts.textContent = `${this.formatLargeNumber(passCount)} pass / ${this.formatLargeNumber(failCount)} fail`;
|
|
wrapper.appendChild(counts);
|
|
|
|
cell.appendChild(wrapper);
|
|
return cell;
|
|
},
|
|
|
|
createStatusBadge(status) {
|
|
const normalized = status || 'none';
|
|
const badge = document.createElement('span');
|
|
const styles = {
|
|
pass: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
|
fail: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
|
mixed: 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300',
|
|
none: 'bg-muted text-muted-foreground'
|
|
};
|
|
badge.className = `inline-flex w-fit items-center rounded-md px-2 py-1 text-xs font-medium ${styles[normalized] || styles.none}`;
|
|
badge.textContent = normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
return badge;
|
|
},
|
|
|
|
populateDomainsTable(domains) {
|
|
if (!domains || !domains.length) return;
|
|
|
|
const tableBody = document.getElementById('domains-table-body');
|
|
if (!tableBody) return;
|
|
|
|
tableBody.textContent = '';
|
|
|
|
domains.forEach(domain => {
|
|
const row = document.createElement('tr');
|
|
row.className = 'table-row';
|
|
|
|
row.appendChild(this.createDomainNameCell(domain.domain_name));
|
|
row.appendChild(this.createTextCell(this.formatLargeNumber(domain.total_emails || 0)));
|
|
row.appendChild(this.createPassRateCell(domain.pass_rate || 0));
|
|
row.appendChild(this.createTextCell(this.formatLargeNumber(domain.failed_count || 0)));
|
|
row.appendChild(this.createTextCell(this.formatLargeNumber(domain.report_count || 0)));
|
|
row.appendChild(this.createDetailsCell(domain));
|
|
|
|
tableBody.appendChild(row);
|
|
});
|
|
},
|
|
|
|
createDomainNameCell(domainName) {
|
|
const cell = document.createElement('td');
|
|
cell.className = 'table-cell';
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'font-medium';
|
|
content.textContent = domainName || 'Unknown';
|
|
cell.appendChild(content);
|
|
|
|
return cell;
|
|
},
|
|
|
|
createPassRateCell(passRate) {
|
|
const cell = document.createElement('td');
|
|
cell.className = 'table-cell';
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = 'inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/20 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300';
|
|
badge.textContent = `${passRate}%`;
|
|
cell.appendChild(badge);
|
|
|
|
return cell;
|
|
},
|
|
|
|
createDetailsCell(domain) {
|
|
const cell = document.createElement('td');
|
|
cell.className = 'table-cell text-right';
|
|
|
|
const link = document.createElement('a');
|
|
link.className = 'btn btn-outline btn-sm';
|
|
const domainId = String(domain.id ?? domain.domain_name ?? '');
|
|
link.href = `/domains/${encodeURIComponent(domainId)}`;
|
|
link.appendChild(this.createDetailsIcon());
|
|
link.appendChild(document.createTextNode('Details'));
|
|
cell.appendChild(link);
|
|
|
|
return cell;
|
|
},
|
|
|
|
createDetailsIcon() {
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svg.setAttribute('width', '14');
|
|
svg.setAttribute('height', '14');
|
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
svg.setAttribute('fill', 'none');
|
|
svg.setAttribute('stroke', 'currentColor');
|
|
svg.setAttribute('stroke-width', '2');
|
|
svg.setAttribute('stroke-linecap', 'round');
|
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
svg.setAttribute('class', 'mr-1');
|
|
|
|
const axis = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
axis.setAttribute('d', 'M3 3v18h18');
|
|
svg.appendChild(axis);
|
|
|
|
const trend = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
trend.setAttribute('d', 'm19 9-5 5-4-4-3 3');
|
|
svg.appendChild(trend);
|
|
|
|
return svg;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|