1158 lines
61 KiB
HTML
1158 lines
61 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>
|
|
|
|
<!-- Posture Dashboard -->
|
|
<section id="posture-dashboard">
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
{% call card_title() %}Posture Dashboard{% endcall %}
|
|
{% call card_description() %}
|
|
Evidence-linked coverage, drift, and operator playbooks
|
|
{% endcall %}
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="inline-flex items-center rounded px-2 py-1 text-xs font-semibold capitalize"
|
|
:class="postureStatusClass"
|
|
x-text="posture.status || 'checking'"></span>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
<span x-text="posture.score"></span><span>/100</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<div class="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
|
<div class="rounded border border-base-300 p-4">
|
|
<div class="text-sm text-base-content/60">Posture score</div>
|
|
<div class="mt-2 text-4xl font-bold" x-text="posture.score"></div>
|
|
<p class="mt-2 text-sm text-base-content/70" x-text="posture.summary"></p>
|
|
</div>
|
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
<template x-for="item in posture.coverage" :key="item.key">
|
|
<a :href="item.href" class="rounded border border-base-300 p-3 hover:border-primary">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<h3 class="font-semibold" x-text="item.label"></h3>
|
|
<span class="rounded px-2 py-1 text-xs font-semibold capitalize"
|
|
:class="item.status === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
|
x-text="item.status"></span>
|
|
</div>
|
|
<p class="mt-2 text-xs text-base-content/70" x-text="item.message"></p>
|
|
<p class="mt-2 text-xs text-base-content/50">
|
|
<span x-text="item.evidence_count"></span>
|
|
<span> evidence links</span>
|
|
</p>
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-5 grid gap-5 xl:grid-cols-2">
|
|
<div class="space-y-3">
|
|
<h3 class="font-semibold">Recommendations</h3>
|
|
<template x-for="recommendation in posture.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">
|
|
<h4 class="font-semibold" x-text="recommendation.title"></h4>
|
|
<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>
|
|
|
|
<div class="space-y-3" id="posture-changes">
|
|
<h3 class="font-semibold">What Changed</h3>
|
|
<template x-for="change in posture.changes" :key="change.title + change.observed_at">
|
|
<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(change.severity)"></span>
|
|
<div class="min-w-0">
|
|
<h4 class="font-semibold" x-text="change.title"></h4>
|
|
<p class="mt-1 text-sm text-base-content/70" x-text="change.detail"></p>
|
|
<p class="mt-1 text-xs text-base-content/50" x-show="change.observed_at" x-text="formatIsoDate(change.observed_at)"></p>
|
|
<div class="mt-3 space-y-1">
|
|
<template x-for="item in change.evidence" :key="item.label + item.value">
|
|
<div class="text-xs">
|
|
<span class="font-medium" x-text="item.label"></span>:
|
|
<span class="font-mono break-all" x-text="item.value"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-5 grid gap-3 lg:grid-cols-3">
|
|
<template x-for="playbook in posture.playbooks" :key="playbook.key">
|
|
<div class="rounded border border-base-300 p-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 class="font-semibold" x-text="playbook.title"></h3>
|
|
<p class="mt-1 text-sm text-base-content/70" x-text="playbook.summary"></p>
|
|
</div>
|
|
</div>
|
|
<ol class="mt-3 list-decimal space-y-1 pl-4 text-sm">
|
|
<template x-for="step in playbook.steps" :key="step">
|
|
<li x-text="step"></li>
|
|
</template>
|
|
</ol>
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
<template x-for="item in playbook.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>
|
|
</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>
|
|
<div id="bimi-posture">
|
|
<h3 class="font-semibold mb-1 flex items-center">
|
|
<span class="mr-2">BIMI</span>
|
|
<span x-show="bimi.status === 'pass'" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
|
|
<span x-show="bimi.status === 'fail'" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
|
<span x-show="!bimi.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="bimi.dns_record || 'No BIMI TXT record found at default._bimi'">-</div>
|
|
<div class="rounded border border-base-300 p-3 text-sm">
|
|
<div class="grid gap-1">
|
|
<div><span class="font-medium">Selector:</span> <span class="font-mono" x-text="bimi.selector || 'default'"></span></div>
|
|
<div><span class="font-medium">Logo:</span> <span class="font-mono break-all" x-text="bimi.logo_url || 'none'"></span></div>
|
|
<div><span class="font-medium">Certificate:</span> <span class="font-mono break-all" x-text="bimi.certificate_url || 'none'"></span></div>
|
|
</div>
|
|
<template x-if="bimi.errors && bimi.errors.length">
|
|
<p class="mt-2 text-xs text-red-600" x-text="bimi.errors[0]"></p>
|
|
</template>
|
|
<template x-if="bimi.warnings && bimi.warnings.length">
|
|
<p class="mt-2 text-xs text-yellow-700" x-text="bimi.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: []
|
|
},
|
|
posture: {
|
|
status: '',
|
|
score: 0,
|
|
summary: '',
|
|
coverage: [],
|
|
recommendations: [],
|
|
changes: [],
|
|
playbooks: []
|
|
},
|
|
mtaSts: {
|
|
status: '',
|
|
dns_record: '',
|
|
mode: '',
|
|
max_age: null,
|
|
mx: [],
|
|
errors: [],
|
|
warnings: []
|
|
},
|
|
bimi: {
|
|
status: '',
|
|
selector: 'default',
|
|
query_name: '',
|
|
dns_record: '',
|
|
logo_url: '',
|
|
certificate_url: '',
|
|
errors: [],
|
|
warnings: []
|
|
},
|
|
selectors: [],
|
|
reportSelectors: [],
|
|
newSelector: '',
|
|
selectorError: '',
|
|
reports: [],
|
|
sources: [],
|
|
complianceChart: null,
|
|
filters: {
|
|
dateRange: '30',
|
|
sourceFilter: '',
|
|
exportStartDate: '',
|
|
exportEndDate: ''
|
|
},
|
|
|
|
init() {
|
|
this.fetchDomainStats();
|
|
this.fetchDNSRecords();
|
|
this.fetchDNSHealth();
|
|
this.fetchPosture();
|
|
this.fetchMtaSts();
|
|
this.fetchBimi();
|
|
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';
|
|
},
|
|
|
|
get postureStatusClass() {
|
|
if (this.posture.status === 'healthy') return 'bg-green-100 text-green-700';
|
|
if (this.posture.status === 'degraded') return 'bg-yellow-100 text-yellow-800';
|
|
if (this.posture.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 fetchPosture() {
|
|
try {
|
|
const response = await fetch(`/api/v1/domains/${this.domainId}/posture`);
|
|
if (response.ok) {
|
|
this.posture = await response.json();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching posture dashboard:', 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 fetchBimi() {
|
|
try {
|
|
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/bimi`);
|
|
if (response.ok) {
|
|
this.bimi = await response.json();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching BIMI 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.fetchPosture();
|
|
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.fetchPosture();
|
|
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();
|
|
},
|
|
|
|
formatIsoDate(value) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString();
|
|
},
|
|
|
|
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 %}
|