Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 301 additions & 0 deletions dashboard/src/components/NotificationHealthPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
fetchScheduleStats,
fetchHealth,
fetchAnalytics,
} from '../services/notificationHealthApi';
import type {
ScheduleStatsResponse,
HealthResponse,
NotificationAnalyticsSnapshot,
} from '../types/notificationHealth';
import { formatTimestampShort } from '../utils/formatTime';
import { formatDuration } from '../utils/formatDuration';

const DEFAULT_POLL_INTERVAL_MS = 5000;

function serviceStatusLabel(status: string): string {
switch (status) {
case 'ok':
return 'Healthy';
case 'error':
return 'Error';
case 'not_configured':
return 'Not Configured';
default:
return 'Unknown';
}
}

function serviceStatusClass(status: string): string {
switch (status) {
case 'ok':
return 'notification-health__service--ok';
case 'error':
return 'notification-health__service--error';
case 'not_configured':
return 'notification-health__service--not-configured';
default:
return 'notification-health__service--unknown';
}
}

function overallStatusLabel(status: string): string {
switch (status) {
case 'ok':
return 'Healthy';
case 'degraded':
return 'Degraded';
case 'error':
return 'Error';
default:
return 'Unknown';
}
}

function overallStatusClass(status: string): string {
switch (status) {
case 'ok':
return 'notification-health__status--ok';
case 'degraded':
return 'notification-health__status--degraded';
case 'error':
return 'notification-health__status--error';
default:
return 'notification-health__status--unknown';
}
}

export function NotificationHealthPanel(props: { healthUrl: string; pollIntervalMs?: number }) {
const pollIntervalMs = props.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const [scheduleStats, setScheduleStats] = useState<ScheduleStatsResponse | null>(null);
const [health, setHealth] = useState<HealthResponse | null>(null);
const [analytics, setAnalytics] = useState<NotificationAnalyticsSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
const abortRef = useRef<AbortController | null>(null);

const effectivePollIntervalMs = useMemo(() => {
if (typeof document === 'undefined') return pollIntervalMs;
return document.visibilityState === 'hidden' ? pollIntervalMs * 3 : pollIntervalMs;
}, [pollIntervalMs]);

const refresh = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;

setIsRefreshing(true);
setError(null);

try {
const [scheduleStatsData, healthData, analyticsData] = await Promise.allSettled([
fetchScheduleStats(props.healthUrl),
fetchHealth(props.healthUrl),
fetchAnalytics(props.healthUrl),
]);

if (scheduleStatsData.status === 'fulfilled') {
setScheduleStats(scheduleStatsData.value);
}
if (healthData.status === 'fulfilled') {
setHealth(healthData.value);
}
if (analyticsData.status === 'fulfilled') {
setAnalytics(analyticsData.value);
}

const allRejected =
scheduleStatsData.status === 'rejected' &&
healthData.status === 'rejected' &&
analyticsData.status === 'rejected';

if (allRejected) {
const errors = [scheduleStatsData, healthData, analyticsData].map(
(p) => (p as PromiseRejectedResult).reason
);
setError(errors.map((e) => (e instanceof Error ? e.message : String(e))).join(', '));
}

setLastUpdated(Date.now());
} catch (err) {
if ((err as any)?.name === 'AbortError') return;

Check failure on line 123 in dashboard/src/components/NotificationHealthPanel.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

Unexpected any. Specify a different type
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsRefreshing(false);
}
}, [props.healthUrl]);

useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;

const schedule = (ms: number) => {
if (cancelled) return;
timer = setTimeout(async () => {
await refresh();
schedule(effectivePollIntervalMs);
}, ms);
};

void refresh();
schedule(effectivePollIntervalMs);

const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void refresh();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);

return () => {
cancelled = true;
abortRef.current?.abort();
if (timer) clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [effectivePollIntervalMs, refresh]);

const overallStatus = health?.status ?? 'unknown';
const successRate = analytics?.overall.successRate ?? 0;

