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
72 changes: 72 additions & 0 deletions dashboard/src/components/IndexingHealthPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { IndexingHealthPanel } from './IndexingHealthPanel';

function mockFetchOnce(payload: unknown) {
const fetchMock = global.fetch as unknown as jest.Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => payload,
});
}

describe('IndexingHealthPanel', () => {
beforeEach(() => {
global.fetch = jest.fn();
});

afterEach(() => {
(global.fetch as unknown as jest.Mock).mockReset();
});

it('renders the synced state with core metrics', async () => {
mockFetchOnce({
status: 'synced',
timestamp: '2026-01-01T00:00:00.000Z',
indexedLedger: 100,
networkTipLedger: 100,
ledgerLag: 0,
processingDelayMs: 10_000,
lastIngestedAt: '2026-01-01T00:00:00.000Z',
});

render(
<IndexingHealthPanel
healthUrl="http://localhost:8787/api/indexing/health"
pollIntervalMs={60_000}
/>
);

expect(await screen.findByText('Indexing Health')).toBeInTheDocument();
expect(await screen.findByText('Synced')).toBeInTheDocument();
expect(await screen.findByText('100 / 100')).toBeInTheDocument();
expect(await screen.findByText('0 block(s)')).toBeInTheDocument();
expect(await screen.findByText('10s')).toBeInTheDocument();
});

it('renders the degraded state for lagging indexers', async () => {
mockFetchOnce({
status: 'degraded',
timestamp: '2026-01-01T00:00:00.000Z',
indexedLedger: 80,
networkTipLedger: 100,
ledgerLag: 20,
processingDelayMs: 300_000,
detail: 'Behind by 20 ledger(s).',
});

render(
<IndexingHealthPanel
healthUrl="http://localhost:8787/api/indexing/health"
pollIntervalMs={60_000}
/>
);

expect(await screen.findByText('Degraded')).toBeInTheDocument();
expect(await screen.findByText('80 / 100')).toBeInTheDocument();
expect(await screen.findByText('20 block(s)')).toBeInTheDocument();
expect(await screen.findByText('5m 0s')).toBeInTheDocument();
expect(await screen.findByText('Behind by 20 ledger(s).')).toBeInTheDocument();
});
});

154 changes: 154 additions & 0 deletions dashboard/src/components/IndexingHealthPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fetchIndexingHealth } from '../services/indexingHealthApi';
import type { IndexingHealth, IndexingSyncStatus } from '../types/indexingHealth';
import { formatTimestampShort } from '../utils/formatTime';
import { formatDuration } from '../utils/formatDuration';

const DEFAULT_POLL_INTERVAL_MS = 5000;

function statusLabel(status: IndexingSyncStatus): string {
switch (status) {
case 'synced':
return 'Synced';
case 'syncing':
return 'Syncing';
case 'degraded':
default:
return 'Degraded';
}
}

function statusClass(status: IndexingSyncStatus): string {
switch (status) {
case 'synced':
return 'indexing-health__status--synced';
case 'syncing':
return 'indexing-health__status--syncing';
case 'degraded':
default:
return 'indexing-health__status--degraded';
}
}

