domain_health_status
This commit is contained in:
19
backend/migrations/004_domain_health_status.sql
Normal file
19
backend/migrations/004_domain_health_status.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 004_domain_health_status.sql
|
||||||
|
-- Persistent storage of the most recent health check result per domain.
|
||||||
|
-- Updated whenever the user clicks "Check health".
|
||||||
|
--
|
||||||
|
-- The 'has_problems' boolean drives the banner in the mailbox view.
|
||||||
|
-- The 'details' JSONB column stores the full report so the modal can
|
||||||
|
-- show last-known state without re-running the checks.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_health_status (
|
||||||
|
domain TEXT PRIMARY KEY,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
has_problems BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_health_problems
|
||||||
|
ON domain_health_status(has_problems, checked_at DESC);
|
||||||
52
backend/src/routes/health.ts
Normal file
52
backend/src/routes/health.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth, canAccessDomain } from '../middleware/auth.js';
|
||||||
|
import { runDomainHealthChecks, getPersistedHealth } from '../services/health.js';
|
||||||
|
import { audit } from '../services/audit.js';
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
healthRouter.use(requireAuth);
|
||||||
|
|
||||||
|
function ensureDomain(req: any, domain: string): void {
|
||||||
|
if (!canAccessDomain(req.user, domain)) {
|
||||||
|
throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/health/domains/:domain
|
||||||
|
* Read the last persisted health status without re-running checks.
|
||||||
|
* Used by the mailbox view to decide whether to show the banner.
|
||||||
|
*
|
||||||
|
* Returns { domain, checked_at, has_problems, summary } or 404 if
|
||||||
|
* the domain has never been checked.
|
||||||
|
*/
|
||||||
|
healthRouter.get('/domains/:domain', async (req, res) => {
|
||||||
|
const domain = String(req.params.domain).toLowerCase();
|
||||||
|
ensureDomain(req, domain);
|
||||||
|
const status = await getPersistedHealth(domain);
|
||||||
|
if (!status) {
|
||||||
|
res.status(404).json({ error: 'No health check has been performed yet' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/health/domains/:domain/check
|
||||||
|
* Run all health checks now. Persists the result and returns the
|
||||||
|
* full report for the modal.
|
||||||
|
*/
|
||||||
|
healthRouter.post('/domains/:domain/check', async (req, res) => {
|
||||||
|
const domain = String(req.params.domain).toLowerCase();
|
||||||
|
ensureDomain(req, domain);
|
||||||
|
const report = await runDomainHealthChecks(domain);
|
||||||
|
await audit(
|
||||||
|
req.user!.email,
|
||||||
|
'domain.health_check',
|
||||||
|
'domain',
|
||||||
|
domain,
|
||||||
|
{ has_problems: report.has_problems },
|
||||||
|
req.ip,
|
||||||
|
);
|
||||||
|
res.json(report);
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { mailboxesRouter } from './routes/mailboxes.js';
|
|||||||
import { auditRouter } from './routes/audit.js';
|
import { auditRouter } from './routes/audit.js';
|
||||||
import { adminsRouter } from './routes/admins.js';
|
import { adminsRouter } from './routes/admins.js';
|
||||||
import { billingRouter } from './routes/billing.js';
|
import { billingRouter } from './routes/billing.js';
|
||||||
|
import { healthRouter } from './routes/health.js';
|
||||||
import { SyncService } from './services/sync.js';
|
import { SyncService } from './services/sync.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -45,6 +46,7 @@ app.use('/api/mailboxes', mailboxesRouter);
|
|||||||
app.use('/api/audit', auditRouter);
|
app.use('/api/audit', auditRouter);
|
||||||
app.use('/api/admins', adminsRouter);
|
app.use('/api/admins', adminsRouter);
|
||||||
app.use('/api/billing', billingRouter);
|
app.use('/api/billing', billingRouter);
|
||||||
|
app.use('/api/health', healthRouter);
|
||||||
|
|
||||||
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
const status = err.status ?? err.statusCode ?? 500;
|
const status = err.status ?? err.statusCode ?? 500;
|
||||||
|
|||||||
392
backend/src/services/health.ts
Normal file
392
backend/src/services/health.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type HealthLevel = 'ok' | 'warn' | 'fail' | 'unknown';
|
||||||
|
|
||||||
|
export interface HealthFinding {
|
||||||
|
level: HealthLevel;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheck {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
level: HealthLevel;
|
||||||
|
findings: HealthFinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainHealthReport {
|
||||||
|
domain: string;
|
||||||
|
checked_at: string;
|
||||||
|
has_problems: boolean;
|
||||||
|
checks: HealthCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const SUBDOMAINS_FOR_CADDY = ['mail', 'webmail', 'imap', 'smtp'];
|
||||||
|
|
||||||
|
// "warn" level for cert expiring within 14 days, "fail" for already expired.
|
||||||
|
const CERT_WARN_DAYS = 14;
|
||||||
|
|
||||||
|
// Aggregate child finding levels into a parent level (worst wins).
|
||||||
|
function worstLevel(levels: HealthLevel[]): HealthLevel {
|
||||||
|
if (levels.includes('fail')) return 'fail';
|
||||||
|
if (levels.includes('warn')) return 'warn';
|
||||||
|
if (levels.includes('unknown')) return 'unknown';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([p, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1) DMS check
|
||||||
|
// ============================================================
|
||||||
|
async function checkDms(domain: string): Promise<HealthCheck> {
|
||||||
|
const findings: HealthFinding[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT count(*)::int AS n
|
||||||
|
FROM mailboxes
|
||||||
|
WHERE domain=$1 AND status='active'`,
|
||||||
|
[domain],
|
||||||
|
);
|
||||||
|
const count = result.rows[0]?.n ?? 0;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: 'No active mailboxes',
|
||||||
|
detail: 'This domain has no active mailboxes in DMS.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'ok',
|
||||||
|
label: `${count} active mailbox${count === 1 ? '' : 'es'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
findings.push({
|
||||||
|
level: 'unknown',
|
||||||
|
label: 'Could not query DMS state',
|
||||||
|
detail: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'dms',
|
||||||
|
title: 'DMS',
|
||||||
|
level: worstLevel(findings.map((f) => f.level)),
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2) DNS check
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function dnsResolve(host: string, type: 'A' | 'AAAA' | 'MX' | 'TXT' | 'CNAME'): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
if (type === 'A') return await withTimeout<string[]>(dns.resolve4(host), 5000, `A ${host}`);
|
||||||
|
if (type === 'AAAA') return await withTimeout<string[]>(dns.resolve6(host), 5000, `AAAA ${host}`);
|
||||||
|
if (type === 'MX') {
|
||||||
|
const mx = await withTimeout<dns.MxRecord[]>(dns.resolveMx(host), 5000, `MX ${host}`);
|
||||||
|
return mx.map((m) => m.exchange);
|
||||||
|
}
|
||||||
|
if (type === 'TXT') {
|
||||||
|
const txt = await withTimeout<string[][]>(dns.resolveTxt(host), 5000, `TXT ${host}`);
|
||||||
|
return txt.map((parts) => parts.join(''));
|
||||||
|
}
|
||||||
|
if (type === 'CNAME') return await withTimeout<string[]>(dns.resolveCname(host), 5000, `CNAME ${host}`);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDns(domain: string): Promise<HealthCheck> {
|
||||||
|
const findings: HealthFinding[] = [];
|
||||||
|
|
||||||
|
// ---- MX ----
|
||||||
|
const mx = await dnsResolve(domain, 'MX');
|
||||||
|
if (mx.length === 0) {
|
||||||
|
findings.push({ level: 'fail', label: 'MX', detail: 'No MX record found.' });
|
||||||
|
} else {
|
||||||
|
const sesMx = mx.find((m) => /amazonaws\.com\.?$/i.test(m));
|
||||||
|
if (sesMx) {
|
||||||
|
findings.push({ level: 'ok', label: 'MX', detail: `points to SES (${sesMx})` });
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'warn',
|
||||||
|
label: 'MX',
|
||||||
|
detail: `Not an SES MX record: ${mx.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SPF (TXT on root) ----
|
||||||
|
const txt = await dnsResolve(domain, 'TXT');
|
||||||
|
const spf = txt.find((t) => /^v=spf1\b/i.test(t));
|
||||||
|
if (!spf) {
|
||||||
|
findings.push({ level: 'fail', label: 'SPF', detail: 'No SPF record found.' });
|
||||||
|
} else if (/include:amazonses\.com/i.test(spf)) {
|
||||||
|
findings.push({ level: 'ok', label: 'SPF', detail: 'includes amazonses.com' });
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'warn',
|
||||||
|
label: 'SPF',
|
||||||
|
detail: `SPF found but does not include amazonses.com: ${spf.slice(0, 100)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DMARC ----
|
||||||
|
const dmarc = await dnsResolve(`_dmarc.${domain}`, 'TXT');
|
||||||
|
const dmarcRecord = dmarc.find((t) => /^v=DMARC1\b/i.test(t));
|
||||||
|
if (!dmarcRecord) {
|
||||||
|
findings.push({ level: 'warn', label: 'DMARC', detail: 'No DMARC record found.' });
|
||||||
|
} else {
|
||||||
|
findings.push({ level: 'ok', label: 'DMARC', detail: dmarcRecord.slice(0, 80) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DKIM (SES uses 3 selectors named "<token>._domainkey") ----
|
||||||
|
// We don't know the SES tokens up front, so we just check whether
|
||||||
|
// there is _ANY_ resolvable DKIM-like CNAME under _domainkey.
|
||||||
|
// Common SES DKIM convention: 3 CNAMEs at <token1|2|3>._domainkey.
|
||||||
|
// We try Amazon's classic pattern first, then fall back to "no info".
|
||||||
|
// This check is best-effort; "unknown" is acceptable.
|
||||||
|
// Note: there's no clean way to enumerate _domainkey subdomains via DNS,
|
||||||
|
// so we record "unknown" rather than making up false positives.
|
||||||
|
findings.push({
|
||||||
|
level: 'unknown',
|
||||||
|
label: 'DKIM',
|
||||||
|
detail: 'Cannot verify automatically — confirm in SES console that 3 DKIM CNAMEs are published.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Subdomains for Caddy (must resolve, content doesn't matter) ----
|
||||||
|
for (const sub of SUBDOMAINS_FOR_CADDY) {
|
||||||
|
const host = `${sub}.${domain}`;
|
||||||
|
const a = await dnsResolve(host, 'A');
|
||||||
|
const aaaa = a.length === 0 ? await dnsResolve(host, 'AAAA') : [];
|
||||||
|
const cname = a.length === 0 && aaaa.length === 0 ? await dnsResolve(host, 'CNAME') : [];
|
||||||
|
|
||||||
|
if (a.length > 0) {
|
||||||
|
findings.push({ level: 'ok', label: `DNS ${host}`, detail: `A → ${a[0]}` });
|
||||||
|
} else if (aaaa.length > 0) {
|
||||||
|
findings.push({ level: 'ok', label: `DNS ${host}`, detail: `AAAA → ${aaaa[0]}` });
|
||||||
|
} else if (cname.length > 0) {
|
||||||
|
findings.push({ level: 'ok', label: `DNS ${host}`, detail: `CNAME → ${cname[0]}` });
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: `DNS ${host}`,
|
||||||
|
detail: 'Does not resolve. Caddy cannot issue a cert without DNS pointing here.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'dns',
|
||||||
|
title: 'DNS',
|
||||||
|
level: worstLevel(findings.map((f) => f.level)),
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3) Caddy cert check
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface CertResult {
|
||||||
|
validFrom: Date | null;
|
||||||
|
validTo: Date | null;
|
||||||
|
cn: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCertOnce(host: string, port = 443, timeoutMs = 7000): Promise<CertResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (r: CertResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try { socket.destroy(); } catch { /* ignore */ }
|
||||||
|
resolve(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = tls.connect({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
servername: host,
|
||||||
|
// We DO want to inspect even bad certs (e.g. self-signed) so we
|
||||||
|
// can report useful info instead of just "connection failed".
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
}, () => {
|
||||||
|
try {
|
||||||
|
const cert = socket.getPeerCertificate();
|
||||||
|
if (!cert || Object.keys(cert).length === 0) {
|
||||||
|
finish({ validFrom: null, validTo: null, cn: null, error: 'No peer certificate returned' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validFrom = cert.valid_from ? new Date(cert.valid_from) : null;
|
||||||
|
const validTo = cert.valid_to ? new Date(cert.valid_to) : null;
|
||||||
|
const cn = cert.subject?.CN ?? null;
|
||||||
|
finish({ validFrom, validTo, cn, error: null });
|
||||||
|
} catch (e: any) {
|
||||||
|
finish({ validFrom: null, validTo: null, cn: null, error: e?.message ?? 'parse error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (e) => finish({ validFrom: null, validTo: null, cn: null, error: e.message }));
|
||||||
|
socket.on('timeout', () => finish({ validFrom: null, validTo: null, cn: null, error: 'TLS handshake timed out' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCaddyCerts(domain: string): Promise<HealthCheck> {
|
||||||
|
const findings: HealthFinding[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const sub of SUBDOMAINS_FOR_CADDY) {
|
||||||
|
const host = `${sub}.${domain}`;
|
||||||
|
const r = await checkCertOnce(host);
|
||||||
|
|
||||||
|
if (r.error || !r.validTo) {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: host,
|
||||||
|
detail: r.error ?? 'No cert info available',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysLeft = Math.floor((r.validTo.getTime() - now) / (1000 * 60 * 60 * 24));
|
||||||
|
const expIso = r.validTo.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (daysLeft < 0) {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: host,
|
||||||
|
detail: `Cert EXPIRED on ${expIso} (${Math.abs(daysLeft)} days ago)`,
|
||||||
|
});
|
||||||
|
} else if (daysLeft <= CERT_WARN_DAYS) {
|
||||||
|
findings.push({
|
||||||
|
level: 'warn',
|
||||||
|
label: host,
|
||||||
|
detail: `Cert expires in ${daysLeft} days (${expIso})`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'ok',
|
||||||
|
label: host,
|
||||||
|
detail: `Cert valid until ${expIso} (${daysLeft} days)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'caddy',
|
||||||
|
title: 'TLS certificates',
|
||||||
|
level: worstLevel(findings.map((f) => f.level)),
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public: run all checks for a domain
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function runDomainHealthChecks(domain: string): Promise<DomainHealthReport> {
|
||||||
|
const d = domain.toLowerCase();
|
||||||
|
|
||||||
|
const [dmsResult, dnsResult, caddyResult] = await Promise.all([
|
||||||
|
checkDms(d),
|
||||||
|
checkDns(d),
|
||||||
|
checkCaddyCerts(d),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checks: HealthCheck[] = [dmsResult, dnsResult, caddyResult];
|
||||||
|
const overall = worstLevel(checks.map((c) => c.level));
|
||||||
|
const has_problems = overall === 'fail' || overall === 'warn';
|
||||||
|
|
||||||
|
const report: DomainHealthReport = {
|
||||||
|
domain: d,
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
has_problems,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist for the banner.
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO domain_health_status (domain, checked_at, has_problems, details)
|
||||||
|
VALUES ($1, now(), $2, $3::jsonb)
|
||||||
|
ON CONFLICT (domain) DO UPDATE SET
|
||||||
|
checked_at = EXCLUDED.checked_at,
|
||||||
|
has_problems = EXCLUDED.has_problems,
|
||||||
|
details = EXCLUDED.details`,
|
||||||
|
[d, has_problems, JSON.stringify(report)],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[health] could not persist health status:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public: load last persisted status (used by mailbox view banner)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface PersistedHealth {
|
||||||
|
domain: string;
|
||||||
|
checked_at: string;
|
||||||
|
has_problems: boolean;
|
||||||
|
summary: { fail: number; warn: number; unknown: number; ok: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersistedHealth(domain: string): Promise<PersistedHealth | null> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT domain, checked_at, has_problems, details
|
||||||
|
FROM domain_health_status WHERE domain=$1`,
|
||||||
|
[domain.toLowerCase()],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
// Build a quick summary of finding counts so the banner can say
|
||||||
|
// "2 problems detected" without needing to rehydrate the whole modal.
|
||||||
|
const counts = { fail: 0, warn: 0, unknown: 0, ok: 0 };
|
||||||
|
const details = row.details as DomainHealthReport;
|
||||||
|
for (const c of details?.checks ?? []) {
|
||||||
|
for (const f of c.findings) {
|
||||||
|
counts[f.level] = (counts[f.level] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: row.domain,
|
||||||
|
checked_at: row.checked_at instanceof Date ? row.checked_at.toISOString() : row.checked_at,
|
||||||
|
has_problems: row.has_problems,
|
||||||
|
summary: counts,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||||
FiUsers, FiUser, FiHardDrive, FiDollarSign,
|
FiUsers, FiUser, FiHardDrive, FiDollarSign, FiActivity,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
@@ -17,8 +17,10 @@ import AdminUsersModal from './components/AdminUsersModal';
|
|||||||
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||||
import DomainQuotaModal from './components/DomainQuotaModal';
|
import DomainQuotaModal from './components/DomainQuotaModal';
|
||||||
import BillingModal from './components/BillingModal';
|
import BillingModal from './components/BillingModal';
|
||||||
|
import HealthModal from './components/HealthModal';
|
||||||
|
import HealthBanner from './components/HealthBanner';
|
||||||
|
|
||||||
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
import { authAPI, domainsAPI, mailboxesAPI, healthAPI } from './services/api';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
@@ -40,6 +42,10 @@ function App() {
|
|||||||
const [showChangePw, setShowChangePw] = useState(false);
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
||||||
const [showBilling, setShowBilling] = useState(false);
|
const [showBilling, setShowBilling] = useState(false);
|
||||||
|
const [showHealth, setShowHealth] = useState(false);
|
||||||
|
|
||||||
|
// Persisted health status for the currently selected domain (drives the banner).
|
||||||
|
const [healthStatus, setHealthStatus] = useState(null);
|
||||||
|
|
||||||
const showToast = useCallback((message, type = 'success') => {
|
const showToast = useCallback((message, type = 'success') => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
@@ -60,6 +66,20 @@ function App() {
|
|||||||
setMailboxes(list);
|
setMailboxes(list);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load (or re-load) the persisted health status for a domain.
|
||||||
|
// Cheap call — just reads from PostgreSQL, no checks are run.
|
||||||
|
const loadHealthStatus = useCallback(async (domain) => {
|
||||||
|
if (!domain) { setHealthStatus(null); return; }
|
||||||
|
try {
|
||||||
|
const status = await healthAPI.getStatus(domain);
|
||||||
|
setHealthStatus(status); // null if never checked, that's fine
|
||||||
|
} catch (err) {
|
||||||
|
// Silent: don't block the UI just because health status load failed.
|
||||||
|
console.warn('Failed to load health status:', err);
|
||||||
|
setHealthStatus(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +104,7 @@ function App() {
|
|||||||
if (first) {
|
if (first) {
|
||||||
setBusyMessage('Refreshing quotas...');
|
setBusyMessage('Refreshing quotas...');
|
||||||
await loadMailboxes(first, true);
|
await loadMailboxes(first, true);
|
||||||
|
await loadHealthStatus(first);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Failed to load: ${err.message}`, 'error');
|
showToast(`Failed to load: ${err.message}`, 'error');
|
||||||
@@ -100,6 +121,7 @@ function App() {
|
|||||||
setBusyMessage('Loading mailboxes...');
|
setBusyMessage('Loading mailboxes...');
|
||||||
try {
|
try {
|
||||||
await loadMailboxes(domain, true);
|
await loadMailboxes(domain, true);
|
||||||
|
await loadHealthStatus(domain);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Failed to load mailboxes: ${err.message}`, 'error');
|
showToast(`Failed to load mailboxes: ${err.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -115,7 +137,10 @@ function App() {
|
|||||||
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
||||||
const first = list[0]?.domain || null;
|
const first = list[0]?.domain || null;
|
||||||
setSelectedDomain(first);
|
setSelectedDomain(first);
|
||||||
if (first) await loadMailboxes(first, false);
|
if (first) {
|
||||||
|
await loadMailboxes(first, false);
|
||||||
|
await loadHealthStatus(first);
|
||||||
|
}
|
||||||
} else if (selectedDomain) {
|
} else if (selectedDomain) {
|
||||||
await loadMailboxes(selectedDomain, false);
|
await loadMailboxes(selectedDomain, false);
|
||||||
}
|
}
|
||||||
@@ -133,6 +158,7 @@ function App() {
|
|||||||
setDomains([]);
|
setDomains([]);
|
||||||
setMailboxes([]);
|
setMailboxes([]);
|
||||||
setSelectedDomain(null);
|
setSelectedDomain(null);
|
||||||
|
setHealthStatus(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@@ -167,6 +193,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Called by HealthModal after a fresh check completes — re-load the
|
||||||
|
// persisted summary so the banner reflects the new state.
|
||||||
|
const handleHealthChecked = async () => {
|
||||||
|
if (selectedDomain) await loadHealthStatus(selectedDomain);
|
||||||
|
};
|
||||||
|
|
||||||
if (!bootChecked) {
|
if (!bootChecked) {
|
||||||
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
||||||
}
|
}
|
||||||
@@ -269,6 +301,9 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="card relative">
|
<section className="card relative">
|
||||||
|
{/* Health banner (shows only when last check found problems) */}
|
||||||
|
<HealthBanner status={healthStatus} onOpen={() => setShowHealth(true)} />
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-5 flex-wrap">
|
<div className="flex items-start justify-between gap-4 mb-5 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-gray-900">
|
<h2 className="text-base font-semibold text-gray-900">
|
||||||
@@ -278,7 +313,16 @@ function App() {
|
|||||||
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
|
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-nowrap gap-2">
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHealth(true)}
|
||||||
|
disabled={!selectedDomain}
|
||||||
|
className="btn-secondary"
|
||||||
|
title="Run DNS, DMS and TLS cert checks for this domain"
|
||||||
|
>
|
||||||
|
<FiActivity className="w-4 h-4 mr-2" />
|
||||||
|
Check health
|
||||||
|
</button>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDomainQuota(true)}
|
onClick={() => setShowDomainQuota(true)}
|
||||||
@@ -441,6 +485,13 @@ function App() {
|
|||||||
onToast={showToast}
|
onToast={showToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<HealthModal
|
||||||
|
open={showHealth}
|
||||||
|
domain={selectedDomain}
|
||||||
|
onClose={() => setShowHealth(false)}
|
||||||
|
onCheckedReport={handleHealthChecked}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
<ChangeMyPasswordModal
|
<ChangeMyPasswordModal
|
||||||
open={showChangePw}
|
open={showChangePw}
|
||||||
onClose={() => setShowChangePw(false)}
|
onClose={() => setShowChangePw(false)}
|
||||||
|
|||||||
56
frontend/src/components/HealthBanner.jsx
Normal file
56
frontend/src/components/HealthBanner.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FiAlertTriangle, FiXCircle } from 'react-icons/fi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders nothing if status is null, has no problems, or there are no
|
||||||
|
* fail/warn findings. Otherwise shows a small banner with a "View details"
|
||||||
|
* action that opens the HealthModal.
|
||||||
|
*/
|
||||||
|
const HealthBanner = ({ status, onOpen }) => {
|
||||||
|
if (!status || !status.has_problems) return null;
|
||||||
|
|
||||||
|
const fails = status.summary?.fail ?? 0;
|
||||||
|
const warns = status.summary?.warn ?? 0;
|
||||||
|
|
||||||
|
const isCritical = fails > 0;
|
||||||
|
const Icon = isCritical ? FiXCircle : FiAlertTriangle;
|
||||||
|
|
||||||
|
const wrapperColor = isCritical
|
||||||
|
? 'bg-red-50 border-red-200'
|
||||||
|
: 'bg-amber-50 border-amber-200';
|
||||||
|
const iconColor = isCritical ? 'text-red-600' : 'text-amber-600';
|
||||||
|
const buttonColor = isCritical
|
||||||
|
? 'text-red-700 hover:text-red-900 hover:bg-red-100'
|
||||||
|
: 'text-amber-700 hover:text-amber-900 hover:bg-amber-100';
|
||||||
|
|
||||||
|
let headline;
|
||||||
|
if (fails > 0 && warns > 0) {
|
||||||
|
headline = `${fails} problem${fails === 1 ? '' : 's'} and ${warns} warning${warns === 1 ? '' : 's'} detected`;
|
||||||
|
} else if (fails > 0) {
|
||||||
|
headline = `${fails} problem${fails === 1 ? '' : 's'} detected`;
|
||||||
|
} else {
|
||||||
|
headline = `${warns} warning${warns === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between gap-3 ${wrapperColor} border rounded-lg p-3 mb-4`}>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Icon className={`w-5 h-5 ${iconColor} flex-shrink-0`} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 truncate">{headline}</p>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Last checked {new Date(status.checked_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${buttonColor}`}
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthBanner;
|
||||||
151
frontend/src/components/HealthModal.jsx
Normal file
151
frontend/src/components/HealthModal.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FiCheckCircle, FiAlertTriangle, FiXCircle, FiHelpCircle, FiRefreshCw } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import { healthAPI } from '../services/api';
|
||||||
|
|
||||||
|
const LEVEL_STYLES = {
|
||||||
|
ok: { icon: FiCheckCircle, color: 'text-green-600', bg: 'bg-green-50', pill: 'pill-success', label: 'OK' },
|
||||||
|
warn: { icon: FiAlertTriangle, color: 'text-amber-600', bg: 'bg-amber-50', pill: 'pill-warn', label: 'Warning' },
|
||||||
|
fail: { icon: FiXCircle, color: 'text-red-600', bg: 'bg-red-50', pill: 'pill', label: 'Problem' },
|
||||||
|
unknown: { icon: FiHelpCircle, color: 'text-gray-500', bg: 'bg-gray-50', pill: 'pill', label: 'Unknown' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const HealthModal = ({ open, domain, onClose, onCheckedReport, onToast }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [report, setReport] = useState(null);
|
||||||
|
|
||||||
|
// When the modal opens, run a fresh check immediately (this is on-demand
|
||||||
|
// mode — the user clicked the "Check health" button, so they expect a
|
||||||
|
// current run, not a cached one).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !domain) { setReport(null); return; }
|
||||||
|
runCheck();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, domain]);
|
||||||
|
|
||||||
|
const runCheck = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await healthAPI.runCheck(domain);
|
||||||
|
setReport(data);
|
||||||
|
onCheckedReport?.(data);
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Health check failed: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Domain health"
|
||||||
|
subtitle={domain}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[350px]">
|
||||||
|
{loading && <LoadingOverlay message="Running checks..." />}
|
||||||
|
|
||||||
|
{!loading && report && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Overall summary */}
|
||||||
|
<OverallBanner report={report} />
|
||||||
|
|
||||||
|
{/* Per check */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{report.checks.map((check) => (
|
||||||
|
<CheckCard key={check.id} check={check} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Re-run button */}
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Last checked: {new Date(report.checked_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button onClick={runCheck} className="btn-secondary">
|
||||||
|
<FiRefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Run check again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OverallBanner = ({ report }) => {
|
||||||
|
// Compute counts from findings to give a "X problems / Y warnings" headline.
|
||||||
|
const counts = { fail: 0, warn: 0, unknown: 0, ok: 0 };
|
||||||
|
for (const c of report.checks) {
|
||||||
|
for (const f of c.findings) counts[f.level] = (counts[f.level] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headline, sub, style;
|
||||||
|
if (counts.fail > 0) {
|
||||||
|
headline = `${counts.fail} problem${counts.fail === 1 ? '' : 's'} detected`;
|
||||||
|
sub = counts.warn > 0 ? `Plus ${counts.warn} warning${counts.warn === 1 ? '' : 's'}.` : 'Action required.';
|
||||||
|
style = LEVEL_STYLES.fail;
|
||||||
|
} else if (counts.warn > 0) {
|
||||||
|
headline = `${counts.warn} warning${counts.warn === 1 ? '' : 's'}`;
|
||||||
|
sub = 'No critical problems, but worth a look.';
|
||||||
|
style = LEVEL_STYLES.warn;
|
||||||
|
} else {
|
||||||
|
headline = 'All checks passed';
|
||||||
|
sub = counts.unknown > 0
|
||||||
|
? `${counts.unknown} item${counts.unknown === 1 ? '' : 's'} could not be verified automatically.`
|
||||||
|
: 'Domain looks healthy.';
|
||||||
|
style = LEVEL_STYLES.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-start gap-3 ${style.bg} border border-gray-200 rounded-lg p-4`}>
|
||||||
|
<Icon className={`w-6 h-6 ${style.color} flex-shrink-0 mt-0.5`} />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{headline}</h3>
|
||||||
|
<p className="text-sm text-gray-700 mt-0.5">{sub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckCard = ({ check }) => {
|
||||||
|
const style = LEVEL_STYLES[check.level] || LEVEL_STYLES.unknown;
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`w-5 h-5 ${style.color}`} />
|
||||||
|
<h4 className="font-semibold text-gray-900">{check.title}</h4>
|
||||||
|
</div>
|
||||||
|
<span className={style.pill}>{style.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{check.findings.map((f, idx) => {
|
||||||
|
const fStyle = LEVEL_STYLES[f.level] || LEVEL_STYLES.unknown;
|
||||||
|
const FIcon = fStyle.icon;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-start gap-3 px-4 py-2.5">
|
||||||
|
<FIcon className={`w-4 h-4 ${fStyle.color} flex-shrink-0 mt-0.5`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-800">{f.label}</div>
|
||||||
|
{f.detail && (
|
||||||
|
<div className="text-xs text-gray-600 mt-0.5 break-words">{f.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthModal;
|
||||||
@@ -102,3 +102,19 @@ export const billingAPI = {
|
|||||||
return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data;
|
return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const healthAPI = {
|
||||||
|
// Read the last persisted status (cheap; used by the banner).
|
||||||
|
// Returns null if the domain has never been checked.
|
||||||
|
getStatus: async (domain) => {
|
||||||
|
try {
|
||||||
|
return (await api.get(`/api/health/domains/${encodeURIComponent(domain)}`)).data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Run all checks now and return the full report.
|
||||||
|
runCheck: async (domain) =>
|
||||||
|
(await api.post(`/api/health/domains/${encodeURIComponent(domain)}/check`)).data,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user