domain_health_status
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||
FiUsers, FiUser, FiHardDrive, FiDollarSign,
|
||||
FiUsers, FiUser, FiHardDrive, FiDollarSign, FiActivity,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import Login from './components/Login';
|
||||
@@ -17,8 +17,10 @@ import AdminUsersModal from './components/AdminUsersModal';
|
||||
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||
import DomainQuotaModal from './components/DomainQuotaModal';
|
||||
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() {
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -40,6 +42,10 @@ function App() {
|
||||
const [showChangePw, setShowChangePw] = useState(false);
|
||||
const [showDomainQuota, setShowDomainQuota] = 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') => {
|
||||
setToast({ message, type });
|
||||
@@ -60,6 +66,20 @@ function App() {
|
||||
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(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -84,6 +104,7 @@ function App() {
|
||||
if (first) {
|
||||
setBusyMessage('Refreshing quotas...');
|
||||
await loadMailboxes(first, true);
|
||||
await loadHealthStatus(first);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Failed to load: ${err.message}`, 'error');
|
||||
@@ -100,6 +121,7 @@ function App() {
|
||||
setBusyMessage('Loading mailboxes...');
|
||||
try {
|
||||
await loadMailboxes(domain, true);
|
||||
await loadHealthStatus(domain);
|
||||
} catch (err) {
|
||||
showToast(`Failed to load mailboxes: ${err.message}`, 'error');
|
||||
} finally {
|
||||
@@ -115,7 +137,10 @@ function App() {
|
||||
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
||||
const first = list[0]?.domain || null;
|
||||
setSelectedDomain(first);
|
||||
if (first) await loadMailboxes(first, false);
|
||||
if (first) {
|
||||
await loadMailboxes(first, false);
|
||||
await loadHealthStatus(first);
|
||||
}
|
||||
} else if (selectedDomain) {
|
||||
await loadMailboxes(selectedDomain, false);
|
||||
}
|
||||
@@ -133,6 +158,7 @@ function App() {
|
||||
setDomains([]);
|
||||
setMailboxes([]);
|
||||
setSelectedDomain(null);
|
||||
setHealthStatus(null);
|
||||
};
|
||||
|
||||
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) {
|
||||
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">
|
||||
{/* 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>
|
||||
<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.
|
||||
</p>
|
||||
</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 && (
|
||||
<button
|
||||
onClick={() => setShowDomainQuota(true)}
|
||||
@@ -441,6 +485,13 @@ function App() {
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
<HealthModal
|
||||
open={showHealth}
|
||||
domain={selectedDomain}
|
||||
onClose={() => setShowHealth(false)}
|
||||
onCheckedReport={handleHealthChecked}
|
||||
onToast={showToast}
|
||||
/>
|
||||
<ChangeMyPasswordModal
|
||||
open={showChangePw}
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
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