export function IndexingHealthPanel(props: { healthUrl: string; pollIntervalMs?: number }) {
const pollIntervalMs = props.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const [health, setHealth] = useState<IndexingHealth | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
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);
try {
const next = await fetchIndexingHealth(props.healthUrl, { signal: controller.signal });
setHealth(next);
setError(null);
} catch (err) {
if ((err as any)?.name === 'AbortError') return;

Check failure on line 56 in dashboard/src/components/IndexingHealthPanel.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 status: IndexingSyncStatus = health?.status ?? 'degraded';
const indexedLedger = health?.indexedLedger ?? null;
const tipLedger = health?.networkTipLedger ?? null;

const indexedVsTip =
indexedLedger === null || tipLedger === null
? '—'
: `${indexedLedger.toLocaleString()} / ${tipLedger.toLocaleString()}`;

const ledgerLag =
health?.ledgerLag === null || health?.ledgerLag === undefined
? '—'
: `${health.ledgerLag.toLocaleString()} block(s)`;

const updatedAt = health ? formatTimestampShort(health.timestampMs) : '—';
const processingDelay = health ? formatDuration(health.processingDelayMs) : '—';
const detail = health?.detail ?? null;

return (
<section className="indexing-health" aria-label="Indexing health">
<div className="indexing-health__header">
<div>
<p className="indexing-health__eyebrow">Maintainer</p>
<h2 className="indexing-health__title">Indexing Health</h2>
</div>

<div className="indexing-health__meta">
<span className={`indexing-health__status ${statusClass(status)}`}>
{statusLabel(status)}
</span>
<span className="indexing-health__updated">
{isRefreshing ? 'Updating…' : `Updated ${updatedAt}`}
</span>
</div>
</div>

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

<dl className="indexing-health__grid">
<div className="indexing-health__metric">
<dt>Indexed Blocks</dt>
<dd>{indexedVsTip}</dd>
</div>
<div className="indexing-health__metric">
<dt>Ledger Lag</dt>
<dd>{ledgerLag}</dd>
</div>
<div className="indexing-health__metric">
<dt>Processing Delay</dt>
<dd>{processingDelay}</dd>
</div>
</dl>

{detail && <p className="indexing-health__detail">{detail}</p>}
</section>
);
}

111 changes: 111 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,117 @@ body {
padding: 24px 0 12px;
}

.indexing-health {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 18px 18px 16px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 14px;
}

.indexing-health__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}

.indexing-health__eyebrow {
margin: 0 0 6px;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #a78bfa;
}

.indexing-health__title {
margin: 0;
font-size: 1.05rem;
}

.indexing-health__meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}

.indexing-health__status {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}

.indexing-health__status--synced {
color: #34d399;
background: rgba(52, 211, 153, 0.14);
}

.indexing-health__status--syncing {
color: #f4b400;
background: rgba(244, 180, 0, 0.14);
}

.indexing-health__status--degraded {
color: #f87171;
background: rgba(248, 113, 113, 0.14);
}

.indexing-health__updated {
color: #9aa0a6;
font-size: 0.85rem;
}

.indexing-health__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin: 0;
}

.indexing-health__metric {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 12px 12px 10px;
background: rgba(255, 255, 255, 0.02);
}

.indexing-health__metric dt {
font-size: 0.75rem;
color: #9aa0a6;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}

.indexing-health__metric dd {
margin: 0;
font-size: 1rem;
font-weight: 650;
font-family: 'Courier New', Courier, monospace;
}

.indexing-health__detail {
margin: 0;
color: #9aa0a6;
font-size: 0.9rem;
}

.indexing-health__error {
margin: 0;
color: #f87171;
font-size: 0.9rem;
}

.event-explorer__eyebrow {
margin: 0 0 10px;
font-size: 0.85rem;
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import { WalletConnectButton } from '../components/WalletConnectButton';
import { EventExplorerTable } from '../components/EventExplorerTable';
import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton';
import { PaginationControls } from '../components/PaginationControls';
import { IndexingHealthPanel } from '../components/IndexingHealthPanel';
import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents } from '../services/eventsApi';
import { resolveIndexingHealthUrl } from '../services/indexingHealthApi';
import { generateMockEvents } from '../utils/eventData';
import { restoreWalletSession } from '../services/wallet';

const DEFAULT_EVENT_COUNT = 5000;
const DEFAULT_LIMIT = 12;
const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events';
const INDEXING_HEALTH_URL =
import.meta.env.VITE_INDEXING_HEALTH_URL ?? resolveIndexingHealthUrl(API_URL);

function parsePageParam(search: string) {
const params = new URLSearchParams(search);
Expand Down Expand Up @@ -137,6 +141,8 @@ export function EventExplorerPage() {
<WalletConnectButton />
</header>

<IndexingHealthPanel healthUrl={INDEXING_HEALTH_URL} />

<EventFiltersBar />

{error && (
Expand Down
Loading
Loading