Skip to content

Commit 009f5e8

Browse files
authored
Merge pull request #273 from DavisVT/feature/notification-health-widget
Implement Notification Health Status Widget
2 parents 26d96b6 + 667a496 commit 009f5e8

4 files changed

Lines changed: 414 additions & 0 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+
import {
3+
fetchScheduleStats,
4+
fetchHealth,
5+
fetchAnalytics,
6+
} from '../services/notificationHealthApi';
7+
import type {
8+
ScheduleStatsResponse,
9+
HealthResponse,
10+
NotificationAnalyticsSnapshot,
11+
} from '../types/notificationHealth';
12+
import { formatTimestampShort } from '../utils/formatTime';
13+
import { formatDuration } from '../utils/formatDuration';
14+
15+
const DEFAULT_POLL_INTERVAL_MS = 5000;
16+
17+
function serviceStatusLabel(status: string): string {
18+
switch (status) {
19+
case 'ok':
20+
return 'Healthy';
21+
case 'error':
22+
return 'Error';
23+
case 'not_configured':
24+
return 'Not Configured';
25+
default:
26+
return 'Unknown';
27+
}
28+
}
29+
30+
function serviceStatusClass(status: string): string {
31+
switch (status) {
32+
case 'ok':
33+
return 'notification-health__service--ok';
34+
case 'error':
35+
return 'notification-health__service--error';
36+
case 'not_configured':
37+
return 'notification-health__service--not-configured';
38+
default:
39+
return 'notification-health__service--unknown';
40+
}
41+
}
42+
43+
function overallStatusLabel(status: string): string {
44+
switch (status) {
45+
case 'ok':
46+
return 'Healthy';
47+
case 'degraded':
48+
return 'Degraded';
49+
case 'error':
50+
return 'Error';
51+
default:
52+
return 'Unknown';
53+
}
54+
}
55+
56+
function overallStatusClass(status: string): string {
57+
switch (status) {
58+
case 'ok':
59+
return 'notification-health__status--ok';
60+
case 'degraded':
61+
return 'notification-health__status--degraded';
62+
case 'error':
63+
return 'notification-health__status--error';
64+
default:
65+
return 'notification-health__status--unknown';
66+
}
67+
}
68+
69+
export function NotificationHealthPanel(props: { healthUrl: string; pollIntervalMs?: number }) {
70+
const pollIntervalMs = props.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
71+
const [scheduleStats, setScheduleStats] = useState<ScheduleStatsResponse | null>(null);
72+
const [health, setHealth] = useState<HealthResponse | null>(null);
73+
const [analytics, setAnalytics] = useState<NotificationAnalyticsSnapshot | null>(null);
74+
const [error, setError] = useState<string | null>(null);
75+
const [isRefreshing, setIsRefreshing] = useState(false);
76+
const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
77+
const abortRef = useRef<AbortController | null>(null);
78+
79+
const effectivePollIntervalMs = useMemo(() => {
80+
if (typeof document === 'undefined') return pollIntervalMs;
81+
return document.visibilityState === 'hidden' ? pollIntervalMs * 3 : pollIntervalMs;
82+
}, [pollIntervalMs]);
83+
84+
const refresh = useCallback(async () => {
85+
abortRef.current?.abort();
86+
const controller = new AbortController();
87+
abortRef.current = controller;
88+
89+
setIsRefreshing(true);
90+
setError(null);
91+
92+
try {
93+
const [scheduleStatsData, healthData, analyticsData] = await Promise.allSettled([
94+
fetchScheduleStats(props.healthUrl),
95+
fetchHealth(props.healthUrl),
96+
fetchAnalytics(props.healthUrl),
97+
]);
98+
99+
if (scheduleStatsData.status === 'fulfilled') {
100+
setScheduleStats(scheduleStatsData.value);
101+
}
102+
if (healthData.status === 'fulfilled') {
103+
setHealth(healthData.value);
104+
}
105+
if (analyticsData.status === 'fulfilled') {
106+
setAnalytics(analyticsData.value);
107+
}
108+
109+
const allRejected =
110+
scheduleStatsData.status === 'rejected' &&
111+
healthData.status === 'rejected' &&
112+
analyticsData.status === 'rejected';
113+
114+
if (allRejected) {
115+
const errors = [scheduleStatsData, healthData, analyticsData].map(
116+
(p) => (p as PromiseRejectedResult).reason
117+
);
118+
setError(errors.map((e) => (e instanceof Error ? e.message : String(e))).join(', '));
119+
}
120+
121+
setLastUpdated(Date.now());
122+
} catch (err) {
123+
if ((err as any)?.name === 'AbortError') return;
124+
setError(err instanceof Error ? err.message : String(err));
125+
} finally {
126+
setIsRefreshing(false);
127+
}
128+
}, [props.healthUrl]);
129+
130+
useEffect(() => {
131+
let cancelled = false;
132+
let timer: ReturnType<typeof setTimeout> | null = null;
133+
134+
const schedule = (ms: number) => {
135+
if (cancelled) return;
136+
timer = setTimeout(async () => {
137+
await refresh();
138+
schedule(effectivePollIntervalMs);
139+
}, ms);
140+
};
141+
142+
void refresh();
143+
schedule(effectivePollIntervalMs);
144+
145+
const onVisibilityChange = () => {
146+
if (document.visibilityState === 'visible') {
147+
void refresh();
148+
}
149+
};
150+
document.addEventListener('visibilitychange', onVisibilityChange);
151+
152+
return () => {
153+
cancelled = true;
154+
abortRef.current?.abort();
155+
if (timer) clearTimeout(timer);
156+
document.removeEventListener('visibilitychange', onVisibilityChange);
157+
};
158+
}, [effectivePollIntervalMs, refresh]);
159+
160+
const overallStatus = health?.status ?? 'unknown';
161+
const successRate = analytics?.overall.successRate ?? 0;
162+
163+
return (
164+
<section className="notification-health" aria-label="Notification Health">
165+
<div className="notification-health__header">
166+
<div>
167+
<p className="notification-health__eyebrow">Monitor</p>
168+
<h2 className="notification-health__title">Notification Health</h2>
169+
</div>
170+
171+
<div className="notification-health__meta">
172+
<span className={`notification-health__status ${overallStatusClass(overallStatus)}`}>
173+
{overallStatusLabel(overallStatus)}
174+
</span>
175+
<span className="notification-health__updated">
176+
{isRefreshing ? 'Updating…' : `Updated ${formatTimestampShort(lastUpdated)}`}
177+
</span>
178+
</div>
179+
</div>
180+
181+
{error && (
182+
<p className="notification-health__error" role="alert">
183+
{error}
184+
</p>
185+
)}
186+
187+
<div className="notification-health__grid">
188+
<div className="notification-health__card">
189+
<h3 className="notification-health__card-title">Queue Health</h3>
190+
<div className="notification-health__metrics-grid">
191+
{scheduleStats && (
192+
<>
193+
<div className="notification-health__metric">
194+
<dt>Pending</dt>
195+
<dd>{scheduleStats.pending.toLocaleString()}</dd>
196+
</div>
197+
<div className="notification-health__metric">
198+
<dt>Processing</dt>
199+
<dd>{scheduleStats.processing.toLocaleString()}</dd>
200+
</div>
201+
<div className="notification-health__metric">
202+
<dt>Completed</dt>
203+
<dd>{scheduleStats.completed.toLocaleString()}</dd>
204+
</div>
205+
<div className="notification-health__metric">
206+
<dt>Failed</dt>
207+
<dd>{scheduleStats.failed.toLocaleString()}</dd>
208+
</div>
209+
<div className="notification-health__metric">
210+
<dt>Overdue</dt>
211+
<dd>{scheduleStats.overdue.toLocaleString()}</dd>
212+
</div>
213+
</>
214+
)}
215+
</div>
216+
</div>
217+
218+
<div className="notification-health__card">
219+
<h3 className="notification-health__card-title">Delivery Status</h3>
220+
<div className="notification-health__metrics-grid">
221+
{analytics && (
222+
<>
223+
<div className="notification-health__metric">
224+
<dt>Success Rate</dt>
225+
<dd>{(successRate * 100).toFixed(1)}%</dd>
226+
</div>
227+
<div className="notification-health__metric">
228+
<dt>Total Delivered</dt>
229+
<dd>{analytics.overall.total.toLocaleString()}</dd>
230+
</div>
231+
<div className="notification-health__metric">
232+
<dt>Success</dt>
233+
<dd>{analytics.overall.success.toLocaleString()}</dd>
234+
</div>
235+
<div className="notification-health__metric">
236+
<dt>Failure</dt>
237+
<dd>{analytics.overall.failure.toLocaleString()}</dd>
238+
</div>
239+
<div className="notification-health__metric">
240+
<dt>Avg Duration</dt>
241+
<dd>{formatDuration(analytics.overall.averageDurationMs)}</dd>
242+
</div>
243+
</>
244+
)}
245+
</div>
246+
</div>
247+
248+
<div className="notification-health__card notification-health__card--full">
249+
<h3 className="notification-health__card-title">Service Indicators</h3>
250+
<div className="notification-health__services-grid">
251+
{health && (
252+
<>
253+
<div className={`notification-health__service ${serviceStatusClass(health.services.stellarRpc.status)}`}>
254+
<div className="notification-health__service-name">Stellar RPC</div>
255+
<div className="notification-health__service-status">
256+
{serviceStatusLabel(health.services.stellarRpc.status)}
257+
</div>
258+
{health.services.stellarRpc.latencyMs && (
259+
<div className="notification-health__service-latency">
260+
{health.services.stellarRpc.latencyMs}ms
261+
</div>
262+
)}
263+
{health.services.stellarRpc.detail && (
264+
<div className="notification-health__service-detail">
265+
{health.services.stellarRpc.detail}
266+
</div>
267+
)}
268+
</div>
269+
<div className={`notification-health__service ${serviceStatusClass(health.services.discord.status)}`}>
270+
<div className="notification-health__service-name">Discord</div>
271+
<div className="notification-health__service-status">
272+
{serviceStatusLabel(health.services.discord.status)}
273+
</div>
274+
{health.services.discord.latencyMs && (
275+
<div className="notification-health__service-latency">
276+
{health.services.discord.latencyMs}ms
277+
</div>
278+
)}
279+
{health.services.discord.detail && (
280+
<div className="notification-health__service-detail">
281+
{health.services.discord.detail}
282+
</div>
283+
)}
284+
</div>
285+
<div className={`notification-health__service ${serviceStatusClass(health.services.eventRegistry.status)}`}>
286+
<div className="notification-health__service-name">Event Registry</div>
287+
<div className="notification-health__service-status">
288+
{serviceStatusLabel(health.services.eventRegistry.status)}
289+
</div>
290+
<div className="notification-health__service-detail">
291+
{health.services.eventRegistry.eventCount.toLocaleString()} events
292+
</div>
293+
</div>
294+
</>
295+
)}
296+
</div>
297+
</div>
298+
</div>
299+
</section>
300+
);
301+
}

