diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/apps/api/src/analytics/lifecycle/transfers/stellar/dto/lifecycle-analytics.dto.ts b/apps/api/src/analytics/lifecycle/transfers/stellar/dto/lifecycle-analytics.dto.ts new file mode 100644 index 00000000..ccfb818e --- /dev/null +++ b/apps/api/src/analytics/lifecycle/transfers/stellar/dto/lifecycle-analytics.dto.ts @@ -0,0 +1,186 @@ +import { + IsBoolean, + IsDateString, + IsEnum, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + BottleneckInfo, + LifecycleStage, + StageStatistics, + TransferOutcome, +} from '../types/lifecycle.types'; + +// ─── Query DTOs ────────────────────────────────────────────────────────────── + +/** + * Query parameters for lifecycle analytics report endpoint. + */ +export class LifecycleAnalyticsQueryDto { + @ApiPropertyOptional({ description: 'Filter by source chain (e.g. stellar)' }) + @IsOptional() + @IsString() + sourceChain?: string; + + @ApiPropertyOptional({ description: 'Filter by destination chain' }) + @IsOptional() + @IsString() + destinationChain?: string; + + @ApiPropertyOptional({ description: 'Filter by asset symbol (e.g. USDC)' }) + @IsOptional() + @IsString() + asset?: string; + + @ApiPropertyOptional({ description: 'Filter by bridge / provider name' }) + @IsOptional() + @IsString() + bridgeName?: string; + + @ApiPropertyOptional({ description: 'Start date for analysis window (ISO 8601)' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: 'End date for analysis window (ISO 8601)' }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ + description: 'Restrict analysis to failed transfers only', + default: false, + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + failedOnly?: boolean = false; +} + +/** + * Query parameters for recording a single lifecycle event. + */ +export class RecordLifecycleEventDto { + @ApiProperty({ description: 'Unique transfer identifier' }) + @IsUUID() + transferId: string; + + @ApiProperty({ description: 'Lifecycle stage reached', enum: LifecycleStage }) + @IsEnum(LifecycleStage) + stage: LifecycleStage; + + @ApiPropertyOptional({ description: 'Source chain identifier' }) + @IsOptional() + @IsString() + sourceChain?: string; + + @ApiPropertyOptional({ description: 'Destination chain identifier' }) + @IsOptional() + @IsString() + destinationChain?: string; + + @ApiPropertyOptional({ description: 'Asset / token symbol' }) + @IsOptional() + @IsString() + asset?: string; + + @ApiPropertyOptional({ description: 'Bridge or provider name' }) + @IsOptional() + @IsString() + bridgeName?: string; + + @ApiPropertyOptional({ description: 'Event timestamp (ISO 8601). Defaults to now.' }) + @IsOptional() + @IsDateString() + timestamp?: string; + + @ApiPropertyOptional({ description: 'Error message (required when stage = FAILED)' }) + @IsOptional() + @IsString() + errorMessage?: string; + + @ApiPropertyOptional({ description: 'Arbitrary JSON metadata' }) + @IsOptional() + metadata?: Record; +} + +// ─── Response DTOs ──────────────────────────────────────────────────────────── + +/** + * Single lifecycle event as returned by the API. + */ +export class LifecycleEventDto { + @ApiProperty() id: string; + @ApiProperty() transferId: string; + @ApiProperty({ enum: LifecycleStage }) stage: LifecycleStage; + @ApiPropertyOptional() sourceChain: string | null; + @ApiPropertyOptional() destinationChain: string | null; + @ApiPropertyOptional() asset: string | null; + @ApiPropertyOptional() bridgeName: string | null; + @ApiPropertyOptional({ nullable: true }) durationFromPreviousMs: number | null; + @ApiPropertyOptional({ enum: TransferOutcome, nullable: true }) outcome: TransferOutcome | null; + @ApiPropertyOptional({ nullable: true }) errorMessage: string | null; + @ApiPropertyOptional() metadata: Record | null; + @ApiProperty() recordedAt: Date; +} + +/** + * Per-stage statistics DTO. + */ +export class StageStatisticsDto implements StageStatistics { + @ApiProperty({ enum: LifecycleStage }) stage: LifecycleStage; + @ApiProperty() label: string; + @ApiProperty() reachCount: number; + @ApiProperty() failCount: number; + @ApiProperty() stageFailureRate: number; + @ApiProperty() avgDurationMs: number; + @ApiProperty() medianDurationMs: number; + @ApiProperty() p95DurationMs: number; + @ApiProperty() minDurationMs: number; + @ApiProperty() maxDurationMs: number; +} + +/** + * Bottleneck description DTO. + */ +export class BottleneckInfoDto implements BottleneckInfo { + @ApiProperty({ enum: LifecycleStage }) stage: LifecycleStage; + @ApiProperty() label: string; + @ApiProperty() avgDurationMs: number; + @ApiProperty() percentOfTotalTime: number; + @ApiProperty() failCount: number; + @ApiProperty({ enum: ['low', 'medium', 'high', 'critical'] }) + severity: 'low' | 'medium' | 'high' | 'critical'; +} + +/** + * Full lifecycle analytics report DTO. + */ +export class LifecycleAnalyticsReportDto { + @ApiProperty() totalTransfers: number; + @ApiProperty() successfulTransfers: number; + @ApiProperty() failedTransfers: number; + @ApiProperty() timedOutTransfers: number; + @ApiProperty() overallSuccessRate: number; + @ApiProperty() avgTotalDurationMs: number; + @ApiProperty() medianTotalDurationMs: number; + @ApiProperty() p95TotalDurationMs: number; + @ApiProperty({ type: [StageStatisticsDto] }) stageStats: StageStatisticsDto[]; + @ApiProperty({ type: [BottleneckInfoDto] }) bottlenecks: BottleneckInfoDto[]; + @ApiProperty() generatedAt: Date; +} + +/** + * Transfer history for a single transferId. + */ +export class TransferLifecycleHistoryDto { + @ApiProperty() transferId: string; + @ApiProperty({ type: [LifecycleEventDto] }) events: LifecycleEventDto[]; + @ApiPropertyOptional({ enum: TransferOutcome, nullable: true }) + finalOutcome: TransferOutcome | null; + @ApiPropertyOptional({ nullable: true }) totalDurationMs: number | null; +} diff --git a/apps/api/src/analytics/lifecycle/transfers/stellar/entities/soroban-transfer-lifecycle.entity.ts b/apps/api/src/analytics/lifecycle/transfers/stellar/entities/soroban-transfer-lifecycle.entity.ts new file mode 100644 index 00000000..f21a55bf --- /dev/null +++ b/apps/api/src/analytics/lifecycle/transfers/stellar/entities/soroban-transfer-lifecycle.entity.ts @@ -0,0 +1,91 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LifecycleStage, TransferOutcome } from '../types/lifecycle.types'; + +/** + * SorobanTransferLifecycle Entity + * + * Stores one row per lifecycle stage event for each Soroban/Stellar transfer. + * Queries aggregate across all events for a transferId to reconstruct + * the full lifecycle and compute per-stage durations. + * + * Table: soroban_transfer_lifecycle_events + */ +@Entity('soroban_transfer_lifecycle_events') +@Index(['transferId']) +@Index(['stage']) +@Index(['sourceChain', 'destinationChain']) +@Index(['recordedAt']) +@Index(['outcome']) +export class SorobanTransferLifecycleEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** Identifies the transfer this event belongs to */ + @Column({ name: 'transfer_id' }) + @Index() + transferId: string; + + /** The lifecycle stage reached */ + @Column({ + name: 'stage', + type: 'enum', + enum: LifecycleStage, + }) + stage: LifecycleStage; + + /** Source chain identifier (e.g. "stellar", "ethereum") */ + @Column({ name: 'source_chain', nullable: true }) + sourceChain: string | null; + + /** Destination chain identifier */ + @Column({ name: 'destination_chain', nullable: true }) + destinationChain: string | null; + + /** Asset/token symbol */ + @Column({ name: 'asset', nullable: true }) + asset: string | null; + + /** Bridge/provider name */ + @Column({ name: 'bridge_name', nullable: true }) + bridgeName: string | null; + + /** + * Duration in milliseconds from the immediately preceding stage. + * NULL for the INITIATED stage (first event). + */ + @Column({ name: 'duration_from_previous_ms', type: 'bigint', nullable: true }) + durationFromPreviousMs: number | null; + + /** + * Final outcome — populated only on terminal events (SETTLED / FAILED / TIMEOUT). + * Allows fast filtering without aggregating all events. + */ + @Column({ + name: 'outcome', + type: 'enum', + enum: TransferOutcome, + nullable: true, + }) + outcome: TransferOutcome | null; + + /** Error description when stage === FAILED */ + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string | null; + + /** Arbitrary JSON metadata (tx hashes, block numbers, etc.) */ + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata: Record | null; + + /** When this stage event was recorded */ + @Column({ name: 'recorded_at', type: 'timestamptz' }) + recordedAt: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.controller.ts b/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.controller.ts new file mode 100644 index 00000000..1c706325 --- /dev/null +++ b/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.controller.ts @@ -0,0 +1,174 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { SorobanLifecycleService } from './soroban-lifecycle.service'; +import { + LifecycleAnalyticsQueryDto, + LifecycleAnalyticsReportDto, + LifecycleEventDto, + RecordLifecycleEventDto, + TransferLifecycleHistoryDto, +} from './dto/lifecycle-analytics.dto'; + +/** + * SorobanLifecycleController + * + * REST API for Soroban transfer lifecycle analytics. + * Provides event recording, per-transfer history, and aggregate reports + * with bottleneck identification. + * + * Base path: /api/v1/analytics/lifecycle/stellar + */ +@ApiTags('Soroban Transfer Lifecycle Analytics') +@Controller('api/v1/analytics/lifecycle/stellar') +export class SorobanLifecycleController { + constructor(private readonly lifecycleService: SorobanLifecycleService) {} + + /** + * POST /api/v1/analytics/lifecycle/stellar/events + * + * Record a single lifecycle stage event for a transfer. + * Called by bridge adapters as each stage completes. + */ + @Post('events') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Record a lifecycle stage event', + description: + 'Records a single stage transition for a Soroban transfer. ' + + 'Automatically computes the duration from the previous stage.', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Lifecycle event recorded successfully', + type: LifecycleEventDto, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid request payload', + }) + async recordEvent( + @Body() dto: RecordLifecycleEventDto, + ): Promise { + const entity = await this.lifecycleService.recordEventFromDto(dto); + return { + id: entity.id, + transferId: entity.transferId, + stage: entity.stage, + sourceChain: entity.sourceChain, + destinationChain: entity.destinationChain, + asset: entity.asset, + bridgeName: entity.bridgeName, + durationFromPreviousMs: entity.durationFromPreviousMs + ? Number(entity.durationFromPreviousMs) + : null, + outcome: entity.outcome, + errorMessage: entity.errorMessage, + metadata: entity.metadata, + recordedAt: entity.recordedAt, + }; + } + + /** + * GET /api/v1/analytics/lifecycle/stellar/transfers/:transferId + * + * Retrieve the full ordered event history for a single transfer. + */ + @Get('transfers/:transferId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get lifecycle history for a transfer', + description: + 'Returns all recorded lifecycle events for the specified transfer in chronological order, ' + + 'including per-stage durations and final outcome.', + }) + @ApiParam({ + name: 'transferId', + description: 'UUID of the transfer', + type: 'string', + format: 'uuid', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Transfer lifecycle history', + type: TransferLifecycleHistoryDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'No events found for the given transferId', + }) + async getTransferHistory( + @Param('transferId', ParseUUIDPipe) transferId: string, + ): Promise { + return this.lifecycleService.getTransferHistory(transferId); + } + + /** + * GET /api/v1/analytics/lifecycle/stellar/report + * + * Generate an aggregate analytics report with bottleneck identification. + */ + @Get('report') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Generate lifecycle analytics report', + description: + 'Aggregates all lifecycle events matching the query filters into a full analytics report. ' + + 'Includes per-stage statistics (avg/median/p95 durations, failure rates) and ranked bottlenecks.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Lifecycle analytics report', + type: LifecycleAnalyticsReportDto, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid query parameters', + }) + async getAnalyticsReport( + @Query() query: LifecycleAnalyticsQueryDto, + ): Promise { + return this.lifecycleService.getAnalyticsReport(query); + } + + /** + * GET /api/v1/analytics/lifecycle/stellar/report/bottlenecks + * + * Returns only the bottleneck summary from the analytics report — useful for + * alerting and lightweight polling without the full stage breakdown. + */ + @Get('report/bottlenecks') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get bottleneck summary', + description: + 'Returns identified bottleneck stages ranked by severity, without the full per-stage detail.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Bottleneck summary', + }) + async getBottlenecks( + @Query() query: LifecycleAnalyticsQueryDto, + ): Promise<{ bottlenecks: LifecycleAnalyticsReportDto['bottlenecks']; generatedAt: Date }> { + const report = await this.lifecycleService.getAnalyticsReport(query); + return { + bottlenecks: report.bottlenecks, + generatedAt: report.generatedAt, + }; + } +} diff --git a/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.module.ts b/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.module.ts new file mode 100644 index 00000000..87c77723 --- /dev/null +++ b/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SorobanTransferLifecycleEntity } from './entities/soroban-transfer-lifecycle.entity'; +import { SorobanLifecycleService } from './soroban-lifecycle.service'; +import { SorobanLifecycleController } from './soroban-lifecycle.controller'; + +/** + * SorobanLifecycleModule + * + * Encapsulates Soroban transfer lifecycle analytics: + * - Records per-stage transition events + * - Computes stage durations and aggregate statistics + * - Exposes REST endpoints for event ingestion and report retrieval + * - Listens to `soroban.transfer.*` events from the application event bus + * + * Register in AppModule to activate. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([SorobanTransferLifecycleEntity]), + ], + controllers: [SorobanLifecycleController], + providers: [SorobanLifecycleService], + exports: [SorobanLifecycleService], +}) +export class SorobanLifecycleModule {} diff --git a/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.service.ts b/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.service.ts new file mode 100644 index 00000000..add4198c --- /dev/null +++ b/apps/api/src/analytics/lifecycle/transfers/stellar/soroban-lifecycle.service.ts @@ -0,0 +1,498 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, FindOptionsWhere, Repository } from 'typeorm'; +import { OnEvent } from '@nestjs/event-emitter'; +import { SorobanTransferLifecycleEntity } from './entities/soroban-transfer-lifecycle.entity'; +import { + BottleneckInfo, + LifecycleAnalyticsQuery, + LifecycleAnalyticsReport, + LifecycleStage, + ORDERED_STAGES, + RecordLifecycleEventPayload, + STAGE_LABELS, + StageStatistics, + TransferOutcome, +} from './types/lifecycle.types'; +import { + LifecycleAnalyticsQueryDto, + LifecycleAnalyticsReportDto, + RecordLifecycleEventDto, + TransferLifecycleHistoryDto, +} from './dto/lifecycle-analytics.dto'; + +/** Timeout threshold — transfers with no terminal event after this are TIMEOUT */ +const TRANSFER_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * SorobanLifecycleService + * + * Tracks individual Soroban transfer lifecycle events, measures per-stage + * durations, and generates analytics reports that identify bottlenecks. + * + * Architecture: + * - `recordEvent()` — writes one row per stage transition + * - `getAnalyticsReport()` — aggregates all events into a full report + * - `getTransferHistory()` — returns the complete event chain for one transfer + * - `@OnEvent('soroban.transfer.*')` — integrates with the existing EventEmitter2 bus + */ +@Injectable() +export class SorobanLifecycleService { + private readonly logger = new Logger(SorobanLifecycleService.name); + + constructor( + @InjectRepository(SorobanTransferLifecycleEntity) + private readonly repo: Repository, + ) {} + + // ─── Public API ───────────────────────────────────────────────────────────── + + /** + * Record a single lifecycle stage event for a transfer. + * + * Automatically computes `durationFromPreviousMs` by looking up + * the most recent event for the same `transferId`. + */ + async recordEvent(payload: RecordLifecycleEventPayload): Promise { + const { + transferId, + stage, + timestamp = new Date(), + errorMessage, + metadata, + } = payload; + + // Determine duration from the previous stage + const previousEvent = await this.repo.findOne({ + where: { transferId }, + order: { recordedAt: 'DESC' }, + }); + + const durationFromPreviousMs = previousEvent + ? timestamp.getTime() - previousEvent.recordedAt.getTime() + : null; + + // Determine outcome for terminal stages + let outcome: TransferOutcome | null = null; + if (stage === LifecycleStage.SETTLED) { + outcome = TransferOutcome.SUCCESS; + } else if (stage === LifecycleStage.FAILED) { + outcome = errorMessage?.toLowerCase().includes('timeout') + ? TransferOutcome.TIMEOUT + : TransferOutcome.FAILED; + } + + const entity = this.repo.create({ + transferId, + stage, + sourceChain: (metadata?.sourceChain as string) ?? previousEvent?.sourceChain ?? null, + destinationChain: (metadata?.destinationChain as string) ?? previousEvent?.destinationChain ?? null, + asset: (metadata?.asset as string) ?? previousEvent?.asset ?? null, + bridgeName: (metadata?.bridgeName as string) ?? previousEvent?.bridgeName ?? null, + durationFromPreviousMs, + outcome, + errorMessage: errorMessage ?? null, + metadata: metadata ?? null, + recordedAt: timestamp, + }); + + const saved = await this.repo.save(entity); + + this.logger.debug( + `Recorded lifecycle event: transferId=${transferId} stage=${stage}` + + (durationFromPreviousMs !== null ? ` duration=${durationFromPreviousMs}ms` : ''), + ); + + return saved; + } + + /** + * Record an event from the HTTP DTO (controller entry point). + */ + async recordEventFromDto(dto: RecordLifecycleEventDto): Promise { + return this.recordEvent({ + transferId: dto.transferId, + stage: dto.stage, + timestamp: dto.timestamp ? new Date(dto.timestamp) : undefined, + errorMessage: dto.errorMessage, + metadata: { + ...(dto.metadata ?? {}), + ...(dto.sourceChain ? { sourceChain: dto.sourceChain } : {}), + ...(dto.destinationChain ? { destinationChain: dto.destinationChain } : {}), + ...(dto.asset ? { asset: dto.asset } : {}), + ...(dto.bridgeName ? { bridgeName: dto.bridgeName } : {}), + }, + }); + } + + /** + * Retrieve the full lifecycle event chain for a single transfer. + */ + async getTransferHistory(transferId: string): Promise { + const events = await this.repo.find({ + where: { transferId }, + order: { recordedAt: 'ASC' }, + }); + + if (events.length === 0) { + throw new NotFoundException(`No lifecycle events found for transfer: ${transferId}`); + } + + const terminalEvent = events.find( + (e) => e.stage === LifecycleStage.SETTLED || e.stage === LifecycleStage.FAILED, + ); + const finalOutcome = terminalEvent?.outcome ?? null; + + const first = events[0]; + const last = events[events.length - 1]; + const totalDurationMs = + first && last ? last.recordedAt.getTime() - first.recordedAt.getTime() : null; + + return { + transferId, + events: events.map((e) => ({ + id: e.id, + transferId: e.transferId, + stage: e.stage, + sourceChain: e.sourceChain, + destinationChain: e.destinationChain, + asset: e.asset, + bridgeName: e.bridgeName, + durationFromPreviousMs: e.durationFromPreviousMs, + outcome: e.outcome, + errorMessage: e.errorMessage, + metadata: e.metadata, + recordedAt: e.recordedAt, + })), + finalOutcome, + totalDurationMs, + }; + } + + /** + * Generate a full lifecycle analytics report. + * + * Loads all relevant events, groups them by transfer, computes per-stage + * duration statistics, and identifies bottlenecks. + */ + async getAnalyticsReport(query: LifecycleAnalyticsQueryDto): Promise { + const where = this.buildWhereClause(query); + + // Load all matching events — use a raw query for performance on large sets + const events = await this.repo.find({ + where, + order: { transferId: 'ASC', recordedAt: 'ASC' }, + }); + + const report = this.computeReport(events, query.failedOnly ?? false); + + return { + ...report, + stageStats: report.stageStats, + bottlenecks: report.bottlenecks, + }; + } + + // ─── EventEmitter2 Integration ─────────────────────────────────────────────── + + @OnEvent('soroban.transfer.initiated') + async onTransferInitiated(payload: { + transferId: string; + sourceChain: string; + destinationChain: string; + asset: string; + bridgeName: string; + }): Promise { + await this.recordEvent({ + transferId: payload.transferId, + stage: LifecycleStage.INITIATED, + metadata: { + sourceChain: payload.sourceChain, + destinationChain: payload.destinationChain, + asset: payload.asset, + bridgeName: payload.bridgeName, + }, + }); + } + + @OnEvent('soroban.transfer.validated') + async onTransferValidated(payload: { transferId: string }): Promise { + await this.recordEvent({ transferId: payload.transferId, stage: LifecycleStage.VALIDATED }); + } + + @OnEvent('soroban.transfer.liquidity_reserved') + async onLiquidityReserved(payload: { transferId: string }): Promise { + await this.recordEvent({ transferId: payload.transferId, stage: LifecycleStage.LIQUIDITY_RESERVED }); + } + + @OnEvent('soroban.transfer.source_tx_submitted') + async onSourceTxSubmitted(payload: { transferId: string; txHash?: string }): Promise { + await this.recordEvent({ + transferId: payload.transferId, + stage: LifecycleStage.SOURCE_TX_SUBMITTED, + metadata: payload.txHash ? { txHash: payload.txHash } : undefined, + }); + } + + @OnEvent('soroban.transfer.source_tx_confirmed') + async onSourceTxConfirmed(payload: { transferId: string; blockNumber?: number }): Promise { + await this.recordEvent({ + transferId: payload.transferId, + stage: LifecycleStage.SOURCE_TX_CONFIRMED, + metadata: payload.blockNumber ? { blockNumber: payload.blockNumber } : undefined, + }); + } + + @OnEvent('soroban.transfer.contract_invoked') + async onContractInvoked(payload: { transferId: string; contractId?: string }): Promise { + await this.recordEvent({ + transferId: payload.transferId, + stage: LifecycleStage.SOROBAN_CONTRACT_INVOKED, + metadata: payload.contractId ? { contractId: payload.contractId } : undefined, + }); + } + + @OnEvent('soroban.transfer.contract_confirmed') + async onContractConfirmed(payload: { transferId: string }): Promise { + await this.recordEvent({ transferId: payload.transferId, stage: LifecycleStage.SOROBAN_CONTRACT_CONFIRMED }); + } + + @OnEvent('soroban.transfer.destination_tx_submitted') + async onDestinationTxSubmitted(payload: { transferId: string; txHash?: string }): Promise { + await this.recordEvent({ + transferId: payload.transferId, + stage: LifecycleStage.DESTINATION_TX_SUBMITTED, + metadata: payload.txHash ? { txHash: payload.txHash } : undefined, + }); + } + + @OnEvent('soroban.transfer.destination_tx_confirmed') + async onDestinationTxConfirmed(payload: { transferId: string }): Promise { + await this.recordEvent({ transferId: payload.transferId, stage: LifecycleStage.DESTINATION_TX_CONFIRMED }); + } + + @OnEvent('soroban.transfer.settled') + async onTransferSettled(payload: { transferId: string }): Promise { + await this.recordEvent({ transferId: payload.transferId, stage: LifecycleStage.SETTLED }); + } + + @OnEvent('soroban.transfer.failed') + async onTransferFailed(payload: { transferId: string; reason?: string }): Promise { + await this.recordEvent({ + transferId: payload.transferId, + stage: LifecycleStage.FAILED, + errorMessage: payload.reason, + }); + } + + // ─── Analysis Engine ───────────────────────────────────────────────────────── + + /** + * Core computation: group events by transfer → derive statistics. + */ + private computeReport( + events: SorobanTransferLifecycleEntity[], + failedOnly: boolean, + ): LifecycleAnalyticsReport { + // Group events by transferId + const byTransfer = new Map(); + for (const ev of events) { + const list = byTransfer.get(ev.transferId) ?? []; + list.push(ev); + byTransfer.set(ev.transferId, list); + } + + let successfulTransfers = 0; + let failedTransfers = 0; + let timedOutTransfers = 0; + const totalDurations: number[] = []; + + // Per-stage accumulators: stage → list of duration samples + const stageDurations = new Map(); + const stageFailCounts = new Map(); + const stageReachCounts = new Map(); + + for (const stage of ORDERED_STAGES) { + stageDurations.set(stage, []); + stageFailCounts.set(stage, 0); + stageReachCounts.set(stage, 0); + } + // FAILED is a terminal stage — track separately + stageDurations.set(LifecycleStage.FAILED, []); + stageFailCounts.set(LifecycleStage.FAILED, 0); + stageReachCounts.set(LifecycleStage.FAILED, 0); + + for (const [, transferEvents] of byTransfer) { + const sorted = [...transferEvents].sort( + (a, b) => a.recordedAt.getTime() - b.recordedAt.getTime(), + ); + + const terminalEvent = sorted.find( + (e) => e.stage === LifecycleStage.SETTLED || e.stage === LifecycleStage.FAILED, + ); + + const isSuccess = terminalEvent?.outcome === TransferOutcome.SUCCESS; + const isTimeout = terminalEvent?.outcome === TransferOutcome.TIMEOUT || + (!terminalEvent && + Date.now() - sorted[0].recordedAt.getTime() > TRANSFER_TIMEOUT_MS); + const isFailed = terminalEvent?.outcome === TransferOutcome.FAILED; + + if (failedOnly && isSuccess) continue; + + if (isSuccess) successfulTransfers++; + else if (isTimeout) timedOutTransfers++; + else if (isFailed || !terminalEvent) failedTransfers++; + + // Total duration for successful transfers + if (isSuccess && sorted.length > 0) { + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + totalDurations.push(last.recordedAt.getTime() - first.recordedAt.getTime()); + } + + // Per-stage stats + const failedAt = terminalEvent?.stage === LifecycleStage.FAILED + ? this.getFailedAtStage(sorted) + : null; + + for (const ev of sorted) { + const s = ev.stage; + stageReachCounts.set(s, (stageReachCounts.get(s) ?? 0) + 1); + + if (ev.durationFromPreviousMs !== null) { + const durations = stageDurations.get(s) ?? []; + durations.push(Number(ev.durationFromPreviousMs)); + stageDurations.set(s, durations); + } + + if (failedAt === s) { + stageFailCounts.set(s, (stageFailCounts.get(s) ?? 0) + 1); + } + } + } + + const totalTransfers = byTransfer.size; + + // Build per-stage stats + const allStages = [...ORDERED_STAGES, LifecycleStage.FAILED]; + const stageStats: StageStatistics[] = allStages + .filter((s) => (stageReachCounts.get(s) ?? 0) > 0) + .map((stage) => { + const durations = stageDurations.get(stage) ?? []; + const reachCount = stageReachCounts.get(stage) ?? 0; + const failCount = stageFailCounts.get(stage) ?? 0; + + return { + stage, + label: STAGE_LABELS[stage], + reachCount, + failCount, + stageFailureRate: reachCount > 0 ? failCount / reachCount : 0, + avgDurationMs: this.average(durations), + medianDurationMs: this.percentile(durations, 50), + p95DurationMs: this.percentile(durations, 95), + minDurationMs: durations.length > 0 ? Math.min(...durations) : 0, + maxDurationMs: durations.length > 0 ? Math.max(...durations) : 0, + }; + }); + + const avgTotal = this.average(totalDurations); + const bottlenecks = this.identifyBottlenecks(stageStats, avgTotal); + + return { + totalTransfers, + successfulTransfers, + failedTransfers, + timedOutTransfers, + overallSuccessRate: + totalTransfers > 0 ? (successfulTransfers / totalTransfers) * 100 : 0, + avgTotalDurationMs: avgTotal, + medianTotalDurationMs: this.percentile(totalDurations, 50), + p95TotalDurationMs: this.percentile(totalDurations, 95), + stageStats, + bottlenecks, + generatedAt: new Date(), + }; + } + + /** + * Identifies which stage a failed transfer stalled at — + * the last non-FAILED stage reached before FAILED. + */ + private getFailedAtStage( + sortedEvents: SorobanTransferLifecycleEntity[], + ): LifecycleStage | null { + const failIdx = sortedEvents.findIndex((e) => e.stage === LifecycleStage.FAILED); + if (failIdx <= 0) return LifecycleStage.INITIATED; + return sortedEvents[failIdx - 1].stage; + } + + /** + * Rank stages by their contribution to total transfer time and failure rate. + * Returns top bottlenecks sorted by severity desc. + */ + private identifyBottlenecks( + stageStats: StageStatistics[], + avgTotalMs: number, + ): BottleneckInfo[] { + const totalMs = avgTotalMs || 1; + + const bottlenecks: BottleneckInfo[] = stageStats + .filter((s) => s.stage !== LifecycleStage.FAILED && s.avgDurationMs > 0) + .map((s) => { + const percentOfTotal = (s.avgDurationMs / totalMs) * 100; + + // Severity based on time share and failure rate + let severity: BottleneckInfo['severity'] = 'low'; + const combined = percentOfTotal + s.stageFailureRate * 100; + if (combined >= 50 || s.stageFailureRate >= 0.3) severity = 'critical'; + else if (combined >= 30 || s.stageFailureRate >= 0.15) severity = 'high'; + else if (combined >= 15 || s.stageFailureRate >= 0.05) severity = 'medium'; + + return { + stage: s.stage, + label: s.label, + avgDurationMs: s.avgDurationMs, + percentOfTotalTime: Math.round(percentOfTotal * 10) / 10, + failCount: s.failCount, + severity, + }; + }) + .sort((a, b) => { + const order = { critical: 4, high: 3, medium: 2, low: 1 }; + return order[b.severity] - order[a.severity] || b.percentOfTotalTime - a.percentOfTotalTime; + }); + + return bottlenecks; + } + + // ─── Helpers ───────────────────────────────────────────────────────────────── + + private buildWhereClause( + query: LifecycleAnalyticsQueryDto, + ): FindOptionsWhere { + const where: FindOptionsWhere = {}; + + if (query.sourceChain) where.sourceChain = query.sourceChain; + if (query.destinationChain) where.destinationChain = query.destinationChain; + if (query.asset) where.asset = query.asset; + if (query.bridgeName) where.bridgeName = query.bridgeName; + if (query.startDate && query.endDate) { + where.recordedAt = Between(new Date(query.startDate), new Date(query.endDate)); + } + + return where; + } + + private average(values: number[]): number { + if (values.length === 0) return 0; + return Math.round(values.reduce((s, v) => s + v, 0) / values.length); + } + + private percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; + } +} diff --git a/apps/api/src/analytics/lifecycle/transfers/stellar/types/lifecycle.types.ts b/apps/api/src/analytics/lifecycle/transfers/stellar/types/lifecycle.types.ts new file mode 100644 index 00000000..c81273a6 --- /dev/null +++ b/apps/api/src/analytics/lifecycle/transfers/stellar/types/lifecycle.types.ts @@ -0,0 +1,182 @@ +/** + * Soroban Transfer Lifecycle — Core Types + * + * Defines all lifecycle stages, event shapes, and analysis interfaces + * for Soroban/Stellar cross-chain transfers. + */ + +/** + * Ordered lifecycle stages for a Soroban transfer. + * Each stage must complete before the next begins. + */ +export enum LifecycleStage { + /** Transfer request submitted by user */ + INITIATED = 'initiated', + /** Source-chain validation passed */ + VALIDATED = 'validated', + /** Liquidity reserved on source chain */ + LIQUIDITY_RESERVED = 'liquidity_reserved', + /** Source-chain transaction submitted */ + SOURCE_TX_SUBMITTED = 'source_tx_submitted', + /** Source-chain transaction confirmed */ + SOURCE_TX_CONFIRMED = 'source_tx_confirmed', + /** Soroban contract invoked */ + SOROBAN_CONTRACT_INVOKED = 'soroban_contract_invoked', + /** Soroban contract execution confirmed */ + SOROBAN_CONTRACT_CONFIRMED = 'soroban_contract_confirmed', + /** Destination-chain transaction submitted */ + DESTINATION_TX_SUBMITTED = 'destination_tx_submitted', + /** Destination-chain transaction confirmed */ + DESTINATION_TX_CONFIRMED = 'destination_tx_confirmed', + /** Transfer fully settled and funds delivered */ + SETTLED = 'settled', + /** Terminal: transfer failed at any stage */ + FAILED = 'failed', +} + +/** + * Final outcome of a transfer. + */ +export enum TransferOutcome { + SUCCESS = 'success', + FAILED = 'failed', + TIMEOUT = 'timeout', +} + +/** + * Human-readable label for each stage transition. + */ +export const STAGE_LABELS: Record = { + [LifecycleStage.INITIATED]: 'Initiated', + [LifecycleStage.VALIDATED]: 'Validated', + [LifecycleStage.LIQUIDITY_RESERVED]: 'Liquidity Reserved', + [LifecycleStage.SOURCE_TX_SUBMITTED]: 'Source Tx Submitted', + [LifecycleStage.SOURCE_TX_CONFIRMED]: 'Source Tx Confirmed', + [LifecycleStage.SOROBAN_CONTRACT_INVOKED]: 'Soroban Contract Invoked', + [LifecycleStage.SOROBAN_CONTRACT_CONFIRMED]: 'Soroban Contract Confirmed', + [LifecycleStage.DESTINATION_TX_SUBMITTED]: 'Destination Tx Submitted', + [LifecycleStage.DESTINATION_TX_CONFIRMED]: 'Destination Tx Confirmed', + [LifecycleStage.SETTLED]: 'Settled', + [LifecycleStage.FAILED]: 'Failed', +}; + +/** + * Ordered happy-path stages (excludes FAILED terminal stage). + */ +export const ORDERED_STAGES: LifecycleStage[] = [ + LifecycleStage.INITIATED, + LifecycleStage.VALIDATED, + LifecycleStage.LIQUIDITY_RESERVED, + LifecycleStage.SOURCE_TX_SUBMITTED, + LifecycleStage.SOURCE_TX_CONFIRMED, + LifecycleStage.SOROBAN_CONTRACT_INVOKED, + LifecycleStage.SOROBAN_CONTRACT_CONFIRMED, + LifecycleStage.DESTINATION_TX_SUBMITTED, + LifecycleStage.DESTINATION_TX_CONFIRMED, + LifecycleStage.SETTLED, +]; + +/** + * A single stage event recorded during a transfer's lifecycle. + */ +export interface LifecycleEvent { + transferId: string; + stage: LifecycleStage; + timestamp: Date; + /** Duration from the previous stage in milliseconds */ + durationFromPreviousMs?: number; + /** Optional error message when stage === FAILED */ + errorMessage?: string; + /** Arbitrary metadata (tx hash, block number, etc.) */ + metadata?: Record; +} + +/** + * Per-stage aggregated statistics across many transfers. + */ +export interface StageStatistics { + stage: LifecycleStage; + label: string; + /** How many transfers reached this stage */ + reachCount: number; + /** How many failed at this specific stage */ + failCount: number; + /** Failure rate for this stage (0–1) */ + stageFailureRate: number; + /** Average duration from previous stage (ms) */ + avgDurationMs: number; + /** Median duration from previous stage (ms) */ + medianDurationMs: number; + /** 95th-percentile duration (ms) */ + p95DurationMs: number; + /** Minimum duration recorded (ms) */ + minDurationMs: number; + /** Maximum duration recorded (ms) */ + maxDurationMs: number; +} + +/** + * Identified bottleneck stage in the pipeline. + */ +export interface BottleneckInfo { + stage: LifecycleStage; + label: string; + /** Average duration at this stage, in ms */ + avgDurationMs: number; + /** Percentage of total transfer time consumed by this stage */ + percentOfTotalTime: number; + /** Failure count at this stage */ + failCount: number; + /** Severity classification */ + severity: 'low' | 'medium' | 'high' | 'critical'; +} + +/** + * Full analytics report for Soroban transfer lifecycle. + */ +export interface LifecycleAnalyticsReport { + /** Total transfers analysed */ + totalTransfers: number; + successfulTransfers: number; + failedTransfers: number; + timedOutTransfers: number; + /** Overall success rate (0–100) */ + overallSuccessRate: number; + /** Average end-to-end duration for successful transfers (ms) */ + avgTotalDurationMs: number; + /** Median end-to-end duration (ms) */ + medianTotalDurationMs: number; + /** P95 end-to-end duration (ms) */ + p95TotalDurationMs: number; + /** Per-stage breakdown */ + stageStats: StageStatistics[]; + /** Detected bottlenecks, ranked by severity */ + bottlenecks: BottleneckInfo[]; + /** Timestamp of report generation */ + generatedAt: Date; +} + +/** + * Payload used to record a single lifecycle event. + */ +export interface RecordLifecycleEventPayload { + transferId: string; + stage: LifecycleStage; + timestamp?: Date; + errorMessage?: string; + metadata?: Record; +} + +/** + * Query filters for lifecycle analytics. + */ +export interface LifecycleAnalyticsQuery { + sourceChain?: string; + destinationChain?: string; + asset?: string; + bridgeName?: string; + startDate?: Date; + endDate?: Date; + /** If set, only analyse transfers that failed */ + failedOnly?: boolean; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f085b5a8..ba12a5d7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -29,6 +29,8 @@ import { StellarEcosystemMetricsModule } from './metrics/ecosystem/stellar/stell import { StellarProviderDiscoveryModule } from '../../../src/providers/discovery/stellar/stellar-provider-discovery.module'; import { AssetCoverageModule } from '../../../src/analytics/coverage/stellar/asset-coverage.module'; import { RouteInsightsExporterModule } from './exporters/routes/stellar/route-insights-exporter.module'; +import { SorobanLifecycleModule } from './analytics/lifecycle/transfers/stellar/soroban-lifecycle.module'; +import { SorobanTransferLifecycleEntity } from './analytics/lifecycle/transfers/stellar/entities/soroban-transfer-lifecycle.entity'; @Module({ imports: [ @@ -47,7 +49,7 @@ import { RouteInsightsExporterModule } from './exporters/routes/stellar/route-in password: dbConfig.password, database: dbConfig.database, ssl: dbConfig.ssl, - entities: [Transaction, WalletSession], + entities: [Transaction, WalletSession, SorobanTransferLifecycleEntity], synchronize: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development', }; @@ -79,6 +81,7 @@ import { RouteInsightsExporterModule } from './exporters/routes/stellar/route-in // Exposed through /explainability/stellar endpoints. StellarExplainabilityModule, RouteInsightsExporterModule, + SorobanLifecycleModule, ], controllers: [AppController], providers: [ diff --git a/apps/dashboard/routes/reliability/page.tsx b/apps/dashboard/routes/reliability/page.tsx index 44f74b07..4b475608 100644 --- a/apps/dashboard/routes/reliability/page.tsx +++ b/apps/dashboard/routes/reliability/page.tsx @@ -1,5 +1,11 @@ -import React, { useState } from 'react'; -import { RouteReliabilityMetric, RouteFilter } from './types'; +'use client'; + +import React, { useState, useMemo } from 'react'; +import { RouteReliabilityMetric, ReliabilityTrend, RouteFilter } from './types'; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- const MOCK_METRICS: RouteReliabilityMetric[] = [ { @@ -50,179 +56,665 @@ const MOCK_METRICS: RouteReliabilityMetric[] = [ failureCount: 136, lastUpdated: new Date().toISOString(), }, + { + routeId: 'stellar-arbitrum-usdc', + sourceChain: 'Stellar', + destinationChain: 'Arbitrum', + asset: 'USDC', + successRate: 0.975, + avgLatencyMs: 3500, + p95LatencyMs: 7100, + totalTransfers: 1450, + failureCount: 36, + lastUpdated: new Date().toISOString(), + }, ]; +/** Generate 14-day trend data for a given route */ +function generateTrends(routeId: string, baseRate: number): ReliabilityTrend[] { + const trends: ReliabilityTrend[] = []; + for (let i = 13; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const jitter = (Math.random() - 0.5) * 0.04; + trends.push({ + routeId, + date: d.toISOString().slice(0, 10), + successRate: Math.min(1, Math.max(0.88, baseRate + jitter)), + avgLatencyMs: 3000 + Math.round(Math.random() * 4000), + transferCount: 80 + Math.round(Math.random() * 120), + }); + } + return trends; +} + +const MOCK_TRENDS: ReliabilityTrend[] = MOCK_METRICS.flatMap((m) => + generateTrends(m.routeId, m.successRate), +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function reliabilityColor(rate: number): string { if (rate >= 0.98) return '#22c55e'; if (rate >= 0.95) return '#f59e0b'; return '#ef4444'; } +function reliabilityLabel(rate: number): string { + if (rate >= 0.98) return 'Excellent'; + if (rate >= 0.95) return 'Good'; + if (rate >= 0.90) return 'Fair'; + return 'Poor'; +} + +function applyFilter( + metrics: RouteReliabilityMetric[], + filter: RouteFilter, +): RouteReliabilityMetric[] { + return metrics.filter((m) => { + if (filter.sourceChain && m.sourceChain !== filter.sourceChain) return false; + if (filter.destinationChain && m.destinationChain !== filter.destinationChain) return false; + if (filter.asset && m.asset !== filter.asset) return false; + if (filter.minSuccessRate !== undefined && m.successRate < filter.minSuccessRate / 100) + return false; + return true; + }); +} + +function formatLatency(ms: number): string { + return ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; +} + +function shortDate(iso: string): string { + const [, m, d] = iso.split('-'); + return `${m}/${d}`; +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Single summary metric card */ +function MetricCard({ + label, + value, + sub, + accent, +}: { + label: string; + value: string; + sub?: string; + accent?: string; +}) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +/** Horizontal reliability progress bar */ function ReliabilityBar({ rate }: { rate: number }) { const color = reliabilityColor(rate); - const pct = (rate * 100).toFixed(0) + '%'; + const pct = `${(rate * 100).toFixed(0)}%`; return ( -
-
+
+
- {(rate * 100).toFixed(1)}% + + {(rate * 100).toFixed(1)}% +
); } -function MetricCard({ label, value, sub }: { label: string; value: string; sub?: string }) { +/** Badge showing reliability tier */ +function ReliabilityBadge({ rate }: { rate: number }) { + const color = reliabilityColor(rate); + const label = reliabilityLabel(rate); return ( -
-
{label}
-
{value}
- {sub &&
{sub}
} -
+ + {label} + ); } -function applyFilter(metrics: RouteReliabilityMetric[], filter: RouteFilter): RouteReliabilityMetric[] { - return metrics.filter((m) => { - if (filter.sourceChain && m.sourceChain !== filter.sourceChain) return false; - if (filter.destinationChain && m.destinationChain !== filter.destinationChain) return false; - if (filter.asset && m.asset !== filter.asset) return false; - if (filter.minSuccessRate !== undefined && m.successRate < filter.minSuccessRate) return false; - return true; +// --------------------------------------------------------------------------- +// Trend Line Chart (SVG, no library) +// --------------------------------------------------------------------------- + +interface TrendChartProps { + trends: ReliabilityTrend[]; + routeIds: string[]; + routeLabels: Record; +} + +const TREND_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4']; + +function TrendLineChart({ trends, routeIds, routeLabels }: TrendChartProps) { + const W = 700; + const H = 220; + const PAD = { top: 20, right: 20, bottom: 40, left: 52 }; + const chartW = W - PAD.left - PAD.right; + const chartH = H - PAD.top - PAD.bottom; + + // Collect all unique dates (x-axis) + const dates = Array.from(new Set(trends.map((t) => t.date))).sort(); + if (dates.length < 2 || routeIds.length === 0) { + return ( +
+ No trend data for the selected filters. +
+ ); + } + + const xScale = (i: number) => PAD.left + (i / (dates.length - 1)) * chartW; + const yMin = 0.85; + const yMax = 1.0; + const yScale = (v: number) => PAD.top + chartH - ((v - yMin) / (yMax - yMin)) * chartH; + + // Y-axis ticks + const yTicks = [0.85, 0.90, 0.95, 0.98, 1.0]; + + // Build polyline points per route + const lines = routeIds.map((id, colorIdx) => { + const routeTrends = trends + .filter((t) => t.routeId === id) + .sort((a, b) => a.date.localeCompare(b.date)); + + const points = routeTrends + .map((t) => { + const xi = dates.indexOf(t.date); + return `${xScale(xi).toFixed(1)},${yScale(t.successRate).toFixed(1)}`; + }) + .join(' '); + + return { id, points, color: TREND_COLORS[colorIdx % TREND_COLORS.length] }; }); + + // X-axis labels — show every 2nd to avoid crowding + const xLabels = dates + .map((d, i) => ({ label: shortDate(d), i })) + .filter((_, i) => i % 2 === 0 || i === dates.length - 1); + + return ( + + {/* Y-axis grid + labels */} + {yTicks.map((tick) => { + const y = yScale(tick); + return ( + + + + {(tick * 100).toFixed(0)}% + + + ); + })} + + {/* X-axis labels */} + {xLabels.map(({ label, i }) => ( + + {label} + + ))} + + {/* Axes */} + + + + {/* Lines per route */} + {lines.map(({ id, points, color }) => ( + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Filter bar +// --------------------------------------------------------------------------- + +interface FilterBarProps { + filter: RouteFilter; + chains: string[]; + assets: string[]; + onChange: (f: RouteFilter) => void; + onClear: () => void; } +const SELECT_STYLE: React.CSSProperties = { + padding: '6px 10px', + border: '1px solid #e2e8f0', + borderRadius: 6, + fontSize: 13, + background: '#fff', + color: '#1e293b', + cursor: 'pointer', +}; + +function FilterBar({ filter, chains, assets, onChange, onClear }: FilterBarProps) { + return ( +
+ + + + + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + export default function RouteReliabilityDashboard() { - const [metrics] = useState(MOCK_METRICS); const [filter, setFilter] = useState({}); + const [selectedRouteId, setSelectedRouteId] = useState(null); - const filtered = applyFilter(metrics, filter); - const avgSuccessRate = - filtered.length > 0 - ? filtered.reduce((s, m) => s + m.successRate, 0) / filtered.length - : 0; - const totalTransfers = filtered.reduce((s, m) => s + m.totalTransfers, 0); - const bestRoute = - filtered.length > 0 - ? filtered.reduce((best, m) => (m.successRate > best.successRate ? m : best)) - : null; + const filtered = useMemo(() => applyFilter(MOCK_METRICS, filter), [filter]); - const chains = Array.from(new Set(metrics.flatMap((m) => [m.sourceChain, m.destinationChain]))); - const assets = Array.from(new Set(metrics.map((m) => m.asset))); + // Aggregate summary metrics + const summary = useMemo(() => { + if (filtered.length === 0) + return { avgRate: 0, totalTransfers: 0, totalFailures: 0, bestRoute: null as RouteReliabilityMetric | null }; + const avgRate = filtered.reduce((s, m) => s + m.successRate, 0) / filtered.length; + const totalTransfers = filtered.reduce((s, m) => s + m.totalTransfers, 0); + const totalFailures = filtered.reduce((s, m) => s + m.failureCount, 0); + const bestRoute = filtered.reduce((best, m) => (m.successRate > best.successRate ? m : best)); + return { avgRate, totalTransfers, totalFailures, bestRoute }; + }, [filtered]); - return ( -
-

- Route Reliability Dashboard -

-

- Stellar cross-chain route performance and reliability metrics. -

+ // Trend data for filtered routes + const trendRouteIds = filtered.map((m) => m.routeId); + const filteredTrends = useMemo( + () => MOCK_TRENDS.filter((t) => trendRouteIds.includes(t.routeId)), + [trendRouteIds.join(',')], + ); -
- - - - + const routeLabels = useMemo( + () => + Object.fromEntries( + MOCK_METRICS.map((m) => [m.routeId, `${m.sourceChain} → ${m.destinationChain}`]), + ), + [], + ); + + // Chains/assets for filter dropdowns + const chains = useMemo( + () => Array.from(new Set(MOCK_METRICS.flatMap((m) => [m.sourceChain, m.destinationChain]))).sort(), + [], + ); + const assets = useMemo( + () => Array.from(new Set(MOCK_METRICS.map((m) => m.asset))).sort(), + [], + ); + + const lastRefreshed = new Date().toLocaleTimeString(); + + return ( +
+ {/* Header */} +
+

+ Route Reliability Dashboard +

+

+ Stellar cross-chain route performance — success rates, latency, and 14-day trends. +

-
- - - - {bestRoute && ( + {/* Filter bar */} +
+ setFilter({})} + /> +
+ + {/* Summary cards */} +
+ + + + 0 ? '#ef4444' : '#22c55e'} + sub="filtered routes" + /> + {summary.bestRoute && ( )} -
+ -
-
-

Route Metrics

+ {/* Trend chart */} +
+
+

+ 14-Day Success Rate Trends +

+ {/* Legend */} +
+ {filtered.map((m, i) => ( + + ))} +
- {filtered.length === 0 ? ( -
No routes match the filters.
- ) : ( - - - - {['Route', 'Asset', 'Success Rate', 'Avg Latency', 'P95 Latency', 'Transfers', 'Failures'].map( - (h) => ( - + + + + + + + + + ))} + +
+ + + + + {/* Route metrics table */} +
+
+
+

+ Route Metrics +

+
+ + {filtered.length === 0 ? ( +
+ No routes match the current filters. +
+ ) : ( +
+ + + + {[ + 'Route', + 'Asset', + 'Status', + 'Success Rate', + 'Avg Latency', + 'P95 Latency', + 'Transfers', + 'Failures', + ].map((h) => ( + + ))} + + + + {filtered.map((m) => ( + { + (e.currentTarget as HTMLTableRowElement).style.background = '#f8fafc'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLTableRowElement).style.background = ''; }} > - {h} - - ), - )} - - - - {filtered.map((m) => ( - - - - - - - - - - ))} - -
+ {h} +
- {m.sourceChain} to {m.destinationChain} - {m.asset} - - {(m.avgLatencyMs / 1000).toFixed(1)} s{(m.p95LatencyMs / 1000).toFixed(1)} s{m.totalTransfers.toLocaleString()}{m.failureCount}
- )} -
+
+ {m.sourceChain} → {m.destinationChain} + + {m.asset} + + + + + + {formatLatency(m.avgLatencyMs)} + + {formatLatency(m.p95LatencyMs)} + + {m.totalTransfers.toLocaleString()} + 100 ? '#ef4444' : m.failureCount > 30 ? '#f59e0b' : '#22c55e', + }} + > + {m.failureCount} +
+
+ )} +
+ -

- Data refreshes every 60 seconds in production. + {/* Footer */} +

+ Last updated: {lastRefreshed} · Data refreshes every 60 s in production.

);