Files
gh-christianlouis-dmarq/backend/app/templates/domain_details.html
T
2026-05-23 16:30:05 +02:00

1012 lines
53 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/table.html" import table, thead, tbody, tr, th, td %}
{% block title %}DMARQ - {{ domain.name }} Details{% endblock %}
{% block content %}
<div class="container mx-auto py-4" x-data="domainDetailsApp('{{ domain_id }}')">
<nav class="mb-4 text-sm">
<ol class="flex items-center space-x-2">
<li><a href="/" class="hover:text-primary">Dashboard</a></li>
<li><span class="text-muted-foreground px-2">/</span></li>
<li><a href="/domains" class="hover:text-primary">Domains</a></li>
<li><span class="text-muted-foreground px-2">/</span></li>
<li><span class="font-medium">{{ domain.name }}</span></li>
</ol>
</nav>
<div class="grid grid-cols-1 gap-6">
<!-- Domain Overview -->
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold mb-1">{{ domain.name }}</h1>
<p class="text-muted-foreground">{{ domain.description or "Domain monitored by DMARQ" }}</p>
</div>
<div class="flex space-x-2">
{% 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="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>
Edit Domain
{% 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="M12 2v7.5"></path><path d="m4.24 10.37 6.58-3.79"></path><path d="m3.24 16.98 7.5-1.01"></path><path d="m13.24 15.97 7.5 1.01"></path><path d="m13.24 3.79 6.58 3.79"></path><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z"></path><path d="M12 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"></path></svg>
Check DNS
{% endcall %}
</div>
</div>
<!-- Domain Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- DMARC Compliance Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}DMARC Compliance{% 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"><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="compliance-rate" class="stat-value" x-text="stats.complianceRate + '%'">-</div>
<p class="stat-description">Emails passing DMARC</p>
</div>
{% endcall %}
{% endcall %}
<!-- Total Emails Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Total Emails{% 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" x-text="stats.totalEmails">-</div>
<p class="stat-description">Total emails processed</p>
</div>
{% endcall %}
{% endcall %}
<!-- Failed Emails Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Failed Emails{% endcall %}
<div class="p-2 bg-red-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-red-500"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
</div>
</div>
{% endcall %}
{% call card_content() %}
<div class="stat-card">
<div id="failed-emails" class="stat-value" x-text="stats.failedEmails">-</div>
<p class="stat-description">Emails failing DMARC</p>
</div>
{% endcall %}
{% endcall %}
<!-- Reports Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Reports{% 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="report-count" class="stat-value" x-text="stats.reportCount">-</div>
<p class="stat-description">DMARC reports received</p>
</div>
{% endcall %}
{% endcall %}
</div>
<!-- Compliance Chart -->
<section id="compliance-chart-section">
{% call card() %}
{% call card_header() %}
{% call card_title() %}Compliance Over Time{% endcall %}
{% call card_description() %}
Daily DMARC volume, pass rate, and failure rate
{% endcall %}
{% endcall %}
{% call card_content() %}
<div class="h-64">
<canvas id="compliance-chart"></canvas>
</div>
{% endcall %}
{% endcall %}
</section>
<!-- DNS Health Summary -->
<section id="dns-health-summary">
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}DNS Health Summary{% endcall %}
<span class="inline-flex items-center rounded px-2 py-1 text-xs font-semibold capitalize"
:class="dnsHealthStatusClass"
x-text="dnsHealth.status || 'checking'"></span>
</div>
{% call card_description() %}
Evidence-linked authentication posture and enforcement readiness
{% endcall %}
{% endcall %}
{% call card_content() %}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<template x-for="check in dnsHealth.checks" :key="check.key">
<div class="rounded border border-base-300 p-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold" x-text="check.label"></h3>
<span class="rounded px-2 py-1 text-xs font-semibold capitalize"
:class="check.status === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
x-text="check.status"></span>
</div>
<p class="mt-2 text-sm text-base-content/70" x-text="check.message"></p>
<div class="mt-3 space-y-1">
<template x-for="item in check.evidence" :key="item.label + item.value">
<a :href="item.href" class="block text-xs link link-primary">
<span x-text="item.label"></span>:
<span class="font-mono break-all" x-text="item.value"></span>
</a>
</template>
</div>
</div>
</template>
</div>
<div class="mt-5 space-y-3">
<template x-for="recommendation in dnsHealth.recommendations" :key="recommendation.type">
<div class="rounded border border-base-300 p-4">
<div class="flex items-start gap-3">
<span class="mt-1 h-2.5 w-2.5 rounded-full"
:class="recommendationSeverityClass(recommendation.severity)"></span>
<div class="min-w-0 flex-1">
<h3 class="font-semibold" x-text="recommendation.title"></h3>
<p class="mt-1 text-sm text-base-content/70" x-text="recommendation.detail"></p>
<p class="mt-2 text-sm font-medium" x-text="recommendation.action"></p>
<div class="mt-3 flex flex-wrap gap-2">
<template x-for="item in recommendation.evidence" :key="item.label + item.value">
<a :href="item.href" class="badge badge-outline gap-1">
<span x-text="item.label"></span>
<span x-text="item.value"></span>
</a>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
{% endcall %}
{% endcall %}
</section>
<!-- DNS Records -->
<section id="dns-records">
{% call card() %}
{% call card_header() %}
{% call card_title() %}DNS Records{% endcall %}
{% call card_description() %}
Email authentication DNS records for this domain
{% endcall %}
{% endcall %}
{% call card_content() %}
<div class="grid gap-4">
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">DMARC Record</span>
<span x-show="dns.dmarc" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.dmarc" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.dmarcRecord || 'No DMARC record found'">-</div>
</div>
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">SPF Record</span>
<span x-show="dns.spf" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.spf" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.spfRecord || 'No SPF record found'">-</div>
</div>
<div id="mta-sts-posture">
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">MTA-STS</span>
<span x-show="mtaSts.status === 'pass'" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="mtaSts.status === 'fail'" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
<span x-show="!mtaSts.status" class="inline-flex h-2 w-2 rounded-full bg-base-300"></span>
</h3>
<div class="space-y-2">
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="mtaSts.dns_record || 'No _mta-sts TXT record found'">-</div>
<div class="rounded border border-base-300 p-3 text-sm">
<div class="grid gap-1">
<div><span class="font-medium">Mode:</span> <span x-text="mtaSts.mode || 'unknown'"></span></div>
<div><span class="font-medium">Max age:</span> <span x-text="mtaSts.max_age || 'unknown'"></span></div>
<div><span class="font-medium">MX:</span> <span class="font-mono" x-text="mtaSts.mx && mtaSts.mx.length ? mtaSts.mx.join(', ') : 'none'"></span></div>
</div>
<template x-if="mtaSts.errors && mtaSts.errors.length">
<p class="mt-2 text-xs text-red-600" x-text="mtaSts.errors[0]"></p>
</template>
<template x-if="mtaSts.warnings && mtaSts.warnings.length">
<p class="mt-2 text-xs text-yellow-700" x-text="mtaSts.warnings[0]"></p>
</template>
</div>
</div>
</div>
<!-- DKIM Selectors — live check result -->
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">DKIM (live check)</span>
<span x-show="dns.dkim" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.dkim" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono"
x-text="dkimLiveText">-</div>
</div>
<!-- DKIM Selector Management -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-3">DKIM Selectors</h3>
<p class="text-sm text-muted-foreground mb-3">
Manually configure selectors to check. Selectors seen in received DMARC
reports and common well-known selectors are checked automatically.
</p>
<!-- Manually configured selectors -->
<div class="mb-3">
<template x-if="selectors.length === 0">
<p class="text-sm text-muted-foreground italic">No manually configured selectors yet.</p>
</template>
<template x-for="sel in selectors" :key="sel">
<div class="flex items-center justify-between py-1 px-2 rounded bg-muted mb-1">
<span class="font-mono text-sm" x-text="sel"></span>
<button
@click="deleteSelector(sel)"
class="text-red-500 hover:text-red-700 text-xs ml-4"
title="Remove selector"
></button>
</div>
</template>
</div>
<!-- Auto-discovered selectors from DMARC reports -->
<template x-if="reportSelectors.length > 0">
<div class="mb-3">
<p class="text-xs text-muted-foreground font-semibold uppercase tracking-wide mb-1">From DMARC reports (auto-detected)</p>
<template x-for="sel in reportSelectors" :key="sel">
<div class="flex items-center py-1 px-2 rounded bg-muted/50 border border-dashed mb-1">
<span class="font-mono text-sm text-muted-foreground" x-text="sel"></span>
<span class="ml-2 text-xs text-muted-foreground italic">auto</span>
</div>
</template>
</div>
</template>
<!-- Add selector form -->
<div class="flex items-center gap-2">
<input
x-model="newSelector"
@keydown.enter.prevent="addSelector()"
type="text"
placeholder="e.g. google, selector1, mail"
class="input input-sm input-bordered flex-1 font-mono"
/>
<button
@click="addSelector()"
:disabled="!newSelector.trim()"
class="btn btn-sm btn-primary"
>Add</button>
</div>
<p x-show="selectorError" x-text="selectorError" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
{% endcall %}
{% endcall %}
</section>
<!-- Source Report Table -->
<section id="sending-sources">
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Sending Sources{% endcall %}
<div class="space-x-2">
<select x-model="filters.dateRange" class="select select-sm select-bordered">
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="all">All time</option>
</select>
<input
x-model="filters.sourceFilter"
type="text"
placeholder="Filter sources..."
class="input input-sm input-bordered max-w-xs"
>
</div>
</div>
{% call card_description() %}
IP addresses and servers sending email as this domain
{% endcall %}
{% endcall %}
{% call card_content() %}
{% call table() %}
{% call thead() %}
{% call tr() %}
{% call th() %}Source IP / Hostname{% endcall %}
{% call th() %}Total Emails{% endcall %}
{% call th() %}SPF{% endcall %}
{% call th() %}DKIM{% endcall %}
{% call th() %}DMARC{% endcall %}
{% call th() %}Disposition{% endcall %}
{% call th() %}Recommendations{% endcall %}
{% endcall %}
{% endcall %}
{% call tbody() %}
<template x-if="sources.length === 0">
<tr>
<td colspan="7" class="text-center py-4">
<div class="text-muted-foreground">No data available for this time period</div>
</td>
</tr>
</template>
<template x-for="source in filteredSources" :key="source.ip">
{% call tr() %}
{% call td() %}
<div class="flex flex-col">
<span class="font-mono text-sm" x-text="source.ip"></span>
<template x-if="source.hostname">
<span class="text-xs text-muted-foreground" x-text="source.hostname"></span>
</template>
</div>
{% endcall %}
{% call td() %}
<span x-text="source.count"></span>
{% endcall %}
{% call td() %}
<template x-if="source.spf === 'pass'">
<div class="tooltip" data-tip="IP is authorized in the SPF record">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
</div>
</template>
<template x-if="source.spf === 'fail'">
<div class="tooltip" data-tip="IP is not listed in the SPF record">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
</div>
</template>
<template x-if="source.spf === 'mixed'">
<div class="tooltip" data-tip="This IP has both SPF passes and failures">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">Mixed</span>
</div>
</template>
<template x-if="source.spf !== 'pass' && source.spf !== 'fail' && source.spf !== 'mixed'">
<div class="tooltip" :data-tip="'SPF returned: ' + source.spf">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800" x-text="source.spf"></span>
</div>
</template>
<div class="mt-1 text-xs text-muted-foreground">
<span x-text="(source.spf_pass_count || 0) + ' pass'"></span>
<span> / </span>
<span x-text="(source.spf_fail_count || 0) + ' fail'"></span>
</div>
{% endcall %}
{% call td() %}
<template x-if="source.dkim === 'pass'">
<div class="tooltip" data-tip="Valid DKIM signature found">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
</div>
</template>
<template x-if="source.dkim === 'fail'">
<div class="tooltip" data-tip="No valid DKIM signature for this domain">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
</div>
</template>
<template x-if="source.dkim === 'mixed'">
<div class="tooltip" data-tip="This IP has both DKIM passes and failures">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">Mixed</span>
</div>
</template>
<template x-if="source.dkim !== 'pass' && source.dkim !== 'fail' && source.dkim !== 'mixed'">
<div class="tooltip" :data-tip="'DKIM returned: ' + source.dkim">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800" x-text="source.dkim"></span>
</div>
</template>
<div class="mt-1 text-xs text-muted-foreground">
<span x-text="(source.dkim_pass_count || 0) + ' pass'"></span>
<span> / </span>
<span x-text="(source.dkim_fail_count || 0) + ' fail'"></span>
</div>
{% endcall %}
{% call td() %}
<template x-if="source.dmarc === 'pass'">
<div class="tooltip" data-tip="DMARC passed: SPF or DKIM alignment succeeded">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
</div>
</template>
<template x-if="source.dmarc === 'fail'">
<div class="tooltip" data-tip="DMARC failed: neither SPF nor DKIM alignment passed">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
</div>
</template>
<template x-if="source.dmarc === 'mixed'">
<div class="tooltip" data-tip="This IP has both DMARC passes and failures">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">Mixed</span>
</div>
</template>
<div class="mt-1 text-xs text-muted-foreground">
<span x-text="(source.dmarc_pass_count || 0) + ' pass'"></span>
<span> / </span>
<span x-text="(source.dmarc_fail_count || 0) + ' fail'"></span>
</div>
{% endcall %}
{% call td() %}
<template x-if="source.disposition === 'none'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">None</span>
</template>
<template x-if="source.disposition === 'quarantine'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">Quarantine</span>
</template>
<template x-if="source.disposition === 'reject'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Reject</span>
</template>
{% endcall %}
{% call td() %}
<template x-if="!source.recommendations || source.recommendations.length === 0">
<span class="text-xs text-muted-foreground">No action</span>
</template>
<template x-if="source.recommendations && source.recommendations.length > 0">
<div class="space-y-2 min-w-72">
<template x-for="recommendation in source.recommendations" :key="recommendation.type">
<div class="rounded-md border border-base-300 bg-base-100 p-2">
<div class="flex items-start gap-2">
<span
class="mt-0.5 inline-flex h-2.5 w-2.5 flex-none rounded-full"
:class="recommendationSeverityClass(recommendation.severity)"
></span>
<div class="min-w-0">
<p class="text-xs font-semibold" x-text="recommendation.title"></p>
<p class="mt-1 text-xs text-muted-foreground" x-text="recommendation.detail"></p>
<p class="mt-1 text-xs" x-text="recommendation.action"></p>
<template x-if="recommendation.type === 'dkim_only_pass' && source.spf_fix_hint">
<div class="mt-2 flex items-center gap-2 rounded bg-base-200 px-2 py-1">
<code class="flex-1 font-mono text-xs" x-text="source.spf_fix_hint"></code>
<button
@click="navigator.clipboard.writeText(source.spf_fix_hint)"
class="btn btn-xs btn-ghost"
title="Copy SPF mechanism"
>
Copy
</button>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
</section>
<!-- Recent Reports -->
{% call card() %}
{% call card_header() %}
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
{% call card_title() %}Recent Reports{% endcall %}
</div>
<div class="flex flex-wrap items-center gap-2">
<input
x-model="filters.exportStartDate"
type="date"
class="input input-sm input-bordered"
aria-label="Export start date"
>
<input
x-model="filters.exportEndDate"
type="date"
class="input input-sm input-bordered"
aria-label="Export end date"
>
<a :href="exportReportsUrl" class="btn btn-sm btn-outline">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
CSV
</a>
</div>
</div>
{% call card_description() %}
Latest DMARC reports received for this domain
{% endcall %}
{% endcall %}
{% call card_content() %}
{% call table() %}
{% call thead() %}
{% call tr() %}
{% call th() %}Date{% endcall %}
{% call th() %}Organization{% endcall %}
{% call th() %}Emails{% endcall %}
{% call th() %}Pass Rate{% endcall %}
{% call th() %}Policy{% endcall %}
{% call th() %}Actions{% endcall %}
{% endcall %}
{% endcall %}
{% call tbody() %}
<template x-if="reports.length === 0">
<tr>
<td colspan="6" class="text-center py-4">
<div class="text-muted-foreground">No reports available</div>
</td>
</tr>
</template>
<template x-for="report in reports" :key="report.id">
{% call tr() %}
{% call td() %}
<span x-text="formatDate(report.begin_date)"></span>
{% endcall %}
{% call td() %}
<span x-text="report.org_name"></span>
{% endcall %}
{% call td() %}
<span x-text="report.total_emails"></span>
{% endcall %}
{% call td() %}
<span class="inline-flex items-center px-2 py-1 rounded text-xs"
:class="getPassRateClass(report.pass_rate)">
<span x-text="report.pass_rate + '%'"></span>
</span>
{% endcall %}
{% call td() %}
<span x-text="report.policy"></span>
{% endcall %}
{% call td() %}
<a :href="'/reports/' + report.id" class="btn btn-sm btn-outline">
View
</a>
{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/chart.umd.min.js"></script>
<script>
function domainDetailsApp(domainId) {
return {
domainId: domainId,
stats: {
complianceRate: '-',
totalEmails: '-',
failedEmails: '-',
reportCount: '-'
},
dns: {
dmarc: false,
dmarcRecord: '',
spf: false,
spfRecord: '',
dkim: false,
dkimSelectors: []
},
dnsHealth: {
status: '',
checks: [],
recommendations: []
},
mtaSts: {
status: '',
dns_record: '',
mode: '',
max_age: null,
mx: [],
errors: [],
warnings: []
},
selectors: [],
reportSelectors: [],
newSelector: '',
selectorError: '',
reports: [],
sources: [],
complianceChart: null,
filters: {
dateRange: '30',
sourceFilter: '',
exportStartDate: '',
exportEndDate: ''
},
init() {
this.fetchDomainStats();
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
this.fetchSelectors();
this.fetchReports();
this.fetchSources();
this.$watch('filters.dateRange', () => {
this.fetchSources();
});
},
get filteredSources() {
if (!this.sources) return [];
return this.sources.filter(source => {
if (!this.filters.sourceFilter) return true;
return source.ip.toLowerCase().includes(this.filters.sourceFilter.toLowerCase());
});
},
get exportReportsUrl() {
const params = new URLSearchParams();
if (this.filters.exportStartDate) params.set('start_date', this.filters.exportStartDate);
if (this.filters.exportEndDate) params.set('end_date', this.filters.exportEndDate);
const query = params.toString();
const baseUrl = `/api/v1/domains/${encodeURIComponent(this.domainId)}/reports/export`;
return query ? `${baseUrl}?${query}` : baseUrl;
},
get dkimLiveText() {
if (!this.dns.dkim) return 'No DKIM record found for configured selectors';
if (this.dns.dkimSelectors && this.dns.dkimSelectors.length > 0) {
return 'selectors: ' + this.dns.dkimSelectors.join(', ');
}
return 'Verified';
},
get dnsHealthStatusClass() {
if (this.dnsHealth.status === 'healthy') return 'bg-green-100 text-green-700';
if (this.dnsHealth.status === 'degraded') return 'bg-yellow-100 text-yellow-800';
if (this.dnsHealth.status === 'critical') return 'bg-red-100 text-red-700';
return 'bg-base-200 text-base-content/70';
},
recommendationSeverityClass(severity) {
if (severity === 'error') return 'bg-red-500';
if (severity === 'warning') return 'bg-yellow-500';
return 'bg-blue-500';
},
async fetchDomainStats() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/stats`);
if (response.ok) {
const data = await response.json();
this.stats = data;
}
} catch (error) {
console.error('Error fetching domain stats:', error);
}
},
async fetchDNSRecords() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns`);
if (response.ok) {
const data = await response.json();
this.dns = data;
}
} catch (error) {
console.error('Error fetching DNS records:', error);
}
},
async fetchDNSHealth() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/health`);
if (response.ok) {
this.dnsHealth = await response.json();
}
} catch (error) {
console.error('Error fetching DNS health:', error);
}
},
async fetchMtaSts() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/mta-sts`);
if (response.ok) {
this.mtaSts = await response.json();
}
} catch (error) {
console.error('Error fetching MTA-STS posture:', error);
}
},
async fetchSelectors() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
if (response.ok) {
const data = await response.json();
this.selectors = data.selectors || [];
this.reportSelectors = data.report_selectors || [];
}
} catch (error) {
console.error('Error fetching selectors:', error);
}
},
async addSelector() {
this.selectorError = '';
const sel = this.newSelector.trim();
if (!sel) return;
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: sel })
});
if (response.ok) {
this.newSelector = '';
// Refresh selectors (both manual and report) and DNS check
this.fetchSelectors();
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
} else {
const err = await response.json();
this.selectorError = err.detail || 'Failed to add selector';
}
} catch (error) {
this.selectorError = 'Network error — could not add selector';
console.error('Error adding selector:', error);
}
},
async deleteSelector(selector) {
try {
const response = await fetch(
`/api/v1/domains/${this.domainId}/selectors/${encodeURIComponent(selector)}`,
{ method: 'DELETE' }
);
if (response.ok) {
// Refresh selectors (both manual and report) and DNS check
this.fetchSelectors();
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
} else {
console.error('Error deleting selector:', response.status);
}
} catch (error) {
console.error('Error deleting selector:', error);
}
},
async fetchReports() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/reports?limit=10`);
if (response.ok) {
const data = await response.json();
this.reports = data.reports;
this.initComplianceChart(data.compliance_timeline);
}
} catch (error) {
console.error('Error fetching reports:', error);
}
},
async fetchSources() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/sources?days=${this.filters.dateRange}`);
if (response.ok) {
const data = await response.json();
this.sources = data.sources;
}
} catch (error) {
console.error('Error fetching sources:', error);
}
},
initComplianceChart(timelineData) {
if (!timelineData || !timelineData.length) return;
const ctx = document.getElementById('compliance-chart').getContext('2d');
if (this.complianceChart) {
this.complianceChart.destroy();
}
const labels = timelineData.map(item => item.date);
const complianceData = timelineData.map(item => item.compliance_rate);
const failureData = timelineData.map(item => item.failure_rate || 0);
const volumeData = timelineData.map(item => item.volume || item.total || 0);
// Calculate the threshold line data (recommended 98% for policy advancement)
const thresholdData = Array(labels.length).fill(98);
this.complianceChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Compliance Rate',
data: complianceData,
yAxisID: 'yRate',
borderColor: 'rgb(59, 130, 246)', // blue-500
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true,
pointBackgroundColor: 'rgb(59, 130, 246)',
pointRadius: 3,
pointHoverRadius: 5
},
{
label: 'Failure Rate',
data: failureData,
yAxisID: 'yRate',
borderColor: 'rgb(220, 38, 38)',
backgroundColor: 'rgba(220, 38, 38, 0.08)',
tension: 0.4,
fill: false,
pointBackgroundColor: 'rgb(220, 38, 38)',
pointRadius: 3,
pointHoverRadius: 5
},
{
label: 'Message Volume',
type: 'bar',
data: volumeData,
yAxisID: 'yVolume',
backgroundColor: 'rgba(107, 114, 128, 0.22)',
borderColor: 'rgba(107, 114, 128, 0.5)',
borderWidth: 1,
borderRadius: 4,
maxBarThickness: 28
},
{
label: 'Recommended Threshold (98%)',
data: thresholdData,
yAxisID: 'yRate',
borderColor: 'rgba(220, 38, 38, 0.6)', // red-600 with opacity
borderDash: [5, 5],
pointRadius: 0,
borderWidth: 2,
fill: false,
tension: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
yRate: {
type: 'linear',
position: 'left',
beginAtZero: false,
min: Math.max(0, Math.min(...complianceData) - 10), // Dynamic min value
max: 100,
ticks: {
callback: value => value + '%'
},
title: {
display: true,
text: 'Compliance Rate (%)',
font: {
weight: 'bold'
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
yVolume: {
type: 'linear',
position: 'right',
beginAtZero: true,
ticks: {
precision: 0
},
title: {
display: true,
text: 'Messages',
font: {
weight: 'bold'
}
},
grid: {
drawOnChartArea: false
}
},
x: {
title: {
display: true,
text: 'Date',
font: {
weight: 'bold'
}
},
grid: {
display: false
}
}
},
plugins: {
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: {
size: 13
},
bodyFont: {
size: 12
},
padding: 10,
callbacks: {
label: function(context) {
if (context.dataset.label === 'Compliance Rate') {
return `Compliance: ${context.parsed.y}%`;
}
if (context.dataset.label === 'Failure Rate') {
return `Failures: ${context.parsed.y}%`;
}
if (context.dataset.label === 'Message Volume') {
return `Messages: ${context.parsed.y}`;
}
return context.dataset.label;
},
title: function(context) {
return `Date: ${context[0].label}`;
}
}
},
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
annotation: {
annotations: {
box1: {
type: 'box',
yScaleID: 'yRate',
yMin: 90,
yMax: 100,
backgroundColor: 'rgba(34, 197, 94, 0.05)',
borderWidth: 0
}
}
}
}
}
});
},
formatDate(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString();
},
getPassRateClass(rate) {
if (rate >= 90) return 'bg-green-100 text-green-800';
if (rate >= 50) return 'bg-yellow-100 text-yellow-800';
return 'bg-red-100 text-red-800';
}
};
}
</script>
{% endblock %}