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.')}
+
+ ) : (
+
+
+
+
+
+ | {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')} |
+
+
+
+ {stuckEmails.map((m) => (
+
+ | {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}`);
+ },
+};