Files
gh-christianlouis-dmarq/backend/app/templates/index.html
T
2026-05-23 00:17:34 +02:00

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 %}