Add domain management, reports, settings, and upload templates
- Implemented domain management page with a list of monitored domains and their DMARC, SPF, and DKIM statuses. - Created reports page with filtering options for domain, report type, and date range, displaying DMARC reports. - Developed settings page for IMAP configuration and DMARC policy management, including form validation and feedback. - Added upload page for DMARC report files with drag-and-drop functionality and file type validation. - Integrated Alpine.js for interactivity and dynamic data handling across all templates.
@@ -0,0 +1,28 @@
|
||||
# DMARQ Environment Variables
|
||||
#
|
||||
# Copy this file to .env and fill in the values for your environment
|
||||
# Example: cp .env.example .env
|
||||
|
||||
# Application Settings
|
||||
PROJECT_NAME="DMARQ"
|
||||
SECRET_KEY="CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION"
|
||||
|
||||
# Database
|
||||
DATABASE_URL="sqlite:///./dmarq.db"
|
||||
|
||||
# IMAP Settings for DMARC Report Retrieval
|
||||
IMAP_SERVER="mail.example.com" # Required for IMAP polling
|
||||
IMAP_PORT=993 # Default for SSL
|
||||
IMAP_USERNAME="dmarc@example.com"
|
||||
IMAP_PASSWORD="your_imap_password"
|
||||
|
||||
# CORS Origins (comma separated)
|
||||
BACKEND_CORS_ORIGINS="http://localhost:3000,http://localhost:5173"
|
||||
|
||||
# Admin User (first-time setup)
|
||||
FIRST_SUPERUSER="admin@example.com"
|
||||
FIRST_SUPERUSER_PASSWORD="adminpassword"
|
||||
|
||||
# Optional Cloudflare API Integration (for Milestone 8)
|
||||
# CLOUDFLARE_API_TOKEN="your_cloudflare_api_token"
|
||||
# CLOUDFLARE_ZONE_ID="your_cloudflare_zone_id"
|
||||
@@ -172,3 +172,17 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
/node_modules
|
||||
|
||||
/backend/node_modules
|
||||
|
||||
# Tailwind build artifacts
|
||||
/backend/app/static/css/output.css
|
||||
/backend/app/static/css/output.css.map
|
||||
|
||||
# NPM log files and cache
|
||||
/backend/npm-debug.log*
|
||||
/backend/yarn-debug.log*
|
||||
/backend/yarn-error.log*
|
||||
/backend/.npm/
|
||||
@@ -2,23 +2,24 @@ FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install required system dependencies
|
||||
# Install required system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt .
|
||||
# Remove Tailwind & DaisyUI build; using CDN links instead
|
||||
|
||||
# Install Python dependencies
|
||||
# Copy Python requirements and install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
# Copy application code including templates and static assets
|
||||
COPY . .
|
||||
|
||||
# Expose the port the app runs on
|
||||
# Expose application port
|
||||
EXPOSE 8080
|
||||
|
||||
# Command to run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.api_v1.endpoints import domains, health, reports, setup
|
||||
from app.api.api_v1.endpoints import domains, health, reports, setup, imap
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -8,4 +8,5 @@ api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
||||
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
|
||||
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
|
||||
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, status, Path, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.report_store import ReportStore
|
||||
@@ -18,6 +19,55 @@ class DomainResponse(DomainBase):
|
||||
emails_count: int = 0
|
||||
compliance_rate: float = 0.0
|
||||
|
||||
class DomainStatsResponse(BaseModel):
|
||||
"""Domain statistics for the domain details page"""
|
||||
complianceRate: float
|
||||
totalEmails: int
|
||||
failedEmails: int
|
||||
reportCount: int
|
||||
|
||||
class DNSRecordResponse(BaseModel):
|
||||
"""DNS record information for a domain"""
|
||||
dmarc: bool
|
||||
dmarcRecord: Optional[str] = None
|
||||
spf: bool
|
||||
spfRecord: Optional[str] = None
|
||||
dkim: bool
|
||||
dkimSelectors: Optional[str] = None
|
||||
|
||||
class TimelinePoint(BaseModel):
|
||||
"""Data point for compliance timeline"""
|
||||
date: str
|
||||
compliance_rate: float
|
||||
|
||||
class ReportEntry(BaseModel):
|
||||
"""Summary of a DMARC report"""
|
||||
id: str
|
||||
org_name: str
|
||||
begin_date: int
|
||||
end_date: int
|
||||
total_emails: int
|
||||
pass_rate: float
|
||||
policy: str
|
||||
|
||||
class SourceEntry(BaseModel):
|
||||
"""Summary of a sending source"""
|
||||
ip: str
|
||||
count: int
|
||||
spf: str
|
||||
dkim: str
|
||||
dmarc: str
|
||||
disposition: str
|
||||
|
||||
class DomainReportsResponse(BaseModel):
|
||||
"""Domain reports with compliance timeline"""
|
||||
reports: List[ReportEntry]
|
||||
compliance_timeline: List[TimelinePoint]
|
||||
|
||||
class DomainSourcesResponse(BaseModel):
|
||||
"""Domain sending sources"""
|
||||
sources: List[SourceEntry]
|
||||
|
||||
class DomainSummaryResponse(BaseModel):
|
||||
"""Domain summary for dashboard"""
|
||||
total_domains: int
|
||||
@@ -119,4 +169,150 @@ async def read_domain(domain_name: str):
|
||||
reports_count=summary.get("reports_processed", 0),
|
||||
emails_count=summary.get("total_count", 0),
|
||||
compliance_rate=summary.get("compliance_rate", 0.0)
|
||||
)
|
||||
|
||||
# New endpoints for domain details page
|
||||
|
||||
@router.get("/{domain_id}/stats", response_model=DomainStatsResponse)
|
||||
async def get_domain_stats(domain_id: str = Path(..., title="The domain ID or name")):
|
||||
"""
|
||||
Get detailed statistics for a specific domain
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
# For Milestone 1, domain_id is simply the domain name
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
summary = store.get_domain_summary(domain_id)
|
||||
total_count = summary.get("total_count", 0)
|
||||
passed_count = summary.get("passed_count", 0)
|
||||
failed_count = total_count - passed_count
|
||||
compliance_rate = summary.get("compliance_rate", 0.0)
|
||||
reports_processed = summary.get("reports_processed", 0)
|
||||
|
||||
return DomainStatsResponse(
|
||||
complianceRate=compliance_rate,
|
||||
totalEmails=total_count,
|
||||
failedEmails=failed_count,
|
||||
reportCount=reports_processed
|
||||
)
|
||||
|
||||
@router.get("/{domain_id}/dns", response_model=DNSRecordResponse)
|
||||
async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID or name")):
|
||||
"""
|
||||
Get DNS records for a specific domain. For Milestone 1,
|
||||
this returns mock data since DNS integration is part of a future milestone.
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# For Milestone 1, return mock DNS record data
|
||||
# In a future milestone, this will be replaced with actual DNS lookups
|
||||
return DNSRecordResponse(
|
||||
dmarc=True,
|
||||
dmarcRecord="v=DMARC1; p=none; rua=mailto:dmarc@example.com; ruf=mailto:forensic@example.com; pct=100",
|
||||
spf=True,
|
||||
spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all",
|
||||
dkim=True,
|
||||
dkimSelectors="selector1, selector2"
|
||||
)
|
||||
|
||||
@router.get("/{domain_id}/reports", response_model=DomainReportsResponse)
|
||||
async def get_domain_reports(
|
||||
domain_id: str = Path(..., title="The domain ID or name"),
|
||||
limit: int = Query(10, title="Maximum number of reports to return")
|
||||
):
|
||||
"""
|
||||
Get recent DMARC reports for a specific domain, along with compliance timeline
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# Get reports for this domain
|
||||
reports = store.get_domain_reports(domain_id, limit=limit)
|
||||
|
||||
# Generate report entries
|
||||
report_entries = []
|
||||
for report in reports:
|
||||
report_entries.append(ReportEntry(
|
||||
id=report.get("report_id", "unknown"),
|
||||
org_name=report.get("org_name", "Unknown Organization"),
|
||||
begin_date=report.get("begin_date", 0),
|
||||
end_date=report.get("end_date", 0),
|
||||
total_emails=report.get("total_count", 0),
|
||||
pass_rate=report.get("pass_rate", 0.0),
|
||||
policy=report.get("policy", "none")
|
||||
))
|
||||
|
||||
# Generate compliance timeline (last 30 days)
|
||||
timeline = []
|
||||
for i in range(30, 0, -1):
|
||||
date = datetime.now() - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
# For Milestone 1, generate some mock data with variation
|
||||
# In future milestone, this will use actual historical data
|
||||
import random
|
||||
compliance_rate = random.uniform(80, 100)
|
||||
|
||||
timeline.append(TimelinePoint(
|
||||
date=date_str,
|
||||
compliance_rate=round(compliance_rate, 1)
|
||||
))
|
||||
|
||||
return DomainReportsResponse(
|
||||
reports=report_entries,
|
||||
compliance_timeline=timeline
|
||||
)
|
||||
|
||||
@router.get("/{domain_id}/sources", response_model=DomainSourcesResponse)
|
||||
async def get_domain_sources(
|
||||
domain_id: str = Path(..., title="The domain ID or name"),
|
||||
days: int = Query(30, title="Number of days to look back")
|
||||
):
|
||||
"""
|
||||
Get sending sources for a specific domain
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# Get sending sources for this domain
|
||||
sources = store.get_domain_sources(domain_id, days=days)
|
||||
|
||||
source_entries = []
|
||||
for source in sources:
|
||||
source_entries.append(SourceEntry(
|
||||
ip=source.get("source_ip", "unknown"),
|
||||
count=source.get("count", 0),
|
||||
spf=source.get("spf_result", "unknown"),
|
||||
dkim=source.get("dkim_result", "unknown"),
|
||||
dmarc="pass" if source.get("spf_result") == "pass" or source.get("dkim_result") == "pass" else "fail",
|
||||
disposition=source.get("disposition", "none")
|
||||
))
|
||||
|
||||
return DomainSourcesResponse(
|
||||
sources=source_entries
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.imap_client import IMAPClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_imap_connection(
|
||||
server: str = None,
|
||||
port: int = 993,
|
||||
username: str = None,
|
||||
password: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Test connection to an IMAP server
|
||||
"""
|
||||
imap_client = IMAPClient(
|
||||
server=server,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password
|
||||
)
|
||||
|
||||
success, message = imap_client.test_connection()
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch-reports")
|
||||
async def fetch_imap_reports(
|
||||
background_tasks: BackgroundTasks,
|
||||
days: int = 7,
|
||||
delete_emails: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch DMARC reports from the configured IMAP mailbox
|
||||
"""
|
||||
imap_client = IMAPClient(delete_emails=delete_emails)
|
||||
|
||||
# Run in background if it might take a while
|
||||
if days > 14:
|
||||
background_tasks.add_task(imap_client.fetch_reports, days)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Background task started to fetch {days} days of reports",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Otherwise run immediately
|
||||
results = imap_client.fetch_reports(days=days)
|
||||
|
||||
return {
|
||||
"success": results["success"],
|
||||
"processed_emails": results["processed"],
|
||||
"reports_found": results["reports_found"],
|
||||
"new_domains": results["new_domains"],
|
||||
"errors": results["errors"] if "errors" in results and results["errors"] else None,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
@@ -1,15 +1,67 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.api.api_v1.api import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.services.imap_client import IMAPClient
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Global variables for background task management
|
||||
background_task = None
|
||||
last_check_time = None
|
||||
|
||||
|
||||
async def scheduled_imap_polling():
|
||||
"""Background task for periodically checking IMAP for new DMARC reports"""
|
||||
global last_check_time
|
||||
|
||||
try:
|
||||
# How often to check for emails (in seconds)
|
||||
check_interval = 3600 # Default: 1 hour
|
||||
|
||||
while True:
|
||||
logger.info("Starting scheduled IMAP polling for DMARC reports")
|
||||
|
||||
try:
|
||||
# Create IMAP client and fetch reports
|
||||
imap_client = IMAPClient(delete_emails=False)
|
||||
results = imap_client.fetch_reports(days=9999)
|
||||
|
||||
# Update last check time
|
||||
last_check_time = datetime.now()
|
||||
|
||||
if results["success"]:
|
||||
logger.info(f"IMAP polling completed: {results['processed']} emails processed, "
|
||||
f"{results['reports_found']} reports found")
|
||||
|
||||
# If new domains were found, log them
|
||||
if results["new_domains"]:
|
||||
logger.info(f"New domains found: {', '.join(results['new_domains'])}")
|
||||
|
||||
else:
|
||||
logger.error(f"IMAP polling failed: {results.get('error', 'Unknown error')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in IMAP polling task: {str(e)}")
|
||||
|
||||
# Wait for the next check interval
|
||||
await asyncio.sleep(check_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("IMAP polling task cancelled")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application"""
|
||||
@@ -35,6 +87,31 @@ def create_app() -> FastAPI:
|
||||
# Mount static files directory
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
||||
|
||||
# Set up event handlers for startup and shutdown
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize background tasks on application startup"""
|
||||
global background_task
|
||||
|
||||
# Check if IMAP credentials are configured
|
||||
if all([settings.IMAP_SERVER, settings.IMAP_USERNAME, settings.IMAP_PASSWORD]):
|
||||
logger.info("Starting IMAP polling background task")
|
||||
background_task = asyncio.create_task(scheduled_imap_polling())
|
||||
else:
|
||||
logger.warning("IMAP credentials not fully configured, polling disabled")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Clean up background tasks on application shutdown"""
|
||||
global background_task
|
||||
if background_task:
|
||||
logger.info("Cancelling IMAP polling background task")
|
||||
background_task.cancel()
|
||||
try:
|
||||
await background_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -46,33 +123,110 @@ templates = Jinja2Templates(directory=templates_dir)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Root endpoint that serves the main HTML page"""
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
async def dashboard(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
# Frontend routes that should return the SPA template
|
||||
# Individual page routes
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
@app.get("/setup", response_class=HTMLResponse)
|
||||
@app.get("/domains", response_class=HTMLResponse)
|
||||
@app.get("/reports", response_class=HTMLResponse)
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def serve_spa(request: Request):
|
||||
"""Serve the SPA for frontend routes"""
|
||||
async def dashboard(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
||||
"dashboard.html", {"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
|
||||
# Fallback for other routes (404 handling)
|
||||
@app.get("/{path:path}", response_class=HTMLResponse)
|
||||
async def catch_all(request: Request, path: str):
|
||||
"""Catch-all route that serves the main HTML page for client-side routing"""
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
"login.html", {"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
|
||||
@app.get("/setup", response_class=HTMLResponse)
|
||||
async def setup(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"setup.html", {"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
|
||||
@app.get("/domains", response_class=HTMLResponse)
|
||||
async def domains(request: Request):
|
||||
return templates.TemplateResponse("domains.html", {"request": request})
|
||||
|
||||
@app.get("/domain/{domain_id}", response_class=HTMLResponse)
|
||||
async def domain_details(request: Request, domain_id: str):
|
||||
"""View detailed reports for a specific domain"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
if domain_id not in domains:
|
||||
# Domain not found, redirect to domains list
|
||||
return templates.TemplateResponse(
|
||||
"domains.html",
|
||||
{"request": request, "error": f"Domain {domain_id} not found"}
|
||||
)
|
||||
|
||||
domain_summary = store.get_domain_summary(domain_id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"domain_details.html",
|
||||
{
|
||||
"request": request,
|
||||
"domain_id": domain_id,
|
||||
"domain": {
|
||||
"name": domain_id,
|
||||
"description": "", # Add description if available
|
||||
"policy": domain_summary.get("policy", "unknown")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/reports", response_class=HTMLResponse)
|
||||
async def reports(request: Request):
|
||||
return templates.TemplateResponse("reports.html", {"request": request})
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
|
||||
@app.get("/upload", response_class=HTMLResponse)
|
||||
async def upload_page(request: Request):
|
||||
return templates.TemplateResponse("upload.html", {"request": request})
|
||||
|
||||
|
||||
# API endpoint to manually trigger IMAP polling
|
||||
@app.post("/api/v1/admin/trigger-poll")
|
||||
async def trigger_imap_poll(background_tasks: BackgroundTasks):
|
||||
"""Manually trigger IMAP polling (admin only)"""
|
||||
global last_check_time
|
||||
|
||||
try:
|
||||
# Create IMAP client and fetch reports
|
||||
imap_client = IMAPClient(delete_emails=False)
|
||||
results = imap_client.fetch_reports(days=7)
|
||||
|
||||
# Update last check time
|
||||
last_check_time = datetime.now()
|
||||
|
||||
return {
|
||||
"success": results["success"],
|
||||
"timestamp": last_check_time.isoformat(),
|
||||
"processed": results["processed"],
|
||||
"reports_found": results["reports_found"],
|
||||
"new_domains": results["new_domains"]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering IMAP poll: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# API endpoint to check status of IMAP polling
|
||||
@app.get("/api/v1/admin/poll-status")
|
||||
async def get_poll_status():
|
||||
"""Get the status of IMAP polling"""
|
||||
global last_check_time
|
||||
|
||||
return {
|
||||
"is_running": background_task is not None and not background_task.done(),
|
||||
"last_check": last_check_time.isoformat() if last_check_time else None
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import imaplib
|
||||
import email
|
||||
import os
|
||||
import logging
|
||||
import tempfile
|
||||
from email.header import decode_header
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services.dmarc_parser import DMARCParser
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
# Setup logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class IMAPClient:
|
||||
"""
|
||||
Client for retrieving DMARC reports from an IMAP mailbox
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
server: str = None,
|
||||
port: int = None,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
delete_emails: bool = False):
|
||||
"""
|
||||
Initialize the IMAP client with credentials
|
||||
|
||||
Args:
|
||||
server: IMAP server hostname (if None, uses settings)
|
||||
port: IMAP server port (if None, uses settings)
|
||||
username: IMAP username (if None, uses settings)
|
||||
password: IMAP password (if None, uses settings)
|
||||
delete_emails: Whether to delete emails after processing (default: False)
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
self.server = server or settings.IMAP_SERVER
|
||||
self.port = port or settings.IMAP_PORT
|
||||
self.username = username or settings.IMAP_USERNAME
|
||||
self.password = password or settings.IMAP_PASSWORD
|
||||
self.delete_emails = delete_emails
|
||||
|
||||
self.report_store = ReportStore.get_instance()
|
||||
|
||||
if not all([self.server, self.username, self.password]):
|
||||
logger.warning("IMAP credentials not fully configured")
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test the IMAP connection
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not all([self.server, self.username, self.password]):
|
||||
return False, "IMAP credentials not fully configured"
|
||||
|
||||
try:
|
||||
# Create IMAP4 connection
|
||||
mail = imaplib.IMAP4_SSL(self.server, self.port)
|
||||
# Login
|
||||
mail.login(self.username, self.password)
|
||||
# List mailboxes
|
||||
mail.list()
|
||||
# Select inbox
|
||||
mail.select('INBOX')
|
||||
# Logout
|
||||
mail.logout()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP connection test failed: {str(e)}")
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
|
||||
def fetch_reports(self, days: int = 7) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch and process DMARC reports from the configured mailbox
|
||||
|
||||
Args:
|
||||
days: Number of days to look back for emails
|
||||
|
||||
Returns:
|
||||
Dictionary with stats about processing results
|
||||
"""
|
||||
if not all([self.server, self.username, self.password]):
|
||||
logger.error("IMAP credentials not fully configured")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "IMAP credentials not configured",
|
||||
"processed": 0
|
||||
}
|
||||
|
||||
stats = {
|
||||
"success": True,
|
||||
"processed": 0,
|
||||
"reports_found": 0,
|
||||
"new_domains": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
# Connect to the mail server
|
||||
mail = imaplib.IMAP4_SSL(self.server, self.port)
|
||||
mail.login(self.username, self.password)
|
||||
mail.select('INBOX')
|
||||
|
||||
# Calculate the date range for search
|
||||
date_since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
|
||||
|
||||
# Search for all emails containing possible DMARC reports
|
||||
search_criteria = f'(SINCE {date_since})'
|
||||
status, data = mail.search(None, search_criteria)
|
||||
|
||||
if status != 'OK':
|
||||
logger.error("Error searching mailbox")
|
||||
stats["success"] = False
|
||||
stats["error"] = "Error searching mailbox"
|
||||
mail.logout()
|
||||
return stats
|
||||
|
||||
# Get list of email IDs
|
||||
email_ids = data[0].split()
|
||||
|
||||
# Track domains before processing to identify new ones
|
||||
domains_before = set(self.report_store.get_domains())
|
||||
|
||||
# Process each email
|
||||
for email_id in email_ids:
|
||||
try:
|
||||
# Fetch the email
|
||||
status, msg_data = mail.fetch(email_id, '(RFC822)')
|
||||
|
||||
if status != 'OK':
|
||||
logger.error(f"Error fetching email ID {email_id}")
|
||||
continue
|
||||
|
||||
# Parse the email
|
||||
raw_email = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw_email)
|
||||
|
||||
# Check if this email might contain DMARC reports
|
||||
if self._is_dmarc_report_email(msg):
|
||||
# Process attachments
|
||||
reports_found = self._process_attachments(msg)
|
||||
stats["reports_found"] += reports_found
|
||||
|
||||
# Mark email as read
|
||||
mail.store(email_id, '+FLAGS', '\\Seen')
|
||||
|
||||
# Delete email if configured
|
||||
if self.delete_emails:
|
||||
mail.store(email_id, '+FLAGS', '\\Deleted')
|
||||
|
||||
stats["processed"] += 1
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing email ID {email_id}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
# Actually remove emails marked for deletion
|
||||
if self.delete_emails:
|
||||
mail.expunge()
|
||||
|
||||
# Logout
|
||||
mail.logout()
|
||||
|
||||
# Identify new domains
|
||||
domains_after = set(self.report_store.get_domains())
|
||||
stats["new_domains"] = list(domains_after - domains_before)
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching DMARC reports: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Error connecting to mailbox: {str(e)}",
|
||||
"processed": 0
|
||||
}
|
||||
|
||||
def _is_dmarc_report_email(self, msg: email.message.Message) -> bool:
|
||||
"""
|
||||
Check if an email likely contains DMARC reports
|
||||
|
||||
Args:
|
||||
msg: Email message object
|
||||
|
||||
Returns:
|
||||
True if the email is likely a DMARC report, False otherwise
|
||||
"""
|
||||
# Get email subject
|
||||
subject = ""
|
||||
if "Subject" in msg:
|
||||
subject = self._decode_email_header(msg["Subject"])
|
||||
|
||||
# Get email from
|
||||
from_addr = ""
|
||||
if "From" in msg:
|
||||
from_addr = self._decode_email_header(msg["From"])
|
||||
|
||||
# Common keywords in DMARC report emails
|
||||
dmarc_keywords = [
|
||||
"dmarc", "aggregate", "report", "rua",
|
||||
"authentication", "domain", "failure"
|
||||
]
|
||||
|
||||
# Common senders of DMARC reports
|
||||
dmarc_senders = [
|
||||
"noreply@", "dmarc-noreply@", "postmaster@",
|
||||
"microsoft.com", "google.com", "yahoo.com",
|
||||
"hotmail.com", "outlook.com", "mail.ru"
|
||||
]
|
||||
|
||||
# Check if subject contains DMARC keywords
|
||||
if any(keyword in subject.lower() for keyword in dmarc_keywords):
|
||||
return True
|
||||
|
||||
# Check if sender matches common DMARC report senders
|
||||
if any(sender in from_addr.lower() for sender in dmarc_senders):
|
||||
return True
|
||||
|
||||
# Check for attachments with typical DMARC report filenames
|
||||
return self._has_dmarc_attachments(msg)
|
||||
|
||||
def _decode_email_header(self, header: str) -> str:
|
||||
"""
|
||||
Decode an email header that might contain non-ASCII characters
|
||||
|
||||
Args:
|
||||
header: Email header string
|
||||
|
||||
Returns:
|
||||
Decoded header text
|
||||
"""
|
||||
decoded_parts = []
|
||||
for text, encoding in decode_header(header):
|
||||
if isinstance(text, bytes):
|
||||
if encoding:
|
||||
decoded_parts.append(text.decode(encoding or 'utf-8', errors='replace'))
|
||||
else:
|
||||
decoded_parts.append(text.decode('utf-8', errors='replace'))
|
||||
else:
|
||||
decoded_parts.append(text)
|
||||
|
||||
return " ".join(decoded_parts)
|
||||
|
||||
def _has_dmarc_attachments(self, msg: email.message.Message) -> bool:
|
||||
"""
|
||||
Check if the email has attachments that might be DMARC reports
|
||||
|
||||
Args:
|
||||
msg: Email message object
|
||||
|
||||
Returns:
|
||||
True if the email has potential DMARC report attachments
|
||||
"""
|
||||
for part in msg.walk():
|
||||
content_disposition = part.get_content_disposition()
|
||||
if content_disposition == 'attachment':
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
# Decode filename if needed
|
||||
filename = self._decode_email_header(filename)
|
||||
|
||||
# Check file extension
|
||||
if (filename.lower().endswith('.xml') or
|
||||
filename.lower().endswith('.zip') or
|
||||
filename.lower().endswith('.gz') or
|
||||
filename.lower().endswith('.gzip')):
|
||||
return True
|
||||
|
||||
# Check content type
|
||||
content_type = part.get_content_type()
|
||||
if (content_type == 'application/zip' or
|
||||
content_type == 'application/gzip' or
|
||||
content_type == 'application/x-gzip' or
|
||||
content_type == 'application/xml' or
|
||||
content_type == 'text/xml'):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _process_attachments(self, msg: email.message.Message) -> int:
|
||||
"""
|
||||
Process email attachments that might be DMARC reports
|
||||
|
||||
Args:
|
||||
msg: Email message object
|
||||
|
||||
Returns:
|
||||
Number of DMARC reports found and processed
|
||||
"""
|
||||
reports_found = 0
|
||||
|
||||
for part in msg.walk():
|
||||
content_disposition = part.get_content_disposition()
|
||||
|
||||
if content_disposition == 'attachment':
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
# Decode filename if needed
|
||||
filename = self._decode_email_header(filename)
|
||||
|
||||
# Check if it's a likely DMARC report file
|
||||
if (filename.lower().endswith('.xml') or
|
||||
filename.lower().endswith('.zip') or
|
||||
filename.lower().endswith('.gz') or
|
||||
filename.lower().endswith('.gzip')):
|
||||
|
||||
try:
|
||||
# Get attachment content
|
||||
content = part.get_payload(decode=True)
|
||||
|
||||
# Parse the DMARC report
|
||||
report = DMARCParser.parse_file(content, filename)
|
||||
|
||||
# Add the report to the store
|
||||
self.report_store.add_report(report)
|
||||
|
||||
reports_found += 1
|
||||
logger.info(f"Successfully processed DMARC report: {filename}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing attachment {filename}: {str(e)}")
|
||||
|
||||
return reports_found
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class ReportStore:
|
||||
"""
|
||||
@@ -29,6 +30,8 @@ class ReportStore:
|
||||
self.domain_reports: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Domain -> summary stats
|
||||
self.domain_summary: Dict[str, Dict[str, Any]] = {}
|
||||
# Domain -> sources (sending IPs)
|
||||
self.domain_sources: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
|
||||
def add_report(self, report: Dict[str, Any]) -> None:
|
||||
"""
|
||||
@@ -48,6 +51,7 @@ class ReportStore:
|
||||
"failed_count": 0,
|
||||
"reports_processed": 0,
|
||||
}
|
||||
self.domain_sources[domain] = {}
|
||||
|
||||
# Add the new report
|
||||
self.domain_reports[domain].append(report)
|
||||
@@ -59,6 +63,28 @@ class ReportStore:
|
||||
self.domain_summary[domain]["failed_count"] += summary.get("failed_count", 0)
|
||||
self.domain_summary[domain]["reports_processed"] += 1
|
||||
|
||||
# Set policy from the latest report
|
||||
if "policy" in report:
|
||||
self.domain_summary[domain]["policy"] = report["policy"]
|
||||
|
||||
# Update source data
|
||||
report_records = report.get("records", [])
|
||||
for record in report_records:
|
||||
source_ip = record.get("source_ip", "unknown")
|
||||
if source_ip not in self.domain_sources[domain]:
|
||||
self.domain_sources[domain][source_ip] = {
|
||||
"count": 0,
|
||||
"spf_result": "unknown",
|
||||
"dkim_result": "unknown",
|
||||
"disposition": "none"
|
||||
}
|
||||
|
||||
# Update source counts and results
|
||||
self.domain_sources[domain][source_ip]["count"] += record.get("count", 0)
|
||||
self.domain_sources[domain][source_ip]["spf_result"] = record.get("spf", "unknown")
|
||||
self.domain_sources[domain][source_ip]["dkim_result"] = record.get("dkim", "unknown")
|
||||
self.domain_sources[domain][source_ip]["disposition"] = record.get("disposition", "none")
|
||||
|
||||
# Calculate compliance rate (percentage of passing emails)
|
||||
if self.domain_summary[domain]["total_count"] > 0:
|
||||
pass_rate = (
|
||||
@@ -96,14 +122,71 @@ class ReportStore:
|
||||
"""
|
||||
return self.domain_summary
|
||||
|
||||
def get_domain_reports(self, domain: str) -> List[Dict[str, Any]]:
|
||||
def get_domain_reports(self, domain: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all reports for a domain
|
||||
|
||||
Args:
|
||||
domain: Domain name
|
||||
limit: Optional limit on number of reports to return
|
||||
|
||||
Returns:
|
||||
List of reports or empty list if domain not found
|
||||
"""
|
||||
return self.domain_reports.get(domain, [])
|
||||
reports = self.domain_reports.get(domain, [])
|
||||
|
||||
# Sort reports by date (most recent first)
|
||||
sorted_reports = sorted(
|
||||
reports,
|
||||
key=lambda r: r.get("end_date", 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Calculate pass rate for each report
|
||||
for report in sorted_reports:
|
||||
total = report.get("summary", {}).get("total_count", 0)
|
||||
passed = report.get("summary", {}).get("passed_count", 0)
|
||||
if total > 0:
|
||||
report["pass_rate"] = round((passed / total) * 100, 1)
|
||||
else:
|
||||
report["pass_rate"] = 0
|
||||
|
||||
# Apply limit if provided
|
||||
if limit is not None:
|
||||
return sorted_reports[:limit]
|
||||
return sorted_reports
|
||||
|
||||
def get_domain_sources(self, domain: str, days: int = 30) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get sending sources for a domain
|
||||
|
||||
Args:
|
||||
domain: Domain name
|
||||
days: Number of days to look back
|
||||
|
||||
Returns:
|
||||
List of source entries or empty list if domain not found
|
||||
"""
|
||||
if domain not in self.domain_sources:
|
||||
return []
|
||||
|
||||
# For Milestone 1, we don't filter by date
|
||||
# In a future milestone, we'll add date-based filtering
|
||||
sources = []
|
||||
for ip, data in self.domain_sources[domain].items():
|
||||
source_entry = {
|
||||
"source_ip": ip,
|
||||
**data
|
||||
}
|
||||
sources.append(source_entry)
|
||||
|
||||
# Sort sources by count (highest first)
|
||||
return sorted(sources, key=lambda s: s["count"], reverse=True)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Clear all data in the store
|
||||
"""
|
||||
self.domain_reports = {}
|
||||
self.domain_summary = {}
|
||||
self.domain_sources = {}
|
||||
@@ -1,161 +1,249 @@
|
||||
/**
|
||||
* DMARQ Integrated Frontend Styles
|
||||
* DMARQ Frontend Styles with DaisyUI
|
||||
*/
|
||||
|
||||
/* Setup Wizard Styles */
|
||||
/* Base style imports */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap');
|
||||
|
||||
/* Include Tailwind layers */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Load DaisyUI plugins */
|
||||
@plugin "./daisyui.js";
|
||||
@plugin "./daisyui-theme.js";
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
}
|
||||
|
||||
/* Theme initialization */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--rounded-box: 0.5rem;
|
||||
--rounded-btn: 0.25rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Custom component overrides and extensions */
|
||||
|
||||
/* Updated color scheme based on DMARQ branding */
|
||||
:root {
|
||||
--color-primary: #1A237E; /* Deep Blue */
|
||||
--color-secondary: #00ACC1; /* Vibrant Teal */
|
||||
--color-accent: #FF7043; /* Bright Orange */
|
||||
--color-neutral-light: #F5F5F5; /* Light Gray */
|
||||
--color-neutral-dark: #212121; /* Dark Gray */
|
||||
}
|
||||
|
||||
/* Apply colors to DaisyUI components */
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-neutral-light);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-neutral-light);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-neutral-light);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-neutral-light);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--color-neutral-light);
|
||||
color: var(--color-neutral-dark);
|
||||
}
|
||||
|
||||
/* Override default DaisyUI styles */
|
||||
.bg-primary {
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
background-color: var(--color-secondary) !important;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-secondary) !important;
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background-color: var(--color-accent) !important;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--color-accent) !important;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background-color: #FFFFFF; /* bg-base-100 */
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #E0E0E0; /* border-r border-base-300 */
|
||||
width: 16rem;
|
||||
height: 100vh;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@apply p-4 border-b border-base-300;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
@apply space-y-1 p-2;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
@apply flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors hover:bg-base-200;
|
||||
}
|
||||
|
||||
.sidebar-nav-item-active {
|
||||
@apply bg-primary text-primary-content;
|
||||
}
|
||||
|
||||
/* Dashboard stats */
|
||||
.stat-card {
|
||||
@apply card bg-base-100 shadow-sm;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
@apply text-sm font-medium opacity-70;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
.stat-description {
|
||||
@apply text-xs opacity-70;
|
||||
}
|
||||
|
||||
/* Setup Wizard Progress */
|
||||
.setup-progress {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
@apply flex items-center mb-8;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.setup-step.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
.setup-step-circle {
|
||||
@apply w-8 h-8 rounded-full bg-base-300 flex items-center justify-center opacity-70;
|
||||
}
|
||||
|
||||
.setup-step.completed {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
.setup-step-active .setup-step-circle {
|
||||
@apply bg-primary text-primary-content;
|
||||
}
|
||||
|
||||
.setup-progress:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #e5e7eb;
|
||||
z-index: 0;
|
||||
.setup-step-completed .setup-step-circle {
|
||||
@apply bg-success text-success-content;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
.setup-step-line {
|
||||
@apply w-16 h-1 bg-base-300;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
.setup-step-completed .setup-step-line {
|
||||
@apply bg-success;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Stats and Dashboard Styles */
|
||||
.stat {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dashboard-stats .card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Additional Utility Classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Navigation Styles */
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar ul li a {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar ul li a:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.sidebar ul li a.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
/* Responsive layout */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: static;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 16rem; /* ml-64 */
|
||||
padding: 1.5rem; /* p-6 */
|
||||
}
|
||||
|
||||
/* Dashboard specific styles */
|
||||
.dashboard-header {
|
||||
@apply flex items-center justify-between mb-6;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
/* Dashboard statistics styling */
|
||||
.stats-grid {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6;
|
||||
}
|
||||
|
||||
/* Domain Compliance table */
|
||||
.domain-compliance-table {
|
||||
@apply table table-zebra w-full rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
/* IMAP status indicator */
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center gap-2;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@apply w-2.5 h-2.5 rounded-full;
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
@apply bg-success;
|
||||
}
|
||||
|
||||
.status-dot.stopped {
|
||||
@apply bg-error;
|
||||
}
|
||||
|
||||
/* Rating status colors */
|
||||
.pass-rate-high {
|
||||
@apply badge badge-success;
|
||||
}
|
||||
|
||||
.pass-rate-medium {
|
||||
@apply badge badge-warning;
|
||||
}
|
||||
|
||||
.pass-rate-low {
|
||||
@apply badge badge-error;
|
||||
}
|
||||
|
||||
/* Toggle theme button */
|
||||
.toggle-theme {
|
||||
@apply btn btn-ghost btn-circle;
|
||||
}
|
||||
|
||||
/* Dashboard chart containers */
|
||||
.chart-container {
|
||||
height: 16rem; /* h-64 */
|
||||
@apply w-full;
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 935 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 885 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,44 @@
|
||||
{% macro alert(variant='info', dismissible=False, id='') %}
|
||||
<div
|
||||
class="alert alert-{{ variant }} shadow-lg mb-4"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
{% if dismissible %}x-data="{ open: true }" x-show="open"{% endif %}
|
||||
>
|
||||
{% if variant == 'info' %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info flex-shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
{% elif variant == 'success' %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-success flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{% elif variant == 'warning' %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-warning flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
{% elif variant == 'error' %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-error flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
|
||||
{% if dismissible %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
x-on:click="open = false"
|
||||
aria-label="Close"
|
||||
>
|
||||
<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"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro alert_title() %}
|
||||
<h5 class="font-medium text-base">
|
||||
{{ caller() }}
|
||||
</h5>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro alert_description() %}
|
||||
<div class="text-sm">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% macro button(variant='default', size='md', class='', type='button', disabled=False) %}
|
||||
<button
|
||||
type="{{ type }}"
|
||||
class="btn btn-{{ variant }} btn-{{ size }} {{ class }}"
|
||||
{% if disabled %}disabled{% endif %}
|
||||
>
|
||||
{{ caller() }}
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro button_link(href='#', variant='default', size='md', class='') %}
|
||||
<a href="{{ href }}" class="btn btn-{{ variant }} btn-{{ size }} {{ class }}">
|
||||
{{ caller() }}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% macro card() %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card_header() %}
|
||||
<div class="card-body pt-6 pb-2">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card_title(text='') %}
|
||||
<h3 class="card-title text-lg font-bold">
|
||||
{% if text %}{{ text }}{% else %}{{ caller() }}{% endif %}
|
||||
</h3>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card_description(text='') %}
|
||||
<p class="text-sm opacity-70 mt-1">
|
||||
{% if text %}{{ text }}{% else %}{{ caller() }}{% endif %}
|
||||
</p>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card_content() %}
|
||||
<div class="card-body py-4">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card_footer() %}
|
||||
<div class="card-actions justify-end p-4 pt-0">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% macro input(
|
||||
type='text',
|
||||
name='',
|
||||
id='',
|
||||
value='',
|
||||
placeholder='',
|
||||
required=False,
|
||||
disabled=False,
|
||||
readonly=False,
|
||||
class='',
|
||||
min='',
|
||||
max='',
|
||||
step=''
|
||||
) %}
|
||||
<input
|
||||
type="{{ type }}"
|
||||
name="{{ name }}"
|
||||
id="{{ id or name }}"
|
||||
value="{{ value }}"
|
||||
placeholder="{{ placeholder }}"
|
||||
class="input input-bordered w-full {{ class }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{% if min %}min="{{ min }}"{% endif %}
|
||||
{% if max %}max="{{ max }}"{% endif %}
|
||||
{% if step %}step="{{ step }}"{% endif %}
|
||||
>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label(for='', required=False, class='') %}
|
||||
<label
|
||||
for="{{ for }}"
|
||||
class="label"
|
||||
>
|
||||
<span class="label-text {{ class }}">{{ caller() }}{% if required %} <span class="text-error">*</span>{% endif %}</span>
|
||||
</label>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro form_group() %}
|
||||
<div class="form-control w-full mb-4">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% macro table(class='', zebra=True) %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table {% if zebra %}table-zebra{% endif %} {{ class }}">
|
||||
{{ caller() }}
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro thead() %}
|
||||
<thead>
|
||||
{{ caller() }}
|
||||
</thead>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tbody() %}
|
||||
<tbody>
|
||||
{{ caller() }}
|
||||
</tbody>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tr(class='') %}
|
||||
<tr class="{{ class }}">
|
||||
{{ caller() }}
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro th(class='') %}
|
||||
<th class="{{ class }}">
|
||||
{{ caller() }}
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro td(class='', colspan='', rowspan='') %}
|
||||
<td class="{{ class }}" {% if colspan %}colspan="{{ colspan }}"{% endif %} {% if rowspan %}rowspan="{{ rowspan }}"{% endif %}>
|
||||
{{ caller() }}
|
||||
</td>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,408 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}DMARQ - DaisyUI Components Demo{% endblock %}
|
||||
{% block page_title %}DaisyUI Components Demo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-12">
|
||||
<!-- Introduction -->
|
||||
<section>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">DaisyUI Components for DMARQ</h2>
|
||||
<p class="text-lg mt-2">
|
||||
This page demonstrates all the available DaisyUI components that can be used in the DMARQ application.
|
||||
All components are styled using the custom DMARQ theme.
|
||||
</p>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button class="btn btn-primary">Primary</button>
|
||||
<button class="btn btn-secondary">Secondary</button>
|
||||
<button class="btn btn-accent">Accent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Theme Colors -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Theme Colors</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="p-4 bg-primary text-primary-content rounded-lg">primary</div>
|
||||
<div class="p-4 bg-secondary text-secondary-content rounded-lg">secondary</div>
|
||||
<div class="p-4 bg-accent text-accent-content rounded-lg">accent</div>
|
||||
<div class="p-4 bg-neutral text-neutral-content rounded-lg">neutral</div>
|
||||
<div class="p-4 bg-base-100 border border-base-300 rounded-lg">base-100</div>
|
||||
<div class="p-4 bg-base-200 rounded-lg">base-200</div>
|
||||
<div class="p-4 bg-base-300 rounded-lg">base-300</div>
|
||||
<div class="p-4 bg-info text-info-content rounded-lg">info</div>
|
||||
<div class="p-4 bg-success text-success-content rounded-lg">success</div>
|
||||
<div class="p-4 bg-warning text-warning-content rounded-lg">warning</div>
|
||||
<div class="p-4 bg-error text-error-content rounded-lg">error</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Buttons -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Buttons</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="text-xl font-medium mb-4">Button Variants</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-8">
|
||||
<button class="btn">Default</button>
|
||||
<button class="btn btn-neutral">Neutral</button>
|
||||
<button class="btn btn-primary">Primary</button>
|
||||
<button class="btn btn-secondary">Secondary</button>
|
||||
<button class="btn btn-accent">Accent</button>
|
||||
<button class="btn btn-ghost">Ghost</button>
|
||||
<button class="btn btn-link">Link</button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-medium mb-4">Button States</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-8">
|
||||
<button class="btn btn-primary">Normal</button>
|
||||
<button class="btn btn-primary btn-outline">Outline</button>
|
||||
<button class="btn btn-primary" disabled>Disabled</button>
|
||||
<button class="btn btn-primary loading">Loading</button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-medium mb-4">Button Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="btn btn-primary btn-xs">Extra Small</button>
|
||||
<button class="btn btn-primary btn-sm">Small</button>
|
||||
<button class="btn btn-primary">Normal</button>
|
||||
<button class="btn btn-primary btn-lg">Large</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alerts -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Alerts</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info flex-shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<div>
|
||||
<h5 class="font-medium text-base">Information</h5>
|
||||
<div class="text-sm">This is an informational alert.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-success flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<div>
|
||||
<h5 class="font-medium text-base">Success!</h5>
|
||||
<div class="text-sm">Your action was completed successfully.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-warning flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<div>
|
||||
<h5 class="font-medium text-base">Warning!</h5>
|
||||
<div class="text-sm">This action requires your attention.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-error flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<div>
|
||||
<h5 class="font-medium text-base">Error!</h5>
|
||||
<div class="text-sm">There was an error processing your request.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Forms -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Forms</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">First name</span>
|
||||
<span class="label-text-alt">Required</span>
|
||||
</label>
|
||||
<input type="text" placeholder="John" class="input input-bordered w-full" />
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Last name</span>
|
||||
</label>
|
||||
<input type="text" placeholder="Doe" class="input input-bordered w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Email</span>
|
||||
</label>
|
||||
<input type="email" placeholder="john.doe@example.com" class="input input-bordered w-full" />
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Phone</span>
|
||||
</label>
|
||||
<input type="tel" placeholder="123-456-7890" class="input input-bordered w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Message</span>
|
||||
</label>
|
||||
<textarea class="textarea textarea-bordered h-24" placeholder="Your message here"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Agree to terms and conditions</span>
|
||||
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-4">
|
||||
<button type="button" class="btn btn-ghost">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tables -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Tables</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<!-- head -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- rows -->
|
||||
<tr>
|
||||
<th>1</th>
|
||||
<td>John Doe</td>
|
||||
<td>john@example.com</td>
|
||||
<td>Admin</td>
|
||||
<td><span class="badge badge-success">Active</span></td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs">Edit</button>
|
||||
<button class="btn btn-error btn-xs">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>2</th>
|
||||
<td>Jane Smith</td>
|
||||
<td>jane@example.com</td>
|
||||
<td>User</td>
|
||||
<td><span class="badge badge-warning">Pending</span></td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs">Edit</button>
|
||||
<button class="btn btn-error btn-xs">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>3</th>
|
||||
<td>Robert Johnson</td>
|
||||
<td>robert@example.com</td>
|
||||
<td>User</td>
|
||||
<td><span class="badge badge-error">Inactive</span></td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs">Edit</button>
|
||||
<button class="btn btn-error btn-xs">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Badges and Tags -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Badges and Tags</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="text-xl font-medium mb-4">Badge Variants</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-8">
|
||||
<span class="badge">Neutral</span>
|
||||
<span class="badge badge-primary">Primary</span>
|
||||
<span class="badge badge-secondary">Secondary</span>
|
||||
<span class="badge badge-accent">Accent</span>
|
||||
<span class="badge badge-info">Info</span>
|
||||
<span class="badge badge-success">Success</span>
|
||||
<span class="badge badge-warning">Warning</span>
|
||||
<span class="badge badge-error">Error</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-medium mb-4">Badge Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-xs">Extra Small</span>
|
||||
<span class="badge badge-sm">Small</span>
|
||||
<span class="badge">Normal</span>
|
||||
<span class="badge badge-lg">Large</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modals -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Modals</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<!-- Open the modal using the button -->
|
||||
<button class="btn btn-primary" onclick="demo_modal.showModal()">Open Modal</button>
|
||||
<dialog id="demo_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Hello!</h3>
|
||||
<p class="py-4">This is a modal example using DaisyUI.</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<!-- if there is a button in form, it will close the modal -->
|
||||
<button class="btn">Close</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Progress -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Progress</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body space-y-4">
|
||||
<div>
|
||||
<div class="text-sm mb-1">Default Progress (75%)</div>
|
||||
<progress class="progress w-full" value="75" max="100"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1">Primary Progress (45%)</div>
|
||||
<progress class="progress progress-primary w-full" value="45" max="100"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1">Secondary Progress (60%)</div>
|
||||
<progress class="progress progress-secondary w-full" value="60" max="100"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1">Success Progress (90%)</div>
|
||||
<progress class="progress progress-success w-full" value="90" max="100"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1">Warning Progress (50%)</div>
|
||||
<progress class="progress progress-warning w-full" value="50" max="100"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1">Error Progress (25%)</div>
|
||||
<progress class="progress progress-error w-full" value="25" max="100"></progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tabs -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Tabs</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Overview" checked />
|
||||
<div role="tabpanel" class="tab-content p-6">
|
||||
<p>This is the Overview tab content. Tabs are useful for organizing content into different sections.</p>
|
||||
</div>
|
||||
|
||||
<input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Details" />
|
||||
<div role="tabpanel" class="tab-content p-6">
|
||||
<p>This is the Details tab content. You can include any type of content here, including forms, tables, or other components.</p>
|
||||
</div>
|
||||
|
||||
<input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Stats" />
|
||||
<div role="tabpanel" class="tab-content p-6">
|
||||
<p>This is the Stats tab content. Tabs help keep your interface clean and organized.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Accordion -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Accordion</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="collapse collapse-arrow bg-base-200 mb-2">
|
||||
<input type="radio" name="accordion-1" checked="checked" />
|
||||
<div class="collapse-title font-medium">
|
||||
What is DMARQ?
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<p>DMARQ is a DMARC monitoring tool that helps organizations track and analyze their email authentication compliance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-arrow bg-base-200 mb-2">
|
||||
<input type="radio" name="accordion-1" />
|
||||
<div class="collapse-title font-medium">
|
||||
How does DMARC work?
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<p>DMARC (Domain-based Message Authentication, Reporting and Conformance) is an email authentication protocol that builds upon SPF and DKIM to help prevent email spoofing.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-arrow bg-base-200">
|
||||
<input type="radio" name="accordion-1" />
|
||||
<div class="collapse-title font-medium">
|
||||
What are the benefits of using DMARQ?
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<p>DMARQ provides insights into email authentication failures, helps identify legitimate sources that may be failing authentication, and offers recommendations to improve email deliverability and security.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tooltips -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-4">Tooltips</h2>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-center gap-6">
|
||||
<div class="tooltip" data-tip="Top tooltip">
|
||||
<button class="btn">Top</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-bottom" data-tip="Bottom tooltip">
|
||||
<button class="btn">Bottom</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-left" data-tip="Left tooltip">
|
||||
<button class="btn">Left</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-right" data-tip="Right tooltip">
|
||||
<button class="btn">Right</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,490 @@
|
||||
{% 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>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1 flex items-center">
|
||||
<span class="mr-2">DKIM Selectors</span>
|
||||
<span x-show="dns.dkim && dns.dkim.length > 0" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span x-show="!dns.dkim || dns.dkim.length === 0" 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.dkimSelectors || 'No DKIM selectors configured'">-</div>
|
||||
</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{% endcall %}
|
||||
{% call th() %}Total Emails{% endcall %}
|
||||
{% call th() %}SPF{% endcall %}
|
||||
{% call th() %}DKIM{% endcall %}
|
||||
{% call th() %}DMARC{% endcall %}
|
||||
{% call th() %}Disposition{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call tbody() %}
|
||||
<template x-if="sources.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" 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() %}
|
||||
<span x-text="source.ip"></span>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<span x-text="source.count"></span>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<template x-if="source.spf === 'pass'">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
|
||||
</template>
|
||||
<template x-if="source.spf === 'fail'">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
|
||||
</template>
|
||||
<template x-if="source.spf === 'neutral' || source.spf === 'none'">
|
||||
<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>
|
||||
</template>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<template x-if="source.dkim === 'pass'">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
|
||||
</template>
|
||||
<template x-if="source.dkim === 'fail'">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
|
||||
</template>
|
||||
<template x-if="source.dkim === 'neutral' || source.dkim === 'none'">
|
||||
<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>
|
||||
</template>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<template x-if="source.dmarc === 'pass'">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
|
||||
</template>
|
||||
<template x-if="source.dmarc === 'fail'">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
|
||||
</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 %}
|
||||
{% 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="https://cdn.jsdelivr.net/npm/chart.js@4.3.0/dist/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: ''
|
||||
},
|
||||
reports: [],
|
||||
sources: [],
|
||||
complianceChart: null,
|
||||
filters: {
|
||||
dateRange: '30',
|
||||
sourceFilter: ''
|
||||
},
|
||||
|
||||
init() {
|
||||
this.fetchDomainStats();
|
||||
this.fetchDNSRecords();
|
||||
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());
|
||||
});
|
||||
},
|
||||
|
||||
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 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);
|
||||
|
||||
this.complianceChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Compliance Rate',
|
||||
data: complianceData,
|
||||
borderColor: '#1A237E',
|
||||
backgroundColor: 'rgba(26, 35, 126, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: value => value + '%'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.parsed.y + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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 %}
|
||||
@@ -0,0 +1,135 @@
|
||||
{% 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 - Domains{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto py-4" x-data="domainsApp()">
|
||||
<h1 class="text-2xl font-bold mb-6">Domain Management</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Domain List -->
|
||||
<div class="mb-8">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
<div class="flex items-center justify-between">
|
||||
{% call card_title() %}Monitored Domains{% endcall %}
|
||||
{% call button(variant="outline", size="sm") %}
|
||||
<span class="mr-1">+</span> Add Domain
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% call card_description() %}
|
||||
Domains currently being monitored for DMARC compliance
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
{% call table() %}
|
||||
{% call thead() %}
|
||||
{% call tr() %}
|
||||
{% call th() %}Domain{% endcall %}
|
||||
{% call th() %}DMARC Status{% endcall %}
|
||||
{% call th() %}SPF Status{% endcall %}
|
||||
{% call th() %}DKIM Status{% endcall %}
|
||||
{% call th("text-right") %}Actions{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call tbody() %}
|
||||
<template x-if="domains.length === 0">
|
||||
{% call tr() %}
|
||||
{% call td(colspan="5", class="text-center") %}
|
||||
<p class="py-4 text-muted-foreground">No domains found. Add a domain to get started.</p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
<template x-for="(domain, index) in domains" :key="index">
|
||||
{% call tr() %}
|
||||
{% call td() %}
|
||||
<div class="font-medium" x-text="domain.name"></div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 rounded-full"
|
||||
:class="domain.dmarc_status ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
<span class="ml-2" x-text="domain.dmarc_policy || 'Not configured'"></span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 rounded-full"
|
||||
:class="domain.spf_status ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
<span class="ml-2" x-text="domain.spf_status ? 'Configured' : 'Missing'"></span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 rounded-full"
|
||||
:class="domain.dkim_status ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
<span class="ml-2" x-text="domain.dkim_status ? 'Configured' : 'Missing'"></span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call td("text-right") %}
|
||||
<div class="flex justify-end space-x-2">
|
||||
<a :href="'/domain/' + domain.name" class="btn btn-sm btn-outline">
|
||||
Details
|
||||
</a>
|
||||
{% call button(variant="outline", size="sm") %}
|
||||
Edit
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function domainsApp() {
|
||||
return {
|
||||
domains: [],
|
||||
|
||||
init() {
|
||||
// Fetch domains from server
|
||||
this.fetchDomains();
|
||||
},
|
||||
|
||||
async fetchDomains() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/domains/summary');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Format domains for display
|
||||
this.domains = data.domains.map(domain => ({
|
||||
name: domain.domain_name,
|
||||
dmarc_status: true, // In Milestone 1, assume DMARC is configured if we have reports
|
||||
dmarc_policy: domain.policy || 'p=none',
|
||||
spf_status: true, // In future milestones, this will come from DNS checks
|
||||
dkim_status: true, // In future milestones, this will come from DNS checks
|
||||
reports_count: domain.report_count,
|
||||
emails_count: domain.total_emails,
|
||||
compliance_rate: domain.pass_rate
|
||||
}));
|
||||
} else {
|
||||
console.error('Error fetching domains:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching domains:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dmarqlight" class="dark:data-theme-dmarqdark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DMARQ - DMARC Monitoring{% endblock %}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS & DaisyUI via CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.css" rel="stylesheet" type="text/css"/>
|
||||
|
||||
<!-- Alpine.js for interactivity -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-100 font-body antialiased">
|
||||
<!-- Updated Menu Bar -->
|
||||
<header class="navbar bg-primary text-primary-content">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="btn btn-ghost normal-case text-xl">
|
||||
<img src="/static/img/monogram_light.png" alt="DMARQ Logo" class="w-8 h-8 mr-2">
|
||||
DMARQ
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/domains">Domains</a></li>
|
||||
<li><a href="/reports">Reports</a></li>
|
||||
<li><a href="/upload">Upload</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Updated Main Content Area with page-specific data initialization -->
|
||||
<main class="p-4 md:p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<!-- Initialize theme from localStorage -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const darkMode = localStorage.getItem('darkMode') === 'true';
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dmarqdark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dmarqlight');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,219 @@
|
||||
{% 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 - Reports{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto py-4" x-data="reportsApp()">
|
||||
<h1 class="text-2xl font-bold mb-6">DMARC Reports</h1>
|
||||
|
||||
<!-- Report Filters -->
|
||||
<div class="mb-6">
|
||||
{% call card() %}
|
||||
{% call card_content() %}
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="w-full sm:w-auto">
|
||||
<label class="block text-sm font-medium mb-1">Domain</label>
|
||||
<select class="select select-bordered w-full" x-model="filters.domain">
|
||||
<option value="">All Domains</option>
|
||||
<template x-for="domain in domains" :key="domain">
|
||||
<option x-text="domain" :value="domain"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto">
|
||||
<label class="block text-sm font-medium mb-1">Report Type</label>
|
||||
<select class="select select-bordered w-full" x-model="filters.reportType">
|
||||
<option value="">All Types</option>
|
||||
<option value="aggregate">Aggregate (RUA)</option>
|
||||
<option value="forensic">Forensic (RUF)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto">
|
||||
<label class="block text-sm font-medium mb-1">Date Range</label>
|
||||
<select class="select select-bordered w-full" x-model="filters.dateRange">
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="14">Last 14 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto flex items-end">
|
||||
{% call button(variant="outline") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
|
||||
Reset Filters
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Reports Table -->
|
||||
<div class="mb-8">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
<div class="flex items-center justify-between">
|
||||
{% call card_title() %}DMARC Reports{% endcall %}
|
||||
</div>
|
||||
{% call card_description() %}
|
||||
Showing <span x-text="filteredReports.length"></span> reports
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
{% call table() %}
|
||||
{% call thead() %}
|
||||
{% call tr() %}
|
||||
{% call th() %}Date{% endcall %}
|
||||
{% call th() %}Type{% endcall %}
|
||||
{% call th() %}Domain{% endcall %}
|
||||
{% call th() %}Organization{% endcall %}
|
||||
{% call th() %}Messages{% endcall %}
|
||||
{% call th() %}Pass Rate{% endcall %}
|
||||
{% call th("text-right") %}Actions{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call tbody() %}
|
||||
<template x-for="(report, index) in filteredReports" :key="index">
|
||||
{% call tr() %}
|
||||
{% call td() %}
|
||||
<div x-text="formatDate(report.date)"></div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div class="inline-flex items-center px-2 py-1 rounded text-xs"
|
||||
:class="report.type === 'aggregate' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'">
|
||||
<span x-text="report.type === 'aggregate' ? 'Aggregate' : 'Forensic'"></span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div class="font-medium" x-text="report.domain"></div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div x-text="report.organization"></div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div x-text="report.messages"></div>
|
||||
{% endcall %}
|
||||
{% call td() %}
|
||||
<div class="inline-flex items-center px-2 py-1 rounded"
|
||||
:class="getPassRateColor(report.passRate)">
|
||||
<span x-text="report.passRate + '%'"></span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call td("text-right") %}
|
||||
{% call button(variant="outline", size="sm") %}
|
||||
View Details
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function reportsApp() {
|
||||
return {
|
||||
filters: {
|
||||
domain: '',
|
||||
reportType: '',
|
||||
dateRange: '30'
|
||||
},
|
||||
domains: ['example.com', 'mydomain.com'],
|
||||
reports: [
|
||||
{
|
||||
id: 1,
|
||||
date: '2023-04-15',
|
||||
type: 'aggregate',
|
||||
domain: 'example.com',
|
||||
organization: 'Google',
|
||||
messages: 128,
|
||||
passRate: 96
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2023-04-15',
|
||||
type: 'aggregate',
|
||||
domain: 'mydomain.com',
|
||||
organization: 'Microsoft',
|
||||
messages: 64,
|
||||
passRate: 100
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2023-04-14',
|
||||
type: 'forensic',
|
||||
domain: 'example.com',
|
||||
organization: 'Yahoo',
|
||||
messages: 1,
|
||||
passRate: 0
|
||||
}
|
||||
],
|
||||
|
||||
init() {
|
||||
// When API is ready, fetch reports from server
|
||||
// this.fetchReports();
|
||||
},
|
||||
|
||||
get filteredReports() {
|
||||
return this.reports.filter(report => {
|
||||
// Filter by domain
|
||||
if (this.filters.domain && report.domain !== this.filters.domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by report type
|
||||
if (this.filters.reportType && report.type !== this.filters.reportType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (this.filters.dateRange !== 'all') {
|
||||
const days = parseInt(this.filters.dateRange);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
|
||||
const reportDate = new Date(report.date);
|
||||
if (reportDate < cutoff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
|
||||
getPassRateColor(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';
|
||||
},
|
||||
|
||||
async fetchReports() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/reports');
|
||||
this.reports = await response.json();
|
||||
|
||||
// Extract unique domains
|
||||
this.domains = [...new Set(this.reports.map(r => r.domain))];
|
||||
} catch (error) {
|
||||
console.error('Error fetching reports:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,336 @@
|
||||
{% 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 %}
|
||||
{% from "components/ui/alert.html" import alert, alert_title, alert_description %}
|
||||
{% from "components/ui/input.html" import input, label, form_group %}
|
||||
|
||||
{% block title %}Settings - DMARQ{% endblock %}
|
||||
|
||||
{% block page_title %}Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid gap-4 md:gap-8 py-4">
|
||||
<!-- IMAP Configuration -->
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}IMAP Configuration{% endcall %}
|
||||
{% call card_description() %}
|
||||
Configure the IMAP connection to automatically retrieve DMARC reports from your email
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<form id="imap-form" class="space-y-6" x-data="imapForm()">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
{% call form_group() %}
|
||||
{% call label(for="imap_server", required=True) %}IMAP Server{% endcall %}
|
||||
{{ input(type="text", name="imap_server", id="imap_server", placeholder="mail.example.com", required=True) }}
|
||||
{% endcall %}
|
||||
|
||||
{% call form_group() %}
|
||||
{% call label(for="imap_port", required=True) %}IMAP Port{% endcall %}
|
||||
{{ input(type="number", name="imap_port", id="imap_port", value="993", required=True) }}
|
||||
{% endcall %}
|
||||
|
||||
{% call form_group() %}
|
||||
{% call label(for="imap_ssl") %}Use SSL{% endcall %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="imap_ssl" name="imap_ssl" class="h-4 w-4 rounded border-border text-primary focus:ring-primary" checked />
|
||||
<label for="imap_ssl" class="text-sm text-muted-foreground">Enable SSL/TLS connection (recommended)</label>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% call form_group() %}
|
||||
{% call label(for="imap_username", required=True) %}IMAP Username{% endcall %}
|
||||
{{ input(type="text", name="imap_username", id="imap_username", placeholder="dmarc-reports@example.com", required=True) }}
|
||||
{% endcall %}
|
||||
|
||||
{% call form_group() %}
|
||||
{% call label(for="imap_password", required=True) %}IMAP Password{% endcall %}
|
||||
<div class="relative">
|
||||
{{ input(type="password", name="imap_password", id="imap_password", required=True) }}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
x-on:click="togglePassword"
|
||||
>
|
||||
<svg x-show="!showPassword" 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"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"></path><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"></path><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"></path><line x1="2" x2="22" y1="2" y2="22"></line></svg>
|
||||
<svg x-show="showPassword" 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" x-cloak><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% call form_group() %}
|
||||
{% call label(for="polling_interval") %}Polling Interval (minutes){% endcall %}
|
||||
{{ input(type="number", name="polling_interval", id="polling_interval", value="60", min="15", max="1440") }}
|
||||
<p class="text-xs text-muted-foreground mt-1">How often to check for new reports (minimum 15 minutes)</p>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="test-connection-result" x-show="testResult" x-cloak>
|
||||
<template x-if="testStatus === 'success'">
|
||||
{% call alert(variant="success") %}
|
||||
{% call alert_title() %}Connection Successful{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<p x-text="testResult"></p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
|
||||
<template x-if="testStatus === 'error'">
|
||||
{% call alert(variant="error") %}
|
||||
{% call alert_title() %}Connection Failed{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<p x-text="testResult"></p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div id="save-result" x-show="saveResult" x-cloak>
|
||||
<template x-if="saveStatus === 'success'">
|
||||
{% call alert(variant="success") %}
|
||||
{% call alert_title() %}Settings Saved{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<p x-text="saveResult"></p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
|
||||
<template x-if="saveStatus === 'error'">
|
||||
{% call alert(variant="error") %}
|
||||
{% call alert_title() %}Save Failed{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<p x-text="saveResult"></p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-md"
|
||||
x-on:click="testConnection"
|
||||
x-bind:disabled="isTesting || isSaving"
|
||||
>
|
||||
<span x-show="!isTesting">
|
||||
<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="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
||||
Test Connection
|
||||
</span>
|
||||
<span x-show="isTesting" class="flex items-center" x-cloak>
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Testing...
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-default btn-md"
|
||||
x-bind:disabled="isTesting || isSaving"
|
||||
>
|
||||
<span x-show="!isSaving">
|
||||
<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||
Save Configuration
|
||||
</span>
|
||||
<span x-show="isSaving" class="flex items-center" x-cloak>
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
<!-- DMARC Policy Management -->
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}DMARC Policy Management{% endcall %}
|
||||
{% call card_description() %}
|
||||
Configure default DMARC policy settings for newly added domains
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<form id="dmarc-policy-form" class="space-y-6" x-data="{isUpdating: false, updateResult: ''}">
|
||||
<div class="space-y-4">
|
||||
{% call form_group() %}
|
||||
{% call label(for="default_policy") %}Default DMARC Policy{% endcall %}
|
||||
<select id="default_policy" name="default_policy" class="input w-full">
|
||||
<option value="none">None (monitoring only)</option>
|
||||
<option value="quarantine">Quarantine (send to spam)</option>
|
||||
<option value="reject">Reject (block delivery)</option>
|
||||
</select>
|
||||
<p class="text-xs text-muted-foreground mt-1">Policy applied to new domains when no specific policy is set</p>
|
||||
{% endcall %}
|
||||
|
||||
{% call form_group() %}
|
||||
{% call label(for="percent") %}Percentage{% endcall %}
|
||||
<div class="flex items-center">
|
||||
<input type="range" id="percent" name="percent" min="0" max="100" value="100" class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer" />
|
||||
<span class="ml-2 text-sm font-medium w-10" id="percent-display">100%</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Percentage of messages to which the DMARC policy is applied</p>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn btn-default btn-md">
|
||||
<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||
Save Policy Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function imapForm() {
|
||||
return {
|
||||
showPassword: false,
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
testResult: '',
|
||||
testStatus: '',
|
||||
saveResult: '',
|
||||
saveStatus: '',
|
||||
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
const passwordInput = document.getElementById('imap_password');
|
||||
passwordInput.type = this.showPassword ? 'text' : 'password';
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
this.isTesting = true;
|
||||
this.testResult = '';
|
||||
|
||||
const formData = new FormData(document.getElementById('imap-form'));
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
data.imap_ssl = formData.get('imap_ssl') === 'on';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/test-imap', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
this.testStatus = 'success';
|
||||
this.testResult = 'Successfully connected to the IMAP server. Found ' + (result.message_count || 0) + ' messages in the inbox.';
|
||||
} else {
|
||||
throw new Error(result.detail || 'Failed to connect to the IMAP server');
|
||||
}
|
||||
} catch (error) {
|
||||
this.testStatus = 'error';
|
||||
this.testResult = `Connection failed: ${error.message}`;
|
||||
} finally {
|
||||
this.isTesting = false;
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
// Load existing IMAP settings
|
||||
this.loadImapSettings();
|
||||
|
||||
// Update percent display
|
||||
const percentInput = document.getElementById('percent');
|
||||
const percentDisplay = document.getElementById('percent-display');
|
||||
if (percentInput && percentDisplay) {
|
||||
percentInput.addEventListener('input', function() {
|
||||
percentDisplay.textContent = this.value + '%';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
const form = document.getElementById('imap-form');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.saveImapSettings();
|
||||
});
|
||||
},
|
||||
|
||||
async loadImapSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/imap-settings');
|
||||
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
|
||||
document.getElementById('imap_server').value = settings.imap_server || '';
|
||||
document.getElementById('imap_port').value = settings.imap_port || 993;
|
||||
document.getElementById('imap_username').value = settings.imap_username || '';
|
||||
document.getElementById('imap_password').value = settings.imap_password || '';
|
||||
document.getElementById('imap_ssl').checked = settings.imap_ssl !== false;
|
||||
document.getElementById('polling_interval').value = settings.polling_interval || 60;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load IMAP settings:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async saveImapSettings() {
|
||||
this.isSaving = true;
|
||||
this.saveResult = '';
|
||||
|
||||
const formData = new FormData(document.getElementById('imap-form'));
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
data.imap_ssl = formData.get('imap_ssl') === 'on';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/imap-settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
this.saveStatus = 'success';
|
||||
this.saveResult = 'IMAP settings saved successfully. The system will now check for new DMARC reports based on your settings.';
|
||||
} else {
|
||||
throw new Error(result.detail || 'Failed to save IMAP settings');
|
||||
}
|
||||
} catch (error) {
|
||||
this.saveStatus = 'error';
|
||||
this.saveResult = `Save failed: ${error.message}`;
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize any scripts after DOM load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DMARC policy form handling
|
||||
const policyForm = document.getElementById('dmarc-policy-form');
|
||||
if (policyForm) {
|
||||
policyForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
// In a real app, you would save the policy settings here
|
||||
alert('DMARC policy settings saved');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,203 @@
|
||||
{% 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 %}
|
||||
{% from "components/ui/alert.html" import alert, alert_title, alert_description %}
|
||||
|
||||
{% block title %}Upload DMARC Reports - DMARQ{% endblock %}
|
||||
|
||||
{% block page_title %}Upload DMARC Reports{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid gap-4 md:gap-8 py-4">
|
||||
<!-- Upload Card -->
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Upload DMARC Reports{% endcall %}
|
||||
{% call card_description() %}
|
||||
Upload your DMARC aggregate report files (XML, ZIP, or GZIP)
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<form id="upload-form" enctype="multipart/form-data" class="space-y-6" x-data="uploadForm()">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed border-border rounded-lg p-8 text-center hover:bg-muted/50 transition-colors cursor-pointer relative"
|
||||
x-on:dragover.prevent="dragover = true"
|
||||
x-on:dragleave.prevent="dragover = false"
|
||||
x-on:drop.prevent="handleDrop($event)"
|
||||
x-bind:class="{'border-primary/50 bg-primary/5': dragover}"
|
||||
>
|
||||
<div class="mb-4 text-muted-foreground">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" x2="12" y1="3" y2="15"></line></svg>
|
||||
</div>
|
||||
<p class="mb-2 text-base font-medium">
|
||||
<span class="text-primary">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
XML, ZIP, or GZIP files only
|
||||
</p>
|
||||
<input id="report-file" type="file" name="file" accept=".xml,.zip,.gz,.gzip" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" x-on:change="handleFileSelect" />
|
||||
</div>
|
||||
|
||||
<div id="file-selected" x-show="selectedFile" class="p-3 bg-muted rounded-md" x-cloak>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<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="text-primary"><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>
|
||||
<span id="file-name" class="text-sm font-medium" x-text="selectedFile"></span>
|
||||
</div>
|
||||
<button type="button" id="remove-file" class="text-muted-foreground hover:text-foreground" x-on:click="clearFile">
|
||||
<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"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-result" x-show="uploadResult" x-cloak>
|
||||
<template x-if="uploadStatus === 'success'">
|
||||
{% call alert(variant="success") %}
|
||||
{% call alert_title() %}Upload Successful{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<p x-text="uploadResult"></p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
|
||||
<template x-if="uploadStatus === 'error'">
|
||||
{% call alert(variant="error") %}
|
||||
{% call alert_title() %}Upload Failed{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<p x-text="uploadResult"></p>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
|
||||
<template x-if="uploadStatus === 'processing'">
|
||||
{% call alert(variant="info") %}
|
||||
{% call alert_title() %}Processing{% endcall %}
|
||||
{% call alert_description() %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Uploading and processing your DMARC report...</span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-default btn-md"
|
||||
x-bind:disabled="!selectedFile || isUploading"
|
||||
>
|
||||
<span x-show="!isUploading">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" x2="12" y1="3" y2="15"></line></svg>
|
||||
Upload Report
|
||||
</span>
|
||||
<span x-show="isUploading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Uploading...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function uploadForm() {
|
||||
return {
|
||||
selectedFile: '',
|
||||
isUploading: false,
|
||||
dragover: false,
|
||||
uploadResult: '',
|
||||
uploadStatus: '',
|
||||
|
||||
handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.selectedFile = file.name;
|
||||
}
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
this.dragover = false;
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && (file.name.endsWith('.xml') || file.name.endsWith('.zip') || file.name.endsWith('.gz') || file.name.endsWith('.gzip'))) {
|
||||
document.getElementById('report-file').files = event.dataTransfer.files;
|
||||
this.selectedFile = file.name;
|
||||
} else {
|
||||
this.uploadStatus = 'error';
|
||||
this.uploadResult = 'Invalid file type. Please upload XML, ZIP, or GZIP files only.';
|
||||
}
|
||||
},
|
||||
|
||||
clearFile() {
|
||||
this.selectedFile = '';
|
||||
this.uploadStatus = '';
|
||||
this.uploadResult = '';
|
||||
document.getElementById('report-file').value = '';
|
||||
},
|
||||
|
||||
init() {
|
||||
const form = document.getElementById('upload-form');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const fileInput = document.getElementById('report-file');
|
||||
|
||||
if (!fileInput.files.length) {
|
||||
this.uploadStatus = 'error';
|
||||
this.uploadResult = 'Please select a file to upload.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading = true;
|
||||
this.uploadStatus = 'processing';
|
||||
this.uploadResult = 'Uploading and processing report...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/reports/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
this.uploadStatus = 'success';
|
||||
this.uploadResult = `Report processed successfully. Found ${data.processed_records || 0} records for domain ${data.domain || ''}.`;
|
||||
this.selectedFile = '';
|
||||
fileInput.value = '';
|
||||
|
||||
// Refresh dashboard data after 1 second
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('dmarq:refresh-data'));
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(data.detail || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.uploadStatus = 'error';
|
||||
this.uploadResult = `Upload failed: ${error.message}`;
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -35,6 +35,8 @@ services:
|
||||
- SECRET_KEY=your_secret_key_change_in_production
|
||||
- DEBUG=True
|
||||
- ENVIRONMENT=development
|
||||
# Add NODE_ENV for Tailwind
|
||||
- NODE_ENV=production
|
||||
networks:
|
||||
- dmarq-network
|
||||
ports:
|
||||
|
||||