feat: add operations health and domain setup polish
This commit is contained in:
@@ -21,9 +21,9 @@
|
||||
{% call card_header() %}
|
||||
<div class="flex items-center justify-between">
|
||||
{% call card_title() %}Monitored Domains{% endcall %}
|
||||
{% call button(variant="outline", size="sm") %}
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="openCreate = true">
|
||||
<span class="mr-1">+</span> Add Domain
|
||||
{% endcall %}
|
||||
</button>
|
||||
</div>
|
||||
{% call card_description() %}
|
||||
Domains currently being monitored for DMARC compliance
|
||||
@@ -91,6 +91,40 @@
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<dialog class="modal" :open="openCreate">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h2 class="text-xl font-semibold">Add monitored domain</h2>
|
||||
<p class="mt-1 text-sm text-base-content/70">Track DNS health and future DMARC reports before the first report arrives.</p>
|
||||
<form class="mt-5 space-y-4" @submit.prevent="createDomain">
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium">Domain</span>
|
||||
<input x-model.trim="newDomain.name" type="text" required placeholder="example.com" class="input input-bordered">
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
<textarea x-model.trim="newDomain.description" rows="3" class="textarea textarea-bordered" placeholder="Production mail domain"></textarea>
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium">DKIM selectors</span>
|
||||
<input x-model.trim="newDomain.dkim_selectors" type="text" placeholder="default, google, selector1" class="input input-bordered">
|
||||
</label>
|
||||
<div x-show="createError" role="alert" class="alert alert-error py-3">
|
||||
<span x-text="createError"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="closeCreate" :disabled="saving">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
<span x-show="saving" class="loading loading-spinner loading-xs"></span>
|
||||
Add domain
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" @click="closeCreate">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -99,6 +133,14 @@
|
||||
function domainsApp() {
|
||||
return {
|
||||
domains: [],
|
||||
openCreate: false,
|
||||
saving: false,
|
||||
createError: '',
|
||||
newDomain: {
|
||||
name: '',
|
||||
description: '',
|
||||
dkim_selectors: '',
|
||||
},
|
||||
|
||||
init() {
|
||||
// Fetch domains from server
|
||||
@@ -128,8 +170,47 @@ function domainsApp() {
|
||||
} catch (error) {
|
||||
console.error('Error fetching domains:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
closeCreate() {
|
||||
this.openCreate = false;
|
||||
this.createError = '';
|
||||
this.newDomain = { name: '', description: '', dkim_selectors: '' };
|
||||
},
|
||||
|
||||
async createDomain() {
|
||||
this.saving = true;
|
||||
this.createError = '';
|
||||
try {
|
||||
const selectors = this.newDomain.dkim_selectors
|
||||
.split(',')
|
||||
.map((selector) => selector.trim())
|
||||
.filter(Boolean);
|
||||
const response = await fetch('/api/v1/domains/domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: this.newDomain.name,
|
||||
description: this.newDomain.description || null,
|
||||
dkim_selectors: selectors,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const detail = typeof data.detail === 'string'
|
||||
? data.detail
|
||||
: Object.values(data.detail || {}).join(', ');
|
||||
throw new Error(detail || 'Domain could not be added.');
|
||||
}
|
||||
this.closeCreate();
|
||||
await this.fetchDomains();
|
||||
} catch (error) {
|
||||
this.createError = error.message || 'Domain could not be added.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<li><a href="/reports">Reports</a></li>
|
||||
<li><a href="/upload">Upload</a></li>
|
||||
<li><a href="/mail-sources">Mail Sources</a></li>
|
||||
<li><a href="/operations">Health</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
<!-- User menu -->
|
||||
@@ -122,4 +123,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}DMARQ - Health{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-6xl space-y-6" x-data="operationsHealth()" x-init="load()">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">System Health</h1>
|
||||
<p class="mt-1 text-sm text-base-content/70">Operational status for imports, scheduler activity, and storage.</p>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" @click="load" :disabled="loading">
|
||||
<span x-show="loading" class="loading loading-spinner loading-xs"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="error" role="alert" class="alert alert-error">
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="text-sm text-base-content/60">Overall</div>
|
||||
<div class="text-2xl font-semibold capitalize" :class="health.status === 'ok' ? 'text-success' : 'text-warning'" x-text="health.status || 'loading'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="text-sm text-base-content/60">Database</div>
|
||||
<div class="text-2xl font-semibold" :class="health.database?.ok ? 'text-success' : 'text-error'" x-text="health.database?.ok ? 'Connected' : 'Failed'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="text-sm text-base-content/60">Mail Sources</div>
|
||||
<div class="text-2xl font-semibold" x-text="`${health.scheduler?.enabled_sources || 0}/${health.scheduler?.total_sources || 0}`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="text-sm text-base-content/60">Reports</div>
|
||||
<div class="text-2xl font-semibold" x-text="health.reports?.count || 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Scheduler</h2>
|
||||
<dl class="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-base-content/60">State</dt>
|
||||
<dd class="font-medium" x-text="health.scheduler?.running ? 'Running' : 'Stopped'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-base-content/60">Last cycle</dt>
|
||||
<dd class="font-medium" x-text="formatDate(health.scheduler?.last_cycle_started_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-base-content/60">Last successful cycle</dt>
|
||||
<dd class="font-medium" x-text="formatDate(health.scheduler?.last_success_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-base-content/60">Last error</dt>
|
||||
<dd class="font-medium" x-text="health.scheduler?.last_error || 'None'"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Imports</h2>
|
||||
<dl class="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-base-content/60">Last completed</dt>
|
||||
<dd class="font-medium" x-text="formatImport(health.imports?.latest)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-base-content/60">Last successful</dt>
|
||||
<dd class="font-medium" x-text="formatImport(health.imports?.latest_successful)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-base-content/60">Latest report</dt>
|
||||
<dd class="font-medium" x-text="formatDate(health.reports?.latest_processed_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-base-content/60">Database detail</dt>
|
||||
<dd class="font-medium" x-text="health.database?.detail || 'Unknown'"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card bg-base-100 shadow" x-show="health.checks?.length">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Attention</h2>
|
||||
<ul class="list-disc space-y-2 pl-5 text-sm">
|
||||
<template x-for="check in health.checks" :key="check">
|
||||
<li x-text="check"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function operationsHealth() {
|
||||
return {
|
||||
loading: false,
|
||||
error: '',
|
||||
health: {},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const response = await fetch('/api/v1/health/operations');
|
||||
if (!response.ok) {
|
||||
throw new Error('Health details could not be loaded.');
|
||||
}
|
||||
this.health = await response.json();
|
||||
} catch (error) {
|
||||
this.error = error.message || 'Health details could not be loaded.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(value) {
|
||||
if (!value) return 'Not recorded';
|
||||
return new Date(value).toLocaleString();
|
||||
},
|
||||
|
||||
formatImport(value) {
|
||||
if (!value) return 'Not recorded';
|
||||
return `${value.status} (${value.reports_found} reports) at ${this.formatDate(value.finished_at)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -166,19 +166,27 @@
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">Next steps</h2>
|
||||
<h2 class="card-title text-lg">Setup checklist</h2>
|
||||
<ul class="space-y-3 text-sm text-base-content/70">
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-2 w-2 rounded-full bg-primary"></span>
|
||||
<span>Add a mail source from the Mail Sources page.</span>
|
||||
<span>
|
||||
<a href="/domains" class="link link-primary font-medium">Add monitored domains</a>
|
||||
<span class="ml-1" x-text="`(${totalDomains} configured)`"></span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-2 w-2 rounded-full bg-secondary"></span>
|
||||
<span>Import DMARC reports from Gmail, IMAP, upload, or webhook.</span>
|
||||
<span>
|
||||
<a href="/mail-sources" class="link link-primary font-medium">Connect a mailbox</a>
|
||||
<span class="ml-1" x-text="`(${enabledMailSources}/${totalMailSources} enabled)`"></span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-2 w-2 rounded-full bg-accent"></span>
|
||||
<span>Use DNS health checks to review SPF, DKIM, and DMARC records.</span>
|
||||
<span>
|
||||
<a href="/operations" class="link link-primary font-medium">Check system health</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -196,6 +204,9 @@
|
||||
complete: false,
|
||||
error: '',
|
||||
message: '',
|
||||
totalDomains: 0,
|
||||
totalMailSources: 0,
|
||||
enabledMailSources: 0,
|
||||
steps: [
|
||||
{ id: 1, title: 'Admin', detail: 'Contact details' },
|
||||
{ id: 2, title: 'System', detail: 'Name and URL' },
|
||||
@@ -222,6 +233,9 @@
|
||||
this.system.app_name = status.app_name || this.system.app_name;
|
||||
this.complete = Boolean(status.is_setup_complete);
|
||||
this.currentStep = this.complete ? 3 : 1;
|
||||
this.totalDomains = status.total_domains || 0;
|
||||
this.totalMailSources = status.total_mail_sources || 0;
|
||||
this.enabledMailSources = status.enabled_mail_sources || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Could not load setup status.';
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user