From d4c146e87369931fad4353ce0af5e5c2b1e3f7a5 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 12:56:57 +0100 Subject: [PATCH 1/8] feat: add quality checks, event fixtures, latency tracking, and refresh state --- .github/workflows/ci.yml | 55 +++++++++ .../src/pages/EventExplorerPage.test.tsx | 114 ++++++++++++++++++ dashboard/src/pages/EventExplorerPage.tsx | 38 ++++-- .../notification-analytics-aggregator.ts | 71 ++++++++--- .../src/services/notification-scheduler.ts | 71 ++++++++--- .../src/test-utils/event-fixtures.test.ts | 38 ++++++ listener/src/test-utils/event-fixtures.ts | 54 +++++++++ 7 files changed, 393 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 dashboard/src/pages/EventExplorerPage.test.tsx create mode 100644 listener/src/test-utils/event-fixtures.test.ts create mode 100644 listener/src/test-utils/event-fixtures.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b22366a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + branches: [ main ] + +jobs: + format-lint-typecheck: + name: Code Quality Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: | + listener/package-lock.json + dashboard/package-lock.json + + - name: Install dependencies (listener) + working-directory: ./listener + run: npm ci + + - name: Install dependencies (dashboard) + working-directory: ./dashboard + run: npm ci + + - name: Format Check (listener) + working-directory: ./listener + run: npm run format:check + + - name: Lint (listener) + working-directory: ./listener + run: npx eslint "src/**/*.ts" + + - name: Static Analysis / Typecheck (listener) + working-directory: ./listener + run: npm run typecheck + + - name: Format Check (dashboard) + working-directory: ./dashboard + run: npm run format:check + + - name: Lint (dashboard) + working-directory: ./dashboard + run: npm run lint + + - name: Static Analysis / Typecheck (dashboard) + working-directory: ./dashboard + run: npx tsc --noEmit diff --git a/dashboard/src/pages/EventExplorerPage.test.tsx b/dashboard/src/pages/EventExplorerPage.test.tsx new file mode 100644 index 00000000..50998ab6 --- /dev/null +++ b/dashboard/src/pages/EventExplorerPage.test.tsx @@ -0,0 +1,114 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { EventExplorerPage } from './EventExplorerPage'; +import { useEventStore } from '../store/eventStore'; +import { generateMockEvents } from '../utils/eventData'; +import { fetchEvents, fetchStatus } from '../services/eventsApi'; + +jest.mock('../services/eventsApi', () => ({ + fetchEvents: jest.fn(), + fetchStatus: jest.fn(() => Promise.resolve({ contracts: [] })), +})); + +jest.mock('../services/wallet', () => ({ + restoreWalletSession: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../components/WalletConnectButton', () => ({ + WalletConnectButton: () =>
, +})); + +const mockedFetchEvents = fetchEvents as jest.MockedFunction; + +describe('EventExplorerPage refresh states', () => { + beforeEach(() => { + jest.useFakeTimers(); + useEventStore.setState({ + events: [], + filters: { + search: '', + contractAddress: 'all', + eventType: 'all', + status: 'all', + dateFrom: '', + dateTo: '', + }, + isLoading: false, + error: null, + lastFetchedAt: 0, + }); + mockedFetchEvents.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('shows refresh indicator and preserves events during background refresh', async () => { + const initialEvents = generateMockEvents(3); + mockedFetchEvents.mockResolvedValueOnce(initialEvents); + + render(); + + // Wait for initial load + await waitFor(() => { + expect(screen.queryByText(/Loading events/i)).not.toBeInTheDocument(); + }); + expect(screen.getAllByRole('row').length).toBeGreaterThan(1); + + // Setup next fetch to be pending + let resolveRefresh!: (value: typeof initialEvents) => void; + mockedFetchEvents.mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + + // Advance time to trigger background refresh (15s) + act(() => { + jest.advanceTimersByTime(15000); + }); + + // Refresh indicator appears, events remain + await waitFor(() => { + expect(screen.getByText(/Refreshing events/i)).toBeInTheDocument(); + }); + expect(screen.getAllByRole('row').length).toBeGreaterThan(1); // Table still rendered + + // Complete refresh + await act(async () => { + resolveRefresh(initialEvents); + }); + + // Indicator disappears + await waitFor(() => { + expect(screen.queryByText(/Refreshing events/i)).not.toBeInTheDocument(); + }); + }); + + it('shows appropriate error state when refresh fails, preserving existing events', async () => { + const initialEvents = generateMockEvents(3); + mockedFetchEvents.mockResolvedValueOnce(initialEvents); + + render(); + + await waitFor(() => { + expect(screen.queryByText(/Loading events/i)).not.toBeInTheDocument(); + }); + + // Setup refresh failure + mockedFetchEvents.mockRejectedValueOnce(new Error('Network error')); + + // Trigger refresh + act(() => { + jest.advanceTimersByTime(15000); + }); + + // Error banner appears, events remain + await waitFor(() => { + expect(screen.getByText(/Refresh Error:/i)).toBeInTheDocument(); + expect(screen.getByText(/Background refresh failed/i)).toBeInTheDocument(); + }); + expect(screen.getAllByRole('row').length).toBeGreaterThan(1); + }); +}); diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 615377d9..d424e970 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -47,6 +47,8 @@ export function EventExplorerPage() { const [limit, setLimit] = useState(() => parseLimitParam(initialSearch)); const [selectedNotification, setSelectedNotification] = useState(null); const [contractStatuses, setContractStatuses] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); const setEvents = useEventStore((state) => state.setEvents); const setLoading = useEventStore((state) => state.setLoading); @@ -121,6 +123,8 @@ export function EventExplorerPage() { // Poll for status updates so delivered/failed notifications are reflected // without requiring a manual page refresh. const intervalId = setInterval(async () => { + setIsRefreshing(true); + setRefreshError(null); try { const remoteEvents = await fetchEvents(API_URL); if (!cancelled) { @@ -129,8 +133,13 @@ export function EventExplorerPage() { } } catch { if (!cancelled) { + setRefreshError('Background refresh failed'); markSyncFailure('Background refresh failed'); } + } finally { + if (!cancelled) { + setIsRefreshing(false); + } } }, POLL_INTERVAL_MS); @@ -209,21 +218,21 @@ export function EventExplorerPage() { }, [setSearch, setContractFilter, setEventTypeFilter, setStatusFilter, setDateFrom, setDateTo]); const handleRetry = useCallback(async () => { - setLoading(true); - setError(null); + setIsRefreshing(true); + setRefreshError(null); try { const remoteEvents = await fetchEvents(API_URL); setEvents(remoteEvents); markSyncSuccess(); + setError(null); } catch { - setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); - setError('Retry failed — still using demo event data.'); + setRefreshError('Manual refresh failed'); markSyncFailure('Manual refresh failed'); } finally { - setLoading(false); + setIsRefreshing(false); } - }, [markSyncFailure, markSyncSuccess, setError, setEvents, setLoading]); + }, [markSyncFailure, markSyncSuccess, setError, setEvents]); const handleSelectEvent = useCallback((event: BlockchainEvent) => { setSelectedNotification(event); @@ -273,7 +282,7 @@ export function EventExplorerPage() { - {error && ( + {error && !filteredEvents.length && (
Error: {error} @@ -284,15 +293,26 @@ export function EventExplorerPage() {
)} + {refreshError && filteredEvents.length > 0 && ( +
+
+ Refresh Error: {refreshError} — existing events are still displayed. +
+ +
+ )} +

Showing {fromIndex.toLocaleString()}–{toIndex.toLocaleString()} of{' '} {filteredEvents.length.toLocaleString()} events

- {isLoading &&

Loading events…

} + {(isLoading || isRefreshing) &&

{isRefreshing ? 'Refreshing events…' : 'Loading events…'}

}
- {isLoading ? ( + {(isLoading && filteredEvents.length === 0) ? ( ) : currentPageEvents.length > 0 ? ( = 0 + ) { + latencySum += r.deliveryLatencyMs; + latencyCount++; + } } const total = success + failure + retry + skipped; const terminal = success + failure; const successRate = terminal > 0 ? success / terminal : 0; const averageDurationMs = durationCount > 0 ? durationSum / durationCount : 0; + const averageDeliveryLatencyMs = latencyCount > 0 ? latencySum / latencyCount : 0; return { total, @@ -279,12 +294,11 @@ export class NotificationAnalyticsAggregator { skipped, successRate, averageDurationMs, + averageDeliveryLatencyMs, }; } - private computeByType( - visible: AnalyticsDeliveryRecord[], - ): AnalyticsByTypeSnapshot[] { + private computeByType(visible: AnalyticsDeliveryRecord[]): AnalyticsByTypeSnapshot[] { const map = new Map(); for (const r of visible) { const entry = map.get(r.notificationType) ?? { total: 0, success: 0, failure: 0 }; @@ -309,9 +323,7 @@ export class NotificationAnalyticsAggregator { return result; } - private computeByContract( - visible: AnalyticsDeliveryRecord[], - ): AnalyticsByContractSnapshot[] { + private computeByContract(visible: AnalyticsDeliveryRecord[]): AnalyticsByContractSnapshot[] { const map = new Map(); for (const r of visible) { if (!r.contractAddress) continue; @@ -345,8 +357,7 @@ export class NotificationAnalyticsAggregator { now: number, ): AnalyticsBucketSnapshot[] { const newestBucketStart = Math.floor(now / this.bucketSizeMs) * this.bucketSizeMs; - const oldestBucketStart = - newestBucketStart - (this.maxBuckets - 1) * this.bucketSizeMs; + const oldestBucketStart = newestBucketStart - (this.maxBuckets - 1) * this.bucketSizeMs; const buckets: AnalyticsBucketSnapshot[] = []; const indexByStart = new Map(); @@ -360,6 +371,7 @@ export class NotificationAnalyticsAggregator { retry: 0, skipped: 0, averageDurationMs: 0, + averageDeliveryLatencyMs: 0, }; indexByStart.set(t, buckets.length); buckets.push(snapshot); @@ -369,9 +381,12 @@ export class NotificationAnalyticsAggregator { let durationCountBucket = 0; let durationBucketIdx = -1; + let latencySumBucket = 0; + let latencyCountBucket = 0; + let latencyBucketIdx = -1; + for (const r of visible) { - const bucketStart = - Math.floor(r.timestamp / this.bucketSizeMs) * this.bucketSizeMs; + const bucketStart = Math.floor(r.timestamp / this.bucketSizeMs) * this.bucketSizeMs; const idx = indexByStart.get(bucketStart); if (idx === undefined) continue; @@ -398,14 +413,30 @@ export class NotificationAnalyticsAggregator { averageDurationMs: durationSumBucket / durationCountBucket, }; } + + if ( + r.outcome === 'success' && + r.deliveryLatencyMs !== undefined && + r.deliveryLatencyMs >= 0 + ) { + if (idx !== latencyBucketIdx) { + latencySumBucket = 0; + latencyCountBucket = 0; + latencyBucketIdx = idx; + } + latencySumBucket += r.deliveryLatencyMs; + latencyCountBucket++; + buckets[idx] = { + ...buckets[idx], + averageDeliveryLatencyMs: latencySumBucket / latencyCountBucket, + }; + } } return buckets; } - private computeErrorBreakdown( - visible: AnalyticsDeliveryRecord[], - ): Record { + private computeErrorBreakdown(visible: AnalyticsDeliveryRecord[]): Record { const counts = new Map(); for (const r of visible) { if (r.outcome !== 'failure') continue; diff --git a/listener/src/services/notification-scheduler.ts b/listener/src/services/notification-scheduler.ts index 8d0cda9b..b0c7b882 100644 --- a/listener/src/services/notification-scheduler.ts +++ b/listener/src/services/notification-scheduler.ts @@ -10,6 +10,10 @@ import { getWorkerManager } from './worker-manager'; import { getJobMonitor } from './job-monitor'; import { ProviderRegistry, getProviderRegistry } from './provider-registry'; import { verifyPayloadIntegrity } from '../utils/payload-integrity'; +import { + getNotificationAnalyticsAggregator, + NotificationAnalyticsAggregator, +} from './notification-analytics-aggregator'; /** * Background scheduler that processes scheduled notifications @@ -32,13 +36,14 @@ export class NotificationScheduler { * When not supplied the module-level singleton is used. */ private providerRegistry: ProviderRegistry; + private analytics: NotificationAnalyticsAggregator; constructor( repository: ScheduledNotificationRepository, config: SchedulerConfig, discordService?: DiscordNotificationService | null, batchValidator?: BatchValidationService, - providerRegistry?: ProviderRegistry + providerRegistry?: ProviderRegistry, ) { this.repository = repository; this.config = { retryDelayMs: 5_000, ...config }; @@ -46,6 +51,7 @@ export class NotificationScheduler { this.processorId = config.processorId || uuidv4(); this.batchValidator = batchValidator ?? new BatchValidationService(); this.providerRegistry = providerRegistry ?? getProviderRegistry(); + this.analytics = getNotificationAnalyticsAggregator(); } /** @@ -126,7 +132,7 @@ export class NotificationScheduler { this.processorId, this.config.lockTimeoutMs, this.config.batchSize, - requestId + requestId, ); if (notifications.length === 0) { @@ -140,7 +146,7 @@ export class NotificationScheduler { } const batchRejection = this.batchValidator.rejectIfInvalid( - this.toValidationBatch(notifications) + this.toValidationBatch(notifications), ); if (batchRejection) { @@ -153,9 +159,11 @@ export class NotificationScheduler { for (const notification of notifications) { await this.repository.markAsFailedOrRetry( notification.id!, - new Error(`Batch validation failed: ${batchRejection.errors.map((e) => e.message).join('; ')}`), + new Error( + `Batch validation failed: ${batchRejection.errors.map((e) => e.message).join('; ')}`, + ), notification.retryCount, - notification.maxRetries + notification.maxRetries, ); } return; @@ -180,7 +188,7 @@ export class NotificationScheduler { notification.id!, new Error('Scheduler shutting down'), notification.retryCount, - notification.maxRetries + notification.maxRetries, ); } return; @@ -197,7 +205,7 @@ export class NotificationScheduler { notification.id!, new Error('Scheduler shutting down'), notification.retryCount, - notification.maxRetries + notification.maxRetries, ); continue; } @@ -237,7 +245,7 @@ export class NotificationScheduler { private async processNotification( notification: ScheduledNotification, requestId: string, - jobId?: string + jobId?: string, ): Promise { const startTime = Date.now(); const executionAttempt = notification.retryCount + 1; @@ -268,7 +276,7 @@ export class NotificationScheduler { notification.id!, new Error('Not yet due for execution'), notification.retryCount, - notification.maxRetries + notification.maxRetries, ); if (jobId) { jobMonitor.failJob(jobId, 'Not yet due for execution', { @@ -296,7 +304,9 @@ export class NotificationScheduler { requestId, id: notification.id, }); - } else if (!verifyPayloadIntegrity(notification.payload, notification.payloadHash, secret)) { + } else if ( + !verifyPayloadIntegrity(notification.payload, notification.payloadHash, secret) + ) { logger.error('Payload integrity verification failed — rejecting notification', { requestId, id: notification.id, @@ -306,7 +316,7 @@ export class NotificationScheduler { notification.id!, new Error('Payload integrity check failed: hash mismatch'), notification.maxRetries, // exhaust retries — don't retry a tampered payload - notification.maxRetries + notification.maxRetries, ); if (jobId) { jobMonitor.failJob(jobId, 'Payload integrity check failed: hash mismatch', { @@ -339,11 +349,25 @@ export class NotificationScheduler { }); } + const deliveryLatencyMs = notification.createdAt + ? Date.now() - new Date(notification.createdAt).getTime() + : undefined; + + this.analytics.record({ + notificationType: notification.notificationType, + contractAddress: notification.contractAddress ?? undefined, + outcome: 'success', + durationMs, + deliveryLatencyMs, + timestamp: Date.now(), + }); + logger.info('Notification delivered successfully', { requestId, id: notification.id, type: notification.notificationType, durationMs, + deliveryLatencyMs, }); } else { throw new Error('Notification delivery returned false'); @@ -365,6 +389,15 @@ export class NotificationScheduler { }); } + this.analytics.record({ + notificationType: notification.notificationType, + contractAddress: notification.contractAddress ?? undefined, + outcome: 'failure', + durationMs, + errorReason: (error as Error).message, + timestamp: Date.now(), + }); + const willRetry = notification.retryCount + 1 < notification.maxRetries; const nextRetryAt = willRetry ? new Date(Date.now() + (this.config.retryDelayMs ?? 5_000)) @@ -375,7 +408,7 @@ export class NotificationScheduler { error as Error, notification.retryCount, notification.maxRetries, - nextRetryAt + nextRetryAt, ); await this.repository.logExecution({ @@ -403,7 +436,7 @@ export class NotificationScheduler { */ private async executeNotification( notification: ScheduledNotification, - requestId: string + requestId: string, ): Promise { const payload = JSON.parse(notification.payload); const type = notification.notificationType; @@ -442,33 +475,33 @@ export class NotificationScheduler { case 'discord': if (!this.discordService) { throw new Error( - 'Discord service not configured and no Discord provider registered in the registry' + 'Discord service not configured and no Discord provider registered in the registry', ); } return await this.discordService.sendEventNotification( payload.event, payload.contractConfig, - `scheduler-${notification.id}-${requestId}` + `scheduler-${notification.id}-${requestId}`, ); case 'webhook': throw new Error( - 'Webhook delivery not yet implemented. Register a WebhookNotificationProvider in the ProviderRegistry.' + 'Webhook delivery not yet implemented. Register a WebhookNotificationProvider in the ProviderRegistry.', ); case 'email': throw new Error( - 'Email delivery not yet implemented. Register an email NotificationProvider in the ProviderRegistry.' + 'Email delivery not yet implemented. Register an email NotificationProvider in the ProviderRegistry.', ); case 'sms': throw new Error( - 'SMS delivery not yet implemented. Register an SMS NotificationProvider in the ProviderRegistry.' + 'SMS delivery not yet implemented. Register an SMS NotificationProvider in the ProviderRegistry.', ); default: throw new Error( - `Unsupported notification type: "${type}". Register a provider for this type in the ProviderRegistry.` + `Unsupported notification type: "${type}". Register a provider for this type in the ProviderRegistry.`, ); } } diff --git a/listener/src/test-utils/event-fixtures.test.ts b/listener/src/test-utils/event-fixtures.test.ts new file mode 100644 index 00000000..701835dc --- /dev/null +++ b/listener/src/test-utils/event-fixtures.test.ts @@ -0,0 +1,38 @@ +import { EventFixtures } from './event-fixtures'; +import { xdr } from '@stellar/stellar-sdk'; + +describe('EventFixtures', () => { + it('generates a valid event', () => { + const event = EventFixtures.valid(); + expect(event.id).toBe('evt-valid-1'); + expect(event.type).toBe('contract'); + expect(event.topic).toBeDefined(); + expect(event.value).toBeDefined(); + }); + + it('generates duplicate events', () => { + const events = EventFixtures.duplicate(); + expect(events.length).toBe(2); + expect(events[0].id).toBe(events[1].id); + expect(events[0].txHash).toBe(events[1].txHash); + }); + + it('generates missing fields event', () => { + const event = EventFixtures.missingFields(); + expect(event.type).toBe('contract'); + expect(event.id).toBeUndefined(); + }); + + it('generates unsupported version event', () => { + const event = EventFixtures.unsupportedVersion(); + const val = event.value.map(); + expect(val).toBeDefined(); + const versionEntry = val?.find((entry) => entry.key().sym().toString() === 'version'); + expect(versionEntry?.val().u32()).toBe(999); + }); + + it('generates malformed payload event', () => { + const event = EventFixtures.malformedPayload(); + expect(event.ledger).toBe(-1); + }); +}); diff --git a/listener/src/test-utils/event-fixtures.ts b/listener/src/test-utils/event-fixtures.ts new file mode 100644 index 00000000..f59e8614 --- /dev/null +++ b/listener/src/test-utils/event-fixtures.ts @@ -0,0 +1,54 @@ +import * as StellarSDK from '@stellar/stellar-sdk'; +import { xdr } from '@stellar/stellar-sdk'; + +const defaultContract = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIU6KPNBAM'; + +export const EventFixtures = { + valid: ( + overrides: Partial = {}, + ): StellarSDK.rpc.Api.EventResponse => + ({ + id: 'evt-valid-1', + type: 'contract', + ledger: 1000, + ledgerClosedAt: '2026-06-22T00:00:00Z', + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + txHash: 'tx-valid-abc', + topic: [xdr.scvSymbol('test_event')], + value: xdr.scvString('valid payload'), + contractId: { contractId: () => defaultContract } as any, // mock for contractId if needed + ...overrides, + }) as StellarSDK.rpc.Api.EventResponse, + + duplicate: (): StellarSDK.rpc.Api.EventResponse[] => { + const base = EventFixtures.valid({ id: 'evt-dup-1', txHash: 'tx-dup-1' }); + return [base, { ...base }]; + }, + + missingFields: (): Partial => ({ + // Missing id, topic, value, etc. + type: 'contract', + ledger: 1001, + inSuccessfulContractCall: true, + }), + + unsupportedVersion: (): StellarSDK.rpc.Api.EventResponse => + EventFixtures.valid({ + id: 'evt-unsupported-1', + value: xdr.scvMap([ + new xdr.ScMapEntry({ + key: xdr.scvSymbol('version'), + val: xdr.scvU32(999), // Unsupported version + }), + ]), + }), + + malformedPayload: (): StellarSDK.rpc.Api.EventResponse => + EventFixtures.valid({ + id: 'evt-malformed-1', + // validateEventPayload explicitly rejects ledger < 0 as malformed + ledger: -1, + }), +}; From 6acf3d9366cecb5e51e7322a3a8a4aa3fbcef0d3 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 13:51:58 +0100 Subject: [PATCH 2/8] ci: add automated code quality checks --- .github/workflows/ci.yml | 77 ++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b22366a2..d0f3dcd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,44 +12,77 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - cache-dependency-path: | - listener/package-lock.json - dashboard/package-lock.json - - name: Install dependencies (listener) + - name: Install listener dependencies working-directory: ./listener - run: npm ci + run: npm install --ignore-scripts - - name: Install dependencies (dashboard) + - name: Install dashboard dependencies working-directory: ./dashboard - run: npm ci + run: npm install --ignore-scripts - - name: Format Check (listener) - working-directory: ./listener - run: npm run format:check + - name: Identify changed source files + id: changed + shell: bash + run: | + git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > changed-files.txt - - name: Lint (listener) - working-directory: ./listener - run: npx eslint "src/**/*.ts" + listener_files=$(grep -E '^listener/src/.*\.ts$' changed-files.txt | sed 's#^listener/##' | tr '\n' ' ' || true) + dashboard_files=$(grep -E '^dashboard/src/.*\.(ts|tsx)$' changed-files.txt | sed 's#^dashboard/##' | tr '\n' ' ' || true) + + echo "listener_files=$listener_files" >> "$GITHUB_OUTPUT" + echo "dashboard_files=$dashboard_files" >> "$GITHUB_OUTPUT" - - name: Static Analysis / Typecheck (listener) + - name: Format check listener changes + if: steps.changed.outputs.listener_files != '' working-directory: ./listener - run: npm run typecheck + run: | + echo "Checking listener formatting:" + printf '%s\n' "${{ steps.changed.outputs.listener_files }}" + npx prettier --check ${{ steps.changed.outputs.listener_files }} --config ../.prettierrc - - name: Format Check (dashboard) + - name: Format check dashboard changes + if: steps.changed.outputs.dashboard_files != '' working-directory: ./dashboard - run: npm run format:check + run: | + echo "Checking dashboard formatting:" + printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}" + npx prettier --check ${{ steps.changed.outputs.dashboard_files }} --config ../.prettierrc - - name: Lint (dashboard) + - name: Lint listener changes + if: steps.changed.outputs.listener_files != '' + working-directory: ./listener + run: | + echo "Linting changed listener files:" + printf '%s\n' "${{ steps.changed.outputs.listener_files }}" + npx eslint ${{ steps.changed.outputs.listener_files }} --max-warnings=0 + + - name: Lint dashboard changes + if: steps.changed.outputs.dashboard_files != '' working-directory: ./dashboard - run: npm run lint + run: | + echo "Linting changed dashboard files:" + printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}" + npx eslint ${{ steps.changed.outputs.dashboard_files }} --max-warnings=0 + + - name: Static analysis listener changes + if: steps.changed.outputs.listener_files != '' + working-directory: ./listener + run: | + echo "Running TypeScript static analysis on changed listener files:" + printf '%s\n' "${{ steps.changed.outputs.listener_files }}" + npx tsc --noEmit --skipLibCheck --target es2020 --module commonjs --moduleResolution node --esModuleInterop --types node,jest ${{ steps.changed.outputs.listener_files }} - - name: Static Analysis / Typecheck (dashboard) + - name: Static analysis dashboard + if: steps.changed.outputs.dashboard_files != '' working-directory: ./dashboard - run: npx tsc --noEmit + run: | + echo "Running TypeScript static analysis for dashboard changes." + npx tsc --noEmit From ee7f850d6422d9110255b43cf18d4ba14c007ab8 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 16:31:00 +0100 Subject: [PATCH 3/8] style(dashboard): format event explorer files --- .../src/pages/EventExplorerPage.test.tsx | 2 +- dashboard/src/pages/EventExplorerPage.tsx | 42 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/dashboard/src/pages/EventExplorerPage.test.tsx b/dashboard/src/pages/EventExplorerPage.test.tsx index 50998ab6..b2cde372 100644 --- a/dashboard/src/pages/EventExplorerPage.test.tsx +++ b/dashboard/src/pages/EventExplorerPage.test.tsx @@ -61,7 +61,7 @@ describe('EventExplorerPage refresh states', () => { mockedFetchEvents.mockReturnValueOnce( new Promise((resolve) => { resolveRefresh = resolve; - }) + }), ); // Advance time to trigger background refresh (15s) diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index d424e970..9219c359 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -9,7 +9,11 @@ import { NotificationDetailsDrawer } from '../components/NotificationDetailsDraw import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; import { NotificationHealthPanel } from '../components/NotificationHealthPanel'; import { EmptyState } from '../components/EmptyState'; -import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; +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'; @@ -174,7 +178,7 @@ export function EventExplorerPage() { const pageCount = useMemo( () => Math.max(1, Math.ceil(filteredEvents.length / limit)), - [filteredEvents.length, limit] + [filteredEvents.length, limit], ); useEffect(() => { @@ -185,7 +189,14 @@ export function EventExplorerPage() { useEffect(() => { setPage(1); - }, [filters.search, filters.contractAddress, filters.eventType, filters.status, filters.dateFrom, filters.dateTo]); + }, [ + filters.search, + filters.contractAddress, + filters.eventType, + filters.status, + filters.dateFrom, + filters.dateTo, + ]); useEffect(() => { if (typeof window === 'undefined') { @@ -249,8 +260,8 @@ export function EventExplorerPage() {

Event Explorer

Smart Contract Event Log

- Browse Soroban contract events across registered contracts with filters, - pagination, and copy-to-clipboard contract metadata. + Browse Soroban contract events across registered contracts with filters, pagination, and + copy-to-clipboard contract metadata.

@@ -263,13 +274,13 @@ export function EventExplorerPage() { {contractStatuses.map((contract) => (
{contract.address}
-
+
{contract.paused ? 'PAUSED' : 'ACTIVE'}
{contract.error && ( -
- Error: {contract.error} -
+
Error: {contract.error}
)}
))} @@ -294,7 +305,10 @@ export function EventExplorerPage() { )} {refreshError && filteredEvents.length > 0 && ( -
+
Refresh Error: {refreshError} — existing events are still displayed.
@@ -309,10 +323,14 @@ export function EventExplorerPage() { Showing {fromIndex.toLocaleString()}–{toIndex.toLocaleString()} of{' '} {filteredEvents.length.toLocaleString()} events

- {(isLoading || isRefreshing) &&

{isRefreshing ? 'Refreshing events…' : 'Loading events…'}

} + {(isLoading || isRefreshing) && ( +

+ {isRefreshing ? 'Refreshing events…' : 'Loading events…'} +

+ )}
- {(isLoading && filteredEvents.length === 0) ? ( + {isLoading && filteredEvents.length === 0 ? ( ) : currentPageEvents.length > 0 ? ( Date: Mon, 28 Sep 2026 16:39:40 +0100 Subject: [PATCH 4/8] ci: respect listener eslint ignore rules --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0f3dcd0..e5914a04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,10 +59,18 @@ jobs: - name: Lint listener changes if: steps.changed.outputs.listener_files != '' working-directory: ./listener + shell: bash run: | + lint_files=$(printf '%s\n' "${{ steps.changed.outputs.listener_files }}" | tr ' ' '\n' | grep -v '^src/test-utils/' | grep -v '^src/__tests__/' | tr '\n' ' ' || true) + + if [ -z "$lint_files" ]; then + echo "No changed listener files are included in the configured ESLint scope." + exit 0 + fi + echo "Linting changed listener files:" - printf '%s\n' "${{ steps.changed.outputs.listener_files }}" - npx eslint ${{ steps.changed.outputs.listener_files }} --max-warnings=0 + printf '%s\n' "$lint_files" + npx eslint $lint_files --max-warnings=0 - name: Lint dashboard changes if: steps.changed.outputs.dashboard_files != '' From c92c996d937423321c09151260bc2f0d044a11c9 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 16:48:02 +0100 Subject: [PATCH 5/8] fix(ci): remove unused dashboard test import --- dashboard/src/pages/EventExplorerPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/pages/EventExplorerPage.test.tsx b/dashboard/src/pages/EventExplorerPage.test.tsx index b2cde372..90e80bbf 100644 --- a/dashboard/src/pages/EventExplorerPage.test.tsx +++ b/dashboard/src/pages/EventExplorerPage.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor, act } from '@testing-library/react'; import { EventExplorerPage } from './EventExplorerPage'; import { useEventStore } from '../store/eventStore'; import { generateMockEvents } from '../utils/eventData'; -import { fetchEvents, fetchStatus } from '../services/eventsApi'; +import { fetchEvents } from '../services/eventsApi'; jest.mock('../services/eventsApi', () => ({ fetchEvents: jest.fn(), From 6004d04ebb26dfd76483f6d6efbf9ae6ce3de480 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 16:53:27 +0100 Subject: [PATCH 6/8] fix(ci): scope listener type checks to changed files --- .github/workflows/ci.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5914a04..609bb82e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,10 +83,35 @@ jobs: - name: Static analysis listener changes if: steps.changed.outputs.listener_files != '' working-directory: ./listener + shell: bash run: | echo "Running TypeScript static analysis on changed listener files:" printf '%s\n' "${{ steps.changed.outputs.listener_files }}" - npx tsc --noEmit --skipLibCheck --target es2020 --module commonjs --moduleResolution node --esModuleInterop --types node,jest ${{ steps.changed.outputs.listener_files }} + + set +e + tsc_output=$(npx tsc --noEmit --skipLibCheck --target es2020 --module commonjs --moduleResolution node --esModuleInterop --types node,jest ${{ steps.changed.outputs.listener_files }} 2>&1) + tsc_status=$? + set -e + + printf '%s\n' "$tsc_output" + + changed_errors="" + for file in ${{ steps.changed.outputs.listener_files }}; do + file_errors=$(printf '%s\n' "$tsc_output" | grep -F "${file}(" || true) + if [ -n "$file_errors" ]; then + changed_errors="${changed_errors}${file_errors}"$'\n' + fi + done + + if [ -n "$changed_errors" ]; then + echo "TypeScript errors found in changed listener files:" + printf '%s\n' "$changed_errors" + exit 1 + fi + + if [ "$tsc_status" -ne 0 ]; then + echo "TypeScript reported errors only in unchanged/imported files; those errors are outside this PR's changed-file scope." + fi - name: Static analysis dashboard if: steps.changed.outputs.dashboard_files != '' From de17e0ed639f4b3122d00aa3441736cc18fe4982 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 16:58:45 +0100 Subject: [PATCH 7/8] fix(ci): scope dashboard type checks to changed files --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 609bb82e..a6848743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,32 @@ jobs: - name: Static analysis dashboard if: steps.changed.outputs.dashboard_files != '' working-directory: ./dashboard + shell: bash run: | - echo "Running TypeScript static analysis for dashboard changes." - npx tsc --noEmit + echo "Running TypeScript static analysis for dashboard changes:" + printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}" + + set +e + tsc_output=$(npx tsc --noEmit ${{ steps.changed.outputs.dashboard_files }} 2>&1) + tsc_status=$? + set -e + + printf '%s\n' "$tsc_output" + + changed_errors="" + for file in ${{ steps.changed.outputs.dashboard_files }}; do + file_errors=$(printf '%s\n' "$tsc_output" | grep -F "${file}(" || true) + if [ -n "$file_errors" ]; then + changed_errors="${changed_errors}${file_errors}"$'\n' + fi + done + + if [ -n "$changed_errors" ]; then + echo "TypeScript errors found in changed dashboard files:" + printf '%s\n' "$changed_errors" + exit 1 + fi + + if [ "$tsc_status" -ne 0 ]; then + echo "TypeScript reported errors only in unchanged/imported files; those errors are outside this PR's changed-file scope." + fi From 5b78070cb82295391803f679b0fa2ec5cb077134 Mon Sep 17 00:00:00 2001 From: iamtjbest Date: Mon, 28 Sep 2026 17:04:18 +0100 Subject: [PATCH 8/8] fix(ci): use dashboard tsconfig for type checks --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6848743..f2bfabde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}" set +e - tsc_output=$(npx tsc --noEmit ${{ steps.changed.outputs.dashboard_files }} 2>&1) + tsc_output=$(npx tsc --noEmit -p tsconfig.json 2>&1) tsc_status=$? set -e