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
20 changes: 20 additions & 0 deletions dashboard/src/components/EventExplorerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BlockchainEvent } from '../types/event';
import type { ContractStatus } from '../services/eventsApi';
import { formatTimestamp } from '../utils/formatTime';

const EVENT_KIND_STYLES: Record<string, string> = {
Expand Down Expand Up @@ -33,6 +34,12 @@
event: BlockchainEvent;
onCopyContract: (contractAddress: string) => void;
isCopied: boolean;
contractStatuses: ContractStatus[];
}

export function EventExplorerCard({ event, onCopyContract, isCopied, contractStatuses }: EventExplorerCardProps) {

Check failure on line 40 in dashboard/src/components/EventExplorerCard.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

'isCopied' is defined but never used

Check failure on line 40 in dashboard/src/components/EventExplorerCard.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

'onCopyContract' is defined but never used
const contractStatus = contractStatuses.find(c => c.address === event.contractAddress);
const isPaused = contractStatus?.paused ?? false;

Check failure on line 42 in dashboard/src/components/EventExplorerCard.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

'isPaused' is assigned a value but never used
}

export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExplorerCardProps) {
Expand All @@ -47,6 +54,19 @@
<p className="event-explorer__contract" title={event.contractAddress}>
{shortenAddress(event.contractAddress)}
</p>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="event-explorer__copy-button"
onClick={() => onCopyContract(event.contractAddress)}
aria-label={`Copy contract address ${event.contractAddress}`}
>
{isCopied ? 'Copied' : 'Copy'}
</button>
{isPaused && (
<span className="event-explorer__badge event-explorer__badge--paused">Paused</span>
)}
</div>
<button
type="button"
className="event-explorer__copy-button"
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/components/EventExplorerTable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { useState } from 'react';
import type { BlockchainEvent } from '../types/event';
import type { ContractStatus } from '../services/eventsApi';
import { EventExplorerCard } from './EventExplorerCard';

interface EventExplorerTableProps {
events: BlockchainEvent[];
contractStatuses: ContractStatus[];
}

export function EventExplorerTable({ events, contractStatuses }: EventExplorerTableProps) {

Check failure on line 11 in dashboard/src/components/EventExplorerTable.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

'contractStatuses' is defined but never used

Check failure on line 11 in dashboard/src/components/EventExplorerTable.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

'events' is defined but never used
}

export function EventExplorerTable({ events }: EventExplorerTableProps) {
Expand Down Expand Up @@ -58,6 +63,7 @@
event={event}
onCopyContract={handleCopyContract}
isCopied={copiedAddress === event.contractAddress}
contractStatuses={contractStatuses}
/>
))}
</div>
Expand Down
70 changes: 70 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,11 @@ body {
background: rgba(167, 139, 250, 0.14);
}

.event-explorer__badge--paused {
color: #f4b400;
background: rgba(244, 180, 0, 0.14);
}

.event-explorer__badge--default {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.08);
Expand Down Expand Up @@ -646,6 +651,71 @@ body {
font-size: 1.4rem;
}

/* ─── Contract Status ────────────────────────────────────────── */
.contract-statuses {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.02);
padding: 16px;
}

.contract-statuses__title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
color: #cbd5e1;
}

.contract-statuses__list {
display: flex;
flex-direction: column;
gap: 10px;
}

.contract-status-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
}

.contract-status-card__address {
font-family: 'Courier New', Courier, monospace;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}

.contract-status-card__badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}

.contract-status-card__badge--active {
color: #34d399;
background: rgba(52, 211, 153, 0.15);
}

.contract-status-card__badge--paused {
color: #f4b400;
background: rgba(244, 180, 0, 0.15);
}

.contract-status-card__error {
font-size: 0.78rem;
color: #f87171;
}

.pagination-controls {
display: flex;
justify-content: space-between;
Expand Down
38 changes: 38 additions & 0 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton';
import { PaginationControls } from '../components/PaginationControls';
import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi';
import { fetchEvents } from '../services/eventsApi';
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 LISTENER_BASE_URL = API_URL.replace('/api/events', '');

function parsePageParam(search: string) {
const params = new URLSearchParams(search);
Expand All @@ -30,6 +32,7 @@ export function EventExplorerPage() {
const initialSearch = typeof window !== 'undefined' ? window.location.search : '';
const [page, setPage] = useState(() => parsePageParam(initialSearch));
const [limit, setLimit] = useState(() => parseLimitParam(initialSearch));
const [contractStatuses, setContractStatuses] = useState<ContractStatus[]>([]);

const setEvents = useEventStore((state) => state.setEvents);
const setLoading = useEventStore((state) => state.setLoading);
Expand Down Expand Up @@ -66,6 +69,19 @@ export function EventExplorerPage() {
}
}

async function loadStatus() {
try {
const status = await fetchStatus(LISTENER_BASE_URL);
if (!cancelled) {
setContractStatuses(status.contracts);
}
} catch {
// Ignore status fetch errors, just don't show status
}
}

loadEvents();
loadStatus();
loadEvents();

return () => {
Expand Down Expand Up @@ -137,6 +153,27 @@ export function EventExplorerPage() {
<WalletConnectButton />
</header>

{contractStatuses.length > 0 && (
<section className="contract-statuses">
<h2 className="contract-statuses__title">Contract Status</h2>
<div className="contract-statuses__list">
{contractStatuses.map((contract) => (
<div key={contract.address} className="contract-status-card">
<div className="contract-status-card__address">{contract.address}</div>
<div className={`contract-status-card__badge ${contract.paused ? 'contract-status-card__badge--paused' : 'contract-status-card__badge--active'}`}>
{contract.paused ? 'PAUSED' : 'ACTIVE'}
</div>
{contract.error && (
<div className="contract-status-card__error">
Error: {contract.error}
</div>
)}
</div>
))}
</div>
</section>
)}

<EventFiltersBar />

{error && (
Expand All @@ -161,6 +198,7 @@ export function EventExplorerPage() {
{isLoading ? (
<EventExplorerSkeleton rows={Math.min(limit, 8)} />
) : currentPageEvents.length > 0 ? (
<EventExplorerTable events={currentPageEvents} contractStatuses={contractStatuses} />
<EventExplorerTable events={currentPageEvents} />
) : (
<section className="event-explorer__empty-state" role="status" aria-live="polite">
Expand Down
19 changes: 19 additions & 0 deletions dashboard/src/services/eventsApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import type { BlockchainEvent } from '../types/event';

export interface ContractStatus {
address: string;
paused: boolean;
error?: string;
}

export interface StatusResponse {
timestamp: string;
contracts: ContractStatus[];
}

export async function fetchEvents(apiUrl: string): Promise<BlockchainEvent[]> {
const response = await fetch(apiUrl);
if (!response.ok) {
Expand All @@ -9,3 +20,11 @@ export async function fetchEvents(apiUrl: string): Promise<BlockchainEvent[]> {
const payload = (await response.json()) as { events?: BlockchainEvent[] };
return payload.events ?? [];
}

export async function fetchStatus(apiUrl: string): Promise<StatusResponse> {
const response = await fetch(`${apiUrl}/api/status`);
if (!response.ok) {
throw new Error(`Failed to fetch status: ${response.status}`);
}
return response.json() as Promise<StatusResponse>;
}
1 change: 1 addition & 0 deletions listener/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ LOG_LEVEL=info
# Stellar Network Configuration
STELLAR_NETWORK=testnet
STELLAR_RPC_URL=https://soroban-testnet.stellar.org:443
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015

# Contract Addresses to Monitor (JSON array)
CONTRACT_ADDRESSES=[{"address":"CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","events":["*"]}]
Expand Down
41 changes: 41 additions & 0 deletions listener/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,47 @@ A service entry's `status` field can be `"ok"`, `"error"`, or `"not_configured"`

---

## Contract Status

### GET /api/status

Returns the pause status of all configured smart contracts.

**Response `200`**

```json
{
"timestamp": "2024-06-20T14:00:00.000Z",
"contracts": [
{
"address": "CCEMX6...",
"paused": false
},
{
"address": "CCEMX7...",
"paused": true,
"error": "Failed to simulate contract call"
}
]
}
```

| Field | Type | Description |
|-------------|----------|-----------------------------------------------------------------------------|
| timestamp | string | ISO 8601 timestamp of when the status was fetched |
| contracts | array | List of contracts and their statuses |
| address | string | Contract address |
| paused | boolean | Whether the contract is currently paused |
| error | string | Optional. Error message if we could not fetch the status for this contract |

**Response `500`** — internal error fetching status

```json
{ "status": "error", "detail": "Internal status check failure" }
```

---

## Error Codes Reference

Every error response is a JSON object. All errors carry an `error` string. Rate-limit responses also include a `message` field; health-check failures use `detail` instead.
Expand Down
2 changes: 2 additions & 0 deletions listener/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ module.exports = {
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
}
};
17 changes: 17 additions & 0 deletions listener/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

beforeEach(() => {
jest.useFakeTimers({ legacyFakeTimers: false });
jest.clearAllTimers();
});

afterEach(() => {
try {
jest.runOnlyPendingTimers();
jest.runAllTimers();
} finally {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
jest.resetAllMocks();
}
});
33 changes: 33 additions & 0 deletions listener/src/api/events-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,32 @@ import { NotificationAnalyticsAggregator } from '../services/notification-analyt
import { NotificationType } from '../types/scheduled-notification';

const mockGetHealth = jest.fn();
const mockSimulateTransaction = jest.fn();
const mockIsSuccessfulSim = jest.fn();
const mockKeypairRandom = jest.fn(() => ({ publicKey: () => 'GAXXX' }));
const mockContractCall = jest.fn();
const mockTxBuilder = {
addOperation: jest.fn().mockReturnThis(),
setTimeout: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({}),
};
const mockTransactionBuilder = jest.fn(() => mockTxBuilder);

jest.mock('@stellar/stellar-sdk', () => ({
rpc: {
Server: jest.fn().mockImplementation(() => ({
getHealth: mockGetHealth,
simulateTransaction: mockSimulateTransaction,
getAccount: jest.fn().mockRejectedValue(new Error('not found')),
})),
isSuccessfulSim: mockIsSuccessfulSim,
},
Keypair: { random: mockKeypairRandom },
Account: jest.fn(),
Contract: jest.fn(() => ({ call: mockContractCall })),
TransactionBuilder: mockTransactionBuilder,
BASE_FEE: '100',
scValToNative: jest.fn(),
})),
},
}));
Expand Down Expand Up @@ -67,6 +88,12 @@ describe('Preference API endpoints', () => {

beforeEach((done) => {
jest.clearAllMocks();
server = createEventsServer({
port: 0,
stellarRpcUrl: 'http://localhost',
stellarNetworkPassphrase: 'Test SDF Network ; September 2015',
contractAddresses: []
});
server = createEventsServer({ port: 0, stellarRpcUrl: 'http://localhost' });
server.listen(0, '127.0.0.1', done);
});
Expand Down Expand Up @@ -187,6 +214,12 @@ function closeServer(s: http.Server): Promise<void> {
return new Promise((resolve) => s.close(() => resolve()));
}

const BASE_OPTIONS = {
port: 0,
stellarRpcUrl: 'https://test',
stellarNetworkPassphrase: 'Test SDF Network ; September 2015',
contractAddresses: []
};
const BASE_OPTIONS = { port: 0, stellarRpcUrl: 'https://test' };

describe('POST /api/webhooks', () => {
Expand Down
Loading
Loading