domain_health_status

This commit is contained in:
2026-04-28 20:38:50 -05:00
parent f097f96d06
commit 62219a372a
8 changed files with 743 additions and 4 deletions

View 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);

View 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);
});

View File

@@ -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;

View 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,
};
}

View File

@@ -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)}

View 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;

View 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;

View File

@@ -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,
};