Skip to content
Open
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
4 changes: 1 addition & 3 deletions dashboard/jest.setup.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
require('@testing-library/jest-dom');

const { jest } = require('@types/jest');

const originalFetch = global.fetch;
const originalSetTimeout = global.setTimeout;
const originalSetInterval = global.setInterval;
Expand Down Expand Up @@ -136,7 +134,7 @@ function failureInjectionFetch(url, init) {
return originalFetch(url, init);
}

fobject.defineProperty(HTMLElement.prototype, 'clientHeight', {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return 600;
Expand Down
6 changes: 5 additions & 1 deletion dashboard/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ function applyTheme(theme: Theme): void {
* and applies a `data-theme` attribute to `<html>` for CSS consumption.
*/
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
const [theme, setThemeState] = useState<Theme>(() => {
const initial = getInitialTheme();
if (typeof document !== 'undefined') applyTheme(initial);
return initial;
});

useEffect(() => {
applyTheme(theme);
Expand Down
28 changes: 15 additions & 13 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
font-weight: 400;
color: #e8eaed;
background: #0b0d12;
--text-muted: #9aa0a6;
--text-accent: #fb923c;
--text-danger: #f28b82;

/* ── Heatmap design tokens (dark) ── */
--hm-bg: rgba(255, 255, 255, 0.02);
Expand All @@ -17,7 +20,7 @@
--hm-cell-hover-ring: rgba(255, 255, 255, 0.25);
--hm-text-primary: #e8eaed;
--hm-text-secondary: #9aa0a6;
--hm-text-tertiary: #6b7280;
--hm-text-tertiary: #7d8694;
--hm-tooltip-bg: #1e2128;
--hm-tooltip-border: rgba(255, 255, 255, 0.14);
--hm-input-bg: #12151c;
Expand Down Expand Up @@ -58,6 +61,9 @@
color-scheme: light;
color: #1a1a2e;
background: #f5f5f9;
--text-muted: #4b5563;
--text-accent: #9a3412;
--text-danger: #b91c1c;

--hm-bg: rgba(0, 0, 0, 0.02);
--hm-border: rgba(0, 0, 0, 0.1);
Expand All @@ -69,7 +75,7 @@
--hm-cell-hover-ring: rgba(0, 0, 0, 0.2);
--hm-text-primary: #1a1a2e;
--hm-text-secondary: #4b5563;
--hm-text-tertiary: #9ca3af;
--hm-text-tertiary: #5b6570;
--hm-tooltip-bg: #ffffff;
--hm-tooltip-border: rgba(0, 0, 0, 0.12);
--hm-input-bg: #ffffff;
Expand Down Expand Up @@ -123,10 +129,6 @@
color: var(--hm-accent);
}

[data-theme="light"] .event-explorer__lead {
color: #4b5563;
}

[data-theme="light"] .event-explorer__table-wrapper {
border-color: rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.7);
Expand Down Expand Up @@ -2906,7 +2908,7 @@ body {
.event-explorer__lead {
margin: 0;
max-width: 700px;
color: #cbd5e1;
color: var(--text-muted);
line-height: 1.75;
}

Expand Down Expand Up @@ -4018,7 +4020,7 @@ body.event-explorer--resizing {

.timeline__entry-attempt {
font-size: 0.8rem;
color: #9aa0a6;
color: var(--text-muted);
background: rgba(255,255,255,0.07);
padding: 2px 6px;
border-radius: 4px;
Expand All @@ -4027,13 +4029,13 @@ body.event-explorer--resizing {
.timeline__entry-time {
margin-left: auto;
font-size: 0.8rem;
color: #9aa0a6;
color: var(--text-muted);
}

.timeline__entry-error {
margin: 2px 0 0;
font-size: 0.85rem;
color: #f28b82;
color: var(--text-danger);
}

.timeline__entry-duration {
Expand Down Expand Up @@ -4226,7 +4228,7 @@ body.event-explorer--resizing {

.activity-feed__subtitle {
margin: 0;
color: #9aa0a6;
color: var(--text-muted);
}

.activity-feed__toggle-realtime {
Expand Down Expand Up @@ -4460,7 +4462,7 @@ body.event-explorer--resizing {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #fb923c;
color: var(--text-accent);
}

.webhook-dashboard__title {
Expand All @@ -4471,7 +4473,7 @@ body.event-explorer--resizing {
.webhook-dashboard__lead {
margin: 0;
max-width: 680px;
color: #cbd5e1;
color: var(--text-muted);
line-height: 1.75;
}

Expand Down
64 changes: 48 additions & 16 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isListenerApiTimeoutError,
type ContractStatus,
} from '../services/eventsApi';
import { openEventStream, toEventStreamUrl } from '../services/eventStream';
import { resolveIndexingHealthUrl } from '../services/indexingHealthApi';
import { resolveNotificationHealthUrl } from '../services/notificationHealthApi';
import { generateMockEvents } from '../utils/eventData';
Expand All @@ -34,6 +35,7 @@ const DEFAULT_EVENT_COUNT = 5000;
const DEFAULT_LIMIT = 12;
const EVENT_ID_PARAM = 'eventId';
const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events';
const STREAM_URL = toEventStreamUrl(API_URL);
const POLL_INTERVAL_MS = 15_000;
const LISTENER_BASE_URL = API_URL.replace('/api/events', '');
const INDEXING_HEALTH_URL =
Expand Down Expand Up @@ -72,6 +74,7 @@ export function EventExplorerPage() {

const events = useEventStore((state) => state.events);
const setEvents = useEventStore((state) => state.setEvents);
const appendEvents = useEventStore((state) => state.appendEvents);
const setLoading = useEventStore((state) => state.setLoading);
const setError = useEventStore((state) => state.setError);
const markSyncSuccess = useEventStore((state) => state.markSyncSuccess);
Expand Down Expand Up @@ -141,27 +144,56 @@ export function EventExplorerPage() {
loadEvents();
loadStatus();

// Poll for status updates so delivered/failed notifications are reflected
// without requiring a manual page refresh.
const intervalId = setInterval(async () => {
try {
const remoteEvents = await fetchEvents(API_URL);
if (!cancelled) {
setEvents(remoteEvents);
markSyncSuccess();
}
} catch {
if (!cancelled) {
markSyncFailure('Background refresh failed');
return () => {
cancelled = true;
};
}, [lastFetchedAt, setEvents, setError, setLoading]);

// Live updates stay outside the fetch effect. That effect stores events and
// changes lastFetchedAt, and its cleanup would otherwise close the stream
// (and the poll timer) as soon as the first response arrives.
useEffect(() => {
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | undefined;

const startPolling = () => {
if (intervalId || cancelled) return;
intervalId = setInterval(async () => {
try {
const remoteEvents = await fetchEvents(API_URL);
if (!cancelled) {
setEvents(remoteEvents);
markSyncSuccess();
}
} catch {
if (!cancelled) {
markSyncFailure('Background refresh failed');
}
}
}
}, POLL_INTERVAL_MS);
}, POLL_INTERVAL_MS);
};

const sseDisabled = import.meta.env.VITE_EVENTS_SSE === 'false';
const closeStream =
!sseDisabled && STREAM_URL
? openEventStream(STREAM_URL, {
onEvent: (event) => {
if (cancelled) return;
appendEvents([event]);
markSyncSuccess();
},
onFallback: startPolling,
})
: undefined;

if (!closeStream) startPolling();

return () => {
cancelled = true;
clearInterval(intervalId);
if (intervalId) clearInterval(intervalId);
closeStream?.();
};
}, [lastFetchedAt, setEvents, setError, setLoading]);
}, [appendEvents, setEvents, markSyncSuccess, markSyncFailure]);

// Clear stale events and re-fetch whenever the connected wallet address
// changes (switch or disconnect). This is the fix for issue #175.
Expand Down
90 changes: 90 additions & 0 deletions dashboard/src/services/eventStream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { openEventStream, toEventStreamUrl } from './eventStream';
import type { BlockchainEvent } from '../types/event';

class FakeEventSource {
static latest: FakeEventSource | null = null;
url: string;
onopen: ((ev: Event) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
closed = false;
private listeners = new Map<string, (event: MessageEvent) => void>();

constructor(url: string) {
this.url = url;
FakeEventSource.latest = this;
}

addEventListener(type: string, listener: (event: MessageEvent) => void) {
this.listeners.set(type, listener);
}

emit(type: string, data: string) {
this.listeners.get(type)?.({ data } as MessageEvent);
}

close() {
this.closed = true;
}
}

describe('event stream client', () => {
it('maps the events URL onto the stream endpoint', () => {
expect(toEventStreamUrl('http://localhost:8787/api/events')).toBe(
'http://localhost:8787/api/events/stream',
);
expect(toEventStreamUrl('http://localhost:8787/api/events/history')).toBeNull();
});

it('delivers notification records and closes on cleanup', () => {
const received: BlockchainEvent[] = [];
const stop = openEventStream(
'http://localhost:8787/api/events/stream',
{ onEvent: (event) => received.push(event), onFallback: () => undefined },
FakeEventSource as unknown as new (url: string) => FakeEventSource,
);

FakeEventSource.latest?.onopen?.(new Event('open'));
FakeEventSource.latest?.emit(
'notification',
JSON.stringify({
eventId: 'evt-1',
contractAddress: 'CABC',
eventName: 'notification_scheduled',
ledger: 1,
type: 'contract',
topic: [],
value: '{}',
receivedAt: 1,
}),
);

expect(received).toHaveLength(1);
expect(received[0]?.notificationStatus).toBe('active');

stop();
expect(FakeEventSource.latest?.closed).toBe(true);
});

it('falls back when the first connection fails and does not fall back after a successful open', () => {
const fallback = jest.fn();
openEventStream(
'http://localhost:8787/api/events/stream',
{ onEvent: () => undefined, onFallback: fallback },
FakeEventSource as unknown as new (url: string) => FakeEventSource,
);

FakeEventSource.latest?.onerror?.(new Event('error'));
expect(fallback).toHaveBeenCalledTimes(1);
expect(FakeEventSource.latest?.closed).toBe(true);

fallback.mockClear();
openEventStream(
'http://localhost:8787/api/events/stream',
{ onEvent: () => undefined, onFallback: fallback },
FakeEventSource as unknown as new (url: string) => FakeEventSource,
);
FakeEventSource.latest?.onopen?.(new Event('open'));
FakeEventSource.latest?.onerror?.(new Event('error'));
expect(fallback).not.toHaveBeenCalled();
});
});
Loading
Loading