596a9b882e
- check_dkim now returns ALL matching selectors instead of stopping at first match - DomainDNSResult.dkim_selectors is now a List[str] instead of a single Optional[str] - DNSRecordResponse.dkimSelectors is now List[str] - /selectors endpoint now also returns report_selectors (auto-discovered from DMARC reports) - Frontend shows all live-check selectors and auto-discovered selectors as read-only - Updated tests to match new data structures; added tests for multi-selector and report_selectors Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/87d8b8d9-23c3-4d3b-85a3-8e354e62c768 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
745 lines
38 KiB
HTML
745 lines
38 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 -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Compliance Over Time{% endcall %}
|
|
{% call card_description() %}
|
|
DMARC pass rate for the past 30 days
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="h-64">
|
|
<canvas id="compliance-chart"></canvas>
|
|
</div>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- 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>
|
|
<!-- 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 %}
|
|
|
|
<!-- Source Report Table -->
|
|
{% 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() %}Fix{% 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 === 'neutral' || source.spf === 'none'">
|
|
<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>
|
|
{% 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 === 'neutral' || source.dkim === 'none'">
|
|
<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>
|
|
{% 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>
|
|
{% 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.spf_fix_hint">
|
|
<div x-data="{ open: false }" class="relative">
|
|
<button
|
|
@click="open = !open"
|
|
class="btn btn-xs btn-warning"
|
|
title="Show SPF fix suggestion"
|
|
>
|
|
Fix SPF
|
|
</button>
|
|
<div
|
|
x-show="open"
|
|
@click.outside="open = false"
|
|
class="absolute right-0 z-20 mt-1 w-72 bg-base-100 border border-base-300 rounded-lg shadow-lg p-3 text-sm"
|
|
>
|
|
<p class="font-semibold mb-1">SPF Fix Suggestion</p>
|
|
<p class="text-xs text-muted-foreground mb-2">
|
|
Add the following mechanism to your SPF TXT record to authorize this server:
|
|
</p>
|
|
<div class="flex items-center gap-2 bg-base-200 rounded 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 to clipboard"
|
|
>
|
|
<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"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground mt-2">
|
|
Example: <code class="font-mono" x-text="'v=spf1 ' + source.spf_fix_hint + ' ~all'"></code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
</template>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- Recent Reports -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Recent Reports{% endcall %}
|
|
{% 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: []
|
|
},
|
|
selectors: [],
|
|
reportSelectors: [],
|
|
newSelector: '',
|
|
selectorError: '',
|
|
reports: [],
|
|
sources: [],
|
|
complianceChart: null,
|
|
filters: {
|
|
dateRange: '30',
|
|
sourceFilter: ''
|
|
},
|
|
|
|
init() {
|
|
this.fetchDomainStats();
|
|
this.fetchDNSRecords();
|
|
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 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';
|
|
},
|
|
|
|
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 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();
|
|
} 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();
|
|
} 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) 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);
|
|
|
|
// 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,
|
|
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: 'Recommended Threshold (98%)',
|
|
data: thresholdData,
|
|
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: {
|
|
y: {
|
|
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)'
|
|
}
|
|
},
|
|
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}%`;
|
|
}
|
|
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',
|
|
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 %} |