return (
<section className="notification-health" aria-label="Notification Health">
<div className="notification-health__header">
<div>
<p className="notification-health__eyebrow">Monitor</p>
<h2 className="notification-health__title">Notification Health</h2>
</div>

<div className="notification-health__meta">
<span className={`notification-health__status ${overallStatusClass(overallStatus)}`}>
{overallStatusLabel(overallStatus)}
</span>
<span className="notification-health__updated">
{isRefreshing ? 'Updating…' : `Updated ${formatTimestampShort(lastUpdated)}`}
</span>
</div>
</div>

{error && (
<p className="notification-health__error" role="alert">
{error}
</p>
)}

<div className="notification-health__grid">
<div className="notification-health__card">
<h3 className="notification-health__card-title">Queue Health</h3>
<div className="notification-health__metrics-grid">
{scheduleStats && (
<>
<div className="notification-health__metric">
<dt>Pending</dt>
<dd>{scheduleStats.pending.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Processing</dt>
<dd>{scheduleStats.processing.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Completed</dt>
<dd>{scheduleStats.completed.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Failed</dt>
<dd>{scheduleStats.failed.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Overdue</dt>
<dd>{scheduleStats.overdue.toLocaleString()}</dd>
</div>
</>
)}
</div>
</div>

<div className="notification-health__card">
<h3 className="notification-health__card-title">Delivery Status</h3>
<div className="notification-health__metrics-grid">
{analytics && (
<>
<div className="notification-health__metric">
<dt>Success Rate</dt>
<dd>{(successRate * 100).toFixed(1)}%</dd>
</div>
<div className="notification-health__metric">
<dt>Total Delivered</dt>
<dd>{analytics.overall.total.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Success</dt>
<dd>{analytics.overall.success.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Failure</dt>
<dd>{analytics.overall.failure.toLocaleString()}</dd>
</div>
<div className="notification-health__metric">
<dt>Avg Duration</dt>
<dd>{formatDuration(analytics.overall.averageDurationMs)}</dd>
</div>
</>
)}
</div>
</div>

<div className="notification-health__card notification-health__card--full">
<h3 className="notification-health__card-title">Service Indicators</h3>
<div className="notification-health__services-grid">
{health && (
<>
<div className={`notification-health__service ${serviceStatusClass(health.services.stellarRpc.status)}`}>
<div className="notification-health__service-name">Stellar RPC</div>
<div className="notification-health__service-status">
{serviceStatusLabel(health.services.stellarRpc.status)}
</div>
{health.services.stellarRpc.latencyMs && (
<div className="notification-health__service-latency">
{health.services.stellarRpc.latencyMs}ms
</div>
)}
{health.services.stellarRpc.detail && (
<div className="notification-health__service-detail">
{health.services.stellarRpc.detail}
</div>
)}
</div>
<div className={`notification-health__service ${serviceStatusClass(health.services.discord.status)}`}>
<div className="notification-health__service-name">Discord</div>
<div className="notification-health__service-status">
{serviceStatusLabel(health.services.discord.status)}
</div>
{health.services.discord.latencyMs && (
<div className="notification-health__service-latency">
{health.services.discord.latencyMs}ms
</div>
)}
{health.services.discord.detail && (
<div className="notification-health__service-detail">
{health.services.discord.detail}
</div>
)}
</div>
<div className={`notification-health__service ${serviceStatusClass(health.services.eventRegistry.status)}`}>
<div className="notification-health__service-name">Event Registry</div>
<div className="notification-health__service-status">
{serviceStatusLabel(health.services.eventRegistry.status)}
</div>
<div className="notification-health__service-detail">
{health.services.eventRegistry.eventCount.toLocaleString()} events
</div>
</div>
</>
)}
</div>
</div>
</div>
</section>
);
}
5 changes: 5 additions & 0 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { EventExplorerTable } from '../components/EventExplorerTable';
import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton';
import { PaginationControls } from '../components/PaginationControls';
import { IndexingHealthPanel } from '../components/IndexingHealthPanel';
import { NotificationHealthPanel } from '../components/NotificationHealthPanel';
import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi';
import { resolveIndexingHealthUrl } from '../services/indexingHealthApi';
import { resolveNotificationHealthUrl } from '../services/notificationHealthApi';
import { generateMockEvents } from '../utils/eventData';
import { restoreWalletSession } from '../services/wallet';
import { useWalletAccountSync } from '../hooks/useWalletAccountSync';
Expand All @@ -20,6 +22,8 @@ const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/ap
const LISTENER_BASE_URL = API_URL.replace('/api/events', '');
const INDEXING_HEALTH_URL =
import.meta.env.VITE_INDEXING_HEALTH_URL ?? resolveIndexingHealthUrl(API_URL);
const NOTIFICATION_HEALTH_URL =
import.meta.env.VITE_NOTIFICATION_HEALTH_URL ?? resolveNotificationHealthUrl(API_URL);

function parsePageParam(search: string) {
const params = new URLSearchParams(search);
Expand Down Expand Up @@ -199,6 +203,7 @@ export function EventExplorerPage() {
</section>
)}
<IndexingHealthPanel healthUrl={INDEXING_HEALTH_URL} />
<NotificationHealthPanel healthUrl={NOTIFICATION_HEALTH_URL} />

<EventFiltersBar />
<NotificationSearchBar />
Expand Down
40 changes: 40 additions & 0 deletions dashboard/src/services/notificationHealthApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {
ScheduleStatsResponse,
HealthResponse,
NotificationAnalyticsSnapshot,
} from '../types/notificationHealth';

export async function fetchScheduleStats(apiUrl: string): Promise<ScheduleStatsResponse> {
const response = await fetch(`${apiUrl}/api/schedule/stats`);
if (!response.ok) {
throw new Error(`Failed to fetch schedule stats: ${response.status}`);
}
return response.json() as Promise<ScheduleStatsResponse>;
}

export async function fetchHealth(apiUrl: string): Promise<HealthResponse> {
const response = await fetch(`${apiUrl}/health`);
if (!response.ok) {
throw new Error(`Failed to fetch health: ${response.status}`);
}
return response.json() as Promise<HealthResponse>;
}

export async function fetchAnalytics(apiUrl: string): Promise<NotificationAnalyticsSnapshot> {
const response = await fetch(`${apiUrl}/api/analytics`);
if (!response.ok) {
throw new Error(`Failed to fetch analytics: ${response.status}`);
}
return response.json() as Promise<NotificationAnalyticsSnapshot>;
}

export function resolveNotificationHealthUrl(eventsApiUrl: string): string {
try {
const url = new URL(eventsApiUrl);
url.pathname = '';
url.search = '';
return url.toString();
} catch {
return 'http://localhost:8787';
}
}
Loading
Loading