dashboard/src/pages/EventExplorerPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton';
77
import { PaginationControls } from '../components/PaginationControls';
88
import { NotificationDetailsDrawer } from '../components/NotificationDetailsDrawer';
99
import { IndexingHealthPanel } from '../components/IndexingHealthPanel';
10+
import { NotificationHealthPanel } from '../components/NotificationHealthPanel';
1011
import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors';
1112
import { useEventStore } from '../store/eventStore';
1213
import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi';
1314
import { resolveIndexingHealthUrl } from '../services/indexingHealthApi';
15+
import { resolveNotificationHealthUrl } from '../services/notificationHealthApi';
1416
import { generateMockEvents } from '../utils/eventData';
1517
import { restoreWalletSession } from '../services/wallet';
1618
import type { BlockchainEvent } from '../types/event';
@@ -23,6 +25,8 @@ const POLL_INTERVAL_MS = 15_000;
2325
const LISTENER_BASE_URL = API_URL.replace('/api/events', '');
2426
const INDEXING_HEALTH_URL =
2527
import.meta.env.VITE_INDEXING_HEALTH_URL ?? resolveIndexingHealthUrl(API_URL);
28+
const NOTIFICATION_HEALTH_URL =
29+
import.meta.env.VITE_NOTIFICATION_HEALTH_URL ?? resolveNotificationHealthUrl(API_URL);
2630

2731
function parsePageParam(search: string) {
2832
const params = new URLSearchParams(search);
@@ -237,6 +241,7 @@ export function EventExplorerPage() {
237241
</section>
238242
)}
239243
<IndexingHealthPanel healthUrl={INDEXING_HEALTH_URL} />
244+
<NotificationHealthPanel healthUrl={NOTIFICATION_HEALTH_URL} />
240245

241246
<EventFiltersBar />
242247
<NotificationSearchBar />
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type {
2+
ScheduleStatsResponse,
3+
HealthResponse,
4+
NotificationAnalyticsSnapshot,
5+
} from '../types/notificationHealth';
6+
7+
export async function fetchScheduleStats(apiUrl: string): Promise<ScheduleStatsResponse> {
8+
const response = await fetch(`${apiUrl}/api/schedule/stats`);
9+
if (!response.ok) {
10+
throw new Error(`Failed to fetch schedule stats: ${response.status}`);
11+
}
12+
return response.json() as Promise<ScheduleStatsResponse>;
13+
}
14+
15+
export async function fetchHealth(apiUrl: string): Promise<HealthResponse> {
16+
const response = await fetch(`${apiUrl}/health`);
17+
if (!response.ok) {
18+
throw new Error(`Failed to fetch health: ${response.status}`);
19+
}
20+
return response.json() as Promise<HealthResponse>;
21+
}
22+
23+
export async function fetchAnalytics(apiUrl: string): Promise<NotificationAnalyticsSnapshot> {
24+
const response = await fetch(`${apiUrl}/api/analytics`);
25+
if (!response.ok) {
26+
throw new Error(`Failed to fetch analytics: ${response.status}`);
27+
}
28+
return response.json() as Promise<NotificationAnalyticsSnapshot>;
29+
}
30+
31+
export function resolveNotificationHealthUrl(eventsApiUrl: string): string {
32+
try {
33+
const url = new URL(eventsApiUrl);
34+
url.pathname = '';
35+
url.search = '';
36+
return url.toString();
37+
} catch {
38+
return 'http://localhost:8787';
39+
}
40+
}

0 commit comments

Comments
 (0)