From 5d7b545bf74ee945799e0fbe809e9c96266f3926 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:56:08 +0200 Subject: [PATCH] feat(admin): System health page surfacing stuck/failed emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/routes/adminSystemHealth.js | 78 ++++++++++++ frontend/src/App.tsx | 2 + .../src/components/admin/AdminSidebar.tsx | 2 + frontend/src/i18n/locales/de.json | 21 ++++ frontend/src/i18n/locales/en.json | 21 ++++ frontend/src/pages/admin/SystemHealthPage.tsx | 115 ++++++++++++++++++ frontend/src/pages/admin/index.ts | 1 + frontend/src/services/systemHealth.service.ts | 35 ++++++ 8 files changed, 275 insertions(+) create mode 100644 frontend/src/pages/admin/SystemHealthPage.tsx create mode 100644 frontend/src/services/systemHealth.service.ts diff --git a/backend/src/routes/adminSystemHealth.js b/backend/src/routes/adminSystemHealth.js index a76bee98..26b16223 100644 --- a/backend/src/routes/adminSystemHealth.js +++ b/backend/src/routes/adminSystemHealth.js @@ -23,6 +23,7 @@ const { requirePermission } = require('../middleware/permissions'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { verifyDocumentArtefacts } = require('../services/backupIntegrityService'); const { getCoverageReport } = require('../services/backupCoverageService'); +const { db } = require('../database/db'); const router = express.Router(); @@ -84,4 +85,81 @@ router.get( }), ); +/** + * GET /api/admin/system-health/failures + * + * Surfaces background failures that would otherwise go unnoticed. v1 + * covers stuck/failed outbound emails: rows the queue processor has + * given up on (status='failed') or exhausted its retries on + * (status='pending' AND retry_count >= 3 — the processor only picks up + * retry_count < 3). Trigger: a 14h window where 'quote_sent' template + * errors left invoices unsent with no admin-visible signal. + */ +router.get( + '/failures', + requirePermission('settings.view'), + handleAsync(async (req, res) => { + const stuckEmails = await db('email_queue') + .where(function () { + this.where('status', 'failed') + .orWhere(function () { + this.where('status', 'pending').andWhere('retry_count', '>=', 3); + }); + }) + .orderBy('created_at', 'desc') + .limit(200) + .select('id', 'recipient_email', 'email_type', 'status', 'retry_count', 'error_message', 'created_at'); + + return successResponse(res, { + stuckEmails: stuckEmails.map((r) => ({ + id: r.id, + recipientEmail: r.recipient_email, + emailType: r.email_type, + status: r.status, + retryCount: r.retry_count, + errorMessage: r.error_message, + createdAt: r.created_at, + })), + counts: { stuckEmails: stuckEmails.length }, + }); + }), +); + +/** + * POST /failures/email/:id/retry — re-queue a stuck email (status back to + * pending, retry_count reset, error cleared, scheduled_at cleared so the + * 60s processor picks it up on its next pass). + */ +router.post( + '/failures/email/:id/retry', + requirePermission('settings.edit'), + handleAsync(async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const updated = await db('email_queue').where({ id }).update({ + status: 'pending', + retry_count: 0, + error_message: null, + scheduled_at: null, + }); + if (!updated) return res.status(404).json({ error: 'Email not found' }); + return successResponse(res, { retried: true }); + }), +); + +/** + * DELETE /failures/email/:id — dismiss a stuck email (remove the row so + * it stops surfacing). Use when the failure is understood + won't be sent. + */ +router.delete( + '/failures/email/:id', + requirePermission('settings.edit'), + handleAsync(async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + await db('email_queue').where({ id }).del(); + return successResponse(res, { dismissed: true }); + }), +); + module.exports = router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6bd48d8..a4d72da6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { ArchivesPage, AnalyticsPage, SettingsPage, + SystemHealthPage, UserManagementPage, CustomerManagementPage, CustomerDetailPage, @@ -267,6 +268,7 @@ function App() { } /> } /> + } /> } /> {/* Old top-level routes — these surfaces now live as diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 1f81e94b..b165e931 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -6,6 +6,7 @@ import { Archive, BarChart3, Settings, + Activity, X, Users, Briefcase, @@ -62,6 +63,7 @@ const navigation: NavItem[] = [ { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' }, { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, + { nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' }, { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' }, // Clients section (#354 follow-up) — admin-side surface for the // CRM-area sub-features. Today this entry leads to /admin/clients diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 7c371084..45df10eb 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -73,6 +73,26 @@ "message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?" } }, + "systemHealth": { + "title": "Systemzustand", + "subtitle": "Hintergrundfehler, die Aufmerksamkeit erfordern.", + "retry": "Erneut versuchen", + "dismiss": "Verwerfen", + "retriedToast": "E-Mail erneut eingereiht.", + "dismissedToast": "Verworfen.", + "stuckEmails": { + "title": "Hängende / fehlgeschlagene E-Mails", + "empty": "Keine hängenden oder fehlgeschlagenen E-Mails — alles in Ordnung.", + "noError": "Versuche aufgebraucht", + "col": { + "recipient": "Empfänger", + "type": "Typ", + "error": "Fehler", + "queued": "Eingereiht", + "actions": "Aktionen" + } + } + }, "common": { "loading": "Wird geladen...", "error": "Fehler", @@ -166,6 +186,7 @@ "dashboard": "Dashboard", "events": "Veranstaltungen", "settings": "Einstellungen", + "systemHealth": "Systemzustand", "archives": "Archive", "emailSettings": "E-Mail-Einstellungen", "branding": "Markenidentität", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e6279e33..ca1ec218 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -73,6 +73,26 @@ "message": "Are you sure you want to cancel the invitation for {{email}}?" } }, + "systemHealth": { + "title": "System health", + "subtitle": "Background failures that need attention.", + "retry": "Retry", + "dismiss": "Dismiss", + "retriedToast": "Email re-queued.", + "dismissedToast": "Dismissed.", + "stuckEmails": { + "title": "Stuck / failed emails", + "empty": "No stuck or failed emails — all clear.", + "noError": "retries exhausted", + "col": { + "recipient": "Recipient", + "type": "Type", + "error": "Error", + "queued": "Queued", + "actions": "Actions" + } + } + }, "common": { "loading": "Loading...", "error": "Error", @@ -167,6 +187,7 @@ "events": "Events", "archives": "Archives", "settings": "Settings", + "systemHealth": "System health", "eventTypes": "Event Types", "branding": "Branding", "emailSettings": "Email Settings", diff --git a/frontend/src/pages/admin/SystemHealthPage.tsx b/frontend/src/pages/admin/SystemHealthPage.tsx new file mode 100644 index 00000000..7df617d6 --- /dev/null +++ b/frontend/src/pages/admin/SystemHealthPage.tsx @@ -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 ( +
+
+

{t('systemHealth.title', 'System health')}

+

+ {t('systemHealth.subtitle', 'Background failures that need attention.')} +

+
+ + +
+ +

+ {t('systemHealth.stuckEmails.title', 'Stuck / failed emails')} +

+ {!isLoading && ( + ({stuckEmails.length}) + )} +
+ + {isLoading ? : stuckEmails.length === 0 ? ( +
+ + {t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')} +
+ ) : ( +
+
+ + + + + + + + + + + + {stuckEmails.map((m) => ( + + + + + + + + ))} + +
{t('systemHealth.stuckEmails.col.recipient', 'Recipient')}{t('systemHealth.stuckEmails.col.type', 'Type')}{t('systemHealth.stuckEmails.col.error', 'Error')}{t('systemHealth.stuckEmails.col.queued', 'Queued')}{t('systemHealth.stuckEmails.col.actions', 'Actions')}
{m.recipientEmail}{m.emailType} + + {m.errorMessage || t('systemHealth.stuckEmails.noError', 'retries exhausted')} + + {m.createdAt ? fmtDateTime(m.createdAt) : '—'} +
+ + +
+
+
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index 5e287b00..bfade88d 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -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'; diff --git a/frontend/src/services/systemHealth.service.ts b/frontend/src/services/systemHealth.service.ts new file mode 100644 index 00000000..2dd84035 --- /dev/null +++ b/frontend/src/services/systemHealth.service.ts @@ -0,0 +1,35 @@ +/** + * Admin → System health. Surfaces background failures (v1: stuck/failed + * outbound emails) so they don't sit unnoticed, with retry/dismiss. + */ +import { api } from '../config/api'; + +export interface StuckEmail { + id: number; + recipientEmail: string; + emailType: string; + status: 'pending' | 'failed'; + retryCount: number; + errorMessage: string | null; + createdAt: string; +} + +export interface SystemHealthFailures { + stuckEmails: StuckEmail[]; + counts: { stuckEmails: number }; +} + +export const systemHealthService = { + async getFailures(): Promise { + const { data } = await api.get('/admin/system-health/failures'); + return data.data || data; + }, + + async retryEmail(id: number): Promise { + await api.post(`/admin/system-health/failures/email/${id}/retry`); + }, + + async dismissEmail(id: number): Promise { + await api.delete(`/admin/system-health/failures/email/${id}`); + }, +};