feat(admin): System health page surfacing stuck/failed emails

New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
This commit is contained in:
Luca
2026-06-03 13:56:08 +02:00
parent a6e6ef7b83
commit 5d7b545bf7
8 changed files with 275 additions and 0 deletions
@@ -0,0 +1,115 @@
/**
* Admin → System health. Aggregates background failures that would
* otherwise go unnoticed. v1: stuck/failed outbound emails (the queue
* processor gave up or exhausted retries), with retry + dismiss.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { systemHealthService } from '../../services/systemHealth.service';
export const SystemHealthPage: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['system-health-failures'],
queryFn: () => systemHealthService.getFailures(),
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['system-health-failures'] });
const retryMutation = useMutation({
mutationFn: (id: number) => systemHealthService.retryEmail(id),
onSuccess: () => { toast.success(t('systemHealth.retriedToast', 'Email re-queued.')); invalidate(); },
onError: () => toast.error(t('toast.saveError')),
});
const dismissMutation = useMutation({
mutationFn: (id: number) => systemHealthService.dismissEmail(id),
onSuccess: () => { toast.success(t('systemHealth.dismissedToast', 'Dismissed.')); invalidate(); },
onError: () => toast.error(t('toast.saveError')),
});
const stuckEmails = data?.stuckEmails ?? [];
return (
<div className="container py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-theme">{t('systemHealth.title', 'System health')}</h1>
<p className="text-sm text-muted-theme mt-1">
{t('systemHealth.subtitle', 'Background failures that need attention.')}
</p>
</div>
<Card padding="lg">
<div className="flex items-center gap-2 mb-3">
<AlertCircle className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('systemHealth.stuckEmails.title', 'Stuck / failed emails')}
</h2>
{!isLoading && (
<span className="ml-1 text-sm text-muted-theme">({stuckEmails.length})</span>
)}
</div>
{isLoading ? <Loading /> : stuckEmails.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 py-6">
<CheckCircle className="w-5 h-5" />
{t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')}
</div>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.error', 'Error')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.queued', 'Queued')}</th>
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
</tr>
</thead>
<tbody>
{stuckEmails.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2 max-w-xs">
<span className="text-xs text-red-700 dark:text-red-400 break-words">
{m.errorMessage || t('systemHealth.stuckEmails.noError', 'retries exhausted')}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="sm"
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
onClick={() => retryMutation.mutate(m.id)}
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
{t('systemHealth.retry', 'Retry')}
</Button>
<button type="button"
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
onClick={() => dismissMutation.mutate(m.id)}
className="p-1.5 text-neutral-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Card>
</div>
);
};
+1
View File
@@ -8,6 +8,7 @@ export { ArchivesPage } from './ArchivesPage';
export { AnalyticsPage } from './AnalyticsPage';
export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { SystemHealthPage } from './SystemHealthPage';
export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage';