diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4fa319428..77e4796f6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -95,6 +95,7 @@ const envValidationSchema = Joi.object({ BACKUP_ENCRYPTION_KEY: Joi.string().length(64).optional(), // 32-byte key as hex BACKUP_RETENTION_DAYS: Joi.number().integer().min(1).default(30).optional(), BACKUP_TMP_DIR: Joi.string().optional(), + ALLOWED_ORIGINS: Joi.string().optional(), }); @Module({ @@ -149,6 +150,30 @@ const envValidationSchema = Joi.object({ useFactory: (configService: ConfigService) => { const dbUrl = configService.get('database.url'); const dbHost = configService.get('database.host'); + const isProduction = configService.get('NODE_ENV') === 'production'; + const redisUrl = configService.get('REDIS_URL'); + + const poolConfig = { + max: configService.get('DATABASE_POOL_MAX', isProduction ? 30 : 10), + min: configService.get('DATABASE_POOL_MIN', isProduction ? 5 : 2), + idleTimeoutMillis: configService.get('DATABASE_IDLE_TIMEOUT', 30000), + connectionTimeoutMillis: configService.get('DATABASE_CONNECTION_TIMEOUT', 2000), + statement_timeout: 30000, + query_timeout: 30000, + validationQuery: 'SELECT 1', + validateConnection: true, + }; + + const cacheConfig = redisUrl + ? { + type: 'redis' as const, + options: { url: redisUrl }, + duration: 30000, + } + : { + type: 'database' as const, + duration: 30000, + }; if (dbUrl) { // URL-based connection (e.g. DATABASE_URL on cloud platforms) @@ -157,6 +182,9 @@ const envValidationSchema = Joi.object({ url: dbUrl, autoLoadEntities: true, synchronize: configService.get('NODE_ENV') !== 'production', + extra: poolConfig, + cache: cacheConfig, + maxQueryExecutionTime: 100, // Monitor and log queries exceeding 100ms }; } @@ -176,6 +204,9 @@ const envValidationSchema = Joi.object({ password: configService.get('database.pass'), autoLoadEntities: true, synchronize: configService.get('NODE_ENV') !== 'production', + extra: poolConfig, + cache: cacheConfig, + maxQueryExecutionTime: 100, // Monitor and log queries exceeding 100ms }; }, }), diff --git a/backend/src/main.ts b/backend/src/main.ts index b34db4ddc..ac8dde013 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -32,6 +32,25 @@ async function bootstrap() { defaultVersion: CURRENT_VERSION, }); + // Configure CORS + const allowedOriginsString = configService.get('ALLOWED_ORIGINS') || ''; + const allowedOrigins = allowedOriginsString.split(',').map((origin) => origin.trim()).filter(Boolean); + const isProduction = configService.get('NODE_ENV') === 'production'; + + app.enableCors({ + origin: (origin, callback) => { + // Allow all origins in non-production, or if ALLOWED_ORIGINS contains '*' or is not set + if (!isProduction || !origin || allowedOrigins.includes(origin) || allowedOrigins.includes('*') || allowedOrigins.length === 0) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Version'], + exposedHeaders: ['X-Deprecated-Version', 'X-Sunset-Date'], + }); // CORS configuration — environment-based allowed origins const corsOrigins = configService.get('cors.origins'); const corsEnabled = configService.get('cors.enabled'); diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index 35e3f5646..0f9e86f30 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -1,10 +1,11 @@ -import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query, Header, Logger } from '@nestjs/common'; import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { TypeOrmHealthIndicator } from './indicators/typeorm.health'; import { IndexerHealthIndicator } from './indicators/indexer.health'; import { RpcHealthIndicator } from './indicators/rpc.health'; import { ConnectionPoolHealthIndicator } from './indicators/connection-pool.health'; +import { SystemHealthIndicator } from './indicators/system.health'; import { RedisHealthIndicator, EmailServiceHealthIndicator, @@ -16,6 +17,8 @@ import { HealthHistoryService } from './health-history.service'; @ApiTags('Health') @Controller('health') export class HealthController { + private readonly logger = new Logger(HealthController.name); + constructor( private readonly health: HealthCheckService, private readonly db: TypeOrmHealthIndicator, @@ -26,6 +29,7 @@ export class HealthController { private readonly email: EmailServiceHealthIndicator, private readonly sorobanRpc: SorobanRpcHealthIndicator, private readonly horizon: HorizonHealthIndicator, + private readonly system: SystemHealthIndicator, private readonly healthHistory: HealthHistoryService, ) {} @@ -37,57 +41,6 @@ export class HealthController { description: 'Comprehensive health check including database, RPC endpoints, indexer service, and connection pool', }) - @ApiResponse({ - status: 200, - description: 'Application is healthy', - schema: { - example: { - status: 'ok', - checks: { - database: { - status: 'up', - responseTime: '45ms', - threshold: '200ms', - }, - database_pool: { - status: 'up', - metrics: { - activeConnections: 5, - idleConnections: 15, - utilizationPercentage: 25, - }, - }, - rpc: { - status: 'up', - responseTime: '120ms', - currentEndpoint: 'https://soroban-testnet.stellar.org', - totalEndpoints: 2, - }, - indexer: { - status: 'up', - timeSinceLastProcess: '3500ms', - threshold: '15000ms', - lastProcessedTime: '2026-03-25T10:30:45.123Z', - }, - }, - }, - }, - }) - @ApiResponse({ - status: 503, - description: 'One or more health checks failed', - schema: { - example: { - status: 'error', - checks: { - database: { - status: 'down', - message: 'Database connection failed', - }, - }, - }, - }, - }) async check() { return this.health.check([ () => this.db.isHealthy('database'), @@ -113,33 +66,51 @@ export class HealthController { this.email.isHealthy('email'), this.sorobanRpc.isHealthy('soroban-rpc'), this.horizon.isHealthy('horizon'), + this.system.isHealthy('system'), ]); + const services = [ + 'database', + 'rpc', + 'indexer', + 'redis', + 'email', + 'soroban-rpc', + 'horizon', + 'system', + ]; + const results = checks.map((check, index) => { - const services = [ - 'database', - 'rpc', - 'indexer', - 'redis', - 'email', - 'soroban-rpc', - 'horizon', - ]; + const serviceName = services[index]; + let status: 'up' | 'down' | 'degraded' = 'down'; + let responseTime = 0; + let details: any = {}; if (check.status === 'fulfilled') { - return check.value; + const val = check.value; + const res = val[serviceName]; + status = res?.status === 'up' || res?.status === 'healthy' || res?.status !== 'down' ? 'up' : 'down'; + responseTime = parseInt(res?.responseTime || '0', 10) || 0; + details = res; + } else { + const errMessage = check.reason?.message || 'Unknown error'; + details = { status: 'down', error: errMessage }; + this.logger.error(`[ALERT] Health check failure: service ${serviceName} is DOWN! Error: ${errMessage}`); } - return { - [services[index]]: { - status: 'down', - error: check.reason?.message || 'Unknown error', - }, - }; + this.healthHistory.recordCheck({ + service: serviceName, + status, + responseTime, + timestamp: new Date(), + error: status === 'down' ? JSON.stringify(details) : undefined, + }); + + return { [serviceName]: details }; }); const totalTime = Date.now() - startTime; - const allHealthy = checks.every((c) => c.status === 'fulfilled'); + const allHealthy = checks.every((c) => c.status === 'fulfilled' && (c.value as any)[Object.keys(c.value)[0]]?.status !== 'down'); return { status: allHealthy ? 'ok' : 'degraded', @@ -203,4 +174,338 @@ export class HealthController { getStats() { return this.healthHistory.getAllStats(); } + + @Get('dashboard') + @HttpCode(HttpStatus.OK) + @Header('Content-Type', 'text/html') + @ApiOperation({ + summary: 'Get health dashboard UI', + description: 'Renders a beautiful premium HTML/CSS dashboard of service health status', + }) + async getDashboard() { + const detailed = await this.detailed(); + const stats = this.healthHistory.getAllStats(); + + // Compute some metrics for System Health indicator + const systemInfo = detailed.checks.system || {}; + const processMem = systemInfo.processMemory || { rss: '0 MB', heapUsed: '0 MB' }; + const sysMem = systemInfo.systemMemory || { total: '0 GB', free: '0 GB', utilizationPercentage: '0%' }; + const cpuInfo = systemInfo.cpu || { loadAverage1m: '0.00', cores: 1 }; + + const services = Object.keys(detailed.checks).filter(s => s !== 'system'); + + const serviceCards = services.map(name => { + const check = detailed.checks[name] || {}; + const isUp = check.status === 'up' || check.status === 'healthy' || (check.status !== 'down' && !check.error); + const resTime = check.responseTime || 'N/A'; + const svcStats = stats[name] || { uptime: '100%', avgResponseTime: '0ms' }; + const statusClass = isUp ? 'status-ok' : 'status-err'; + const badge = isUp ? 'UP' : 'DOWN'; + + let extraDetails = ''; + if (!isUp) { + extraDetails = `
${check.error || check.message || 'Unknown connection error'}
`; + } else { + extraDetails = `
Resp Time: ${resTime} | Uptime: ${svcStats.uptime}
`; + } + + return ` +
+
+ ${name.toUpperCase().replace('-', ' ')} + ${badge} +
+
+
+ Avg response: + ${svcStats.avgResponseTime || resTime} +
+ ${extraDetails} +
+
+ `; + }).join(''); + + const overallStatus = detailed.status === 'ok' ? 'ALL SYSTEMS OPERATIONAL' : 'DEGRADED PERFORMANCE'; + const overallClass = detailed.status === 'ok' ? 'status-ok' : 'status-err'; + + return ` + + + + + + Nestera Systems Health + + + + + +
+
+
+

Nestera Health Dashboard

+

Last checked: ${new Date(detailed.timestamp).toLocaleString()} | Refreshes automatically

+
+
+ + ${overallStatus} +
+
+ +
+ ${serviceCards} +
+ +
+

System & Resource Metrics

+
+
+
PROCESS MEMORY (RSS)
+
${processMem.rss}
+
Heap Used: ${processMem.heapUsed}
+
+
+
SYSTEM MEMORY UTILIZATION
+
${sysMem.utilizationPercentage}
+
Free: ${sysMem.free} / Total: ${sysMem.total}
+
+
+
CPU LOAD AVERAGE (1M)
+
${cpuInfo.loadAverage1m}
+
Cores: ${cpuInfo.cores}
+
+
+
PROCESS UPTIME
+
${systemInfo.uptime || 'N/A'}
+
Response: ${detailed.responseTime}
+
+
+
+ + +
+ + + `; + } } diff --git a/backend/src/modules/health/health.module.ts b/backend/src/modules/health/health.module.ts index ac4351d00..fbecedbc2 100644 --- a/backend/src/modules/health/health.module.ts +++ b/backend/src/modules/health/health.module.ts @@ -6,6 +6,7 @@ import { TypeOrmHealthIndicator } from './indicators/typeorm.health'; import { IndexerHealthIndicator } from './indicators/indexer.health'; import { RpcHealthIndicator } from './indicators/rpc.health'; import { ConnectionPoolHealthIndicator } from './indicators/connection-pool.health'; +import { SystemHealthIndicator } from './indicators/system.health'; import { RedisHealthIndicator, EmailServiceHealthIndicator, @@ -30,6 +31,7 @@ import { DeadLetterEvent } from '../blockchain/entities/dead-letter-event.entity IndexerHealthIndicator, RpcHealthIndicator, ConnectionPoolHealthIndicator, + SystemHealthIndicator, RedisHealthIndicator, EmailServiceHealthIndicator, SorobanRpcHealthIndicator, diff --git a/backend/src/modules/health/indicators/external-services.health.ts b/backend/src/modules/health/indicators/external-services.health.ts index 6e68b6c5e..e49b375b2 100644 --- a/backend/src/modules/health/indicators/external-services.health.ts +++ b/backend/src/modules/health/indicators/external-services.health.ts @@ -10,6 +10,9 @@ interface ServiceHealth { error?: string; } +import * as net from 'net'; +import { URL } from 'url'; + @Injectable() export class RedisHealthIndicator extends HealthIndicator { private readonly logger = new Logger(RedisHealthIndicator.name); @@ -28,23 +31,68 @@ export class RedisHealthIndicator extends HealthIndicator { } const startTime = Date.now(); - try { - // Simple ping test - const response = await axios.get(redisUrl, { timeout: 5000 }); - const responseTime = Date.now() - startTime; + return new Promise((resolve) => { + let host = 'localhost'; + let port = 6379; + + try { + const parsed = new URL(redisUrl); + host = parsed.hostname || 'localhost'; + port = parsed.port ? parseInt(parsed.port, 10) : 6379; + } catch (e) { + const match = redisUrl.match(/(?:redis:\/\/)?([^:/]+)(?::(\d+))?/); + if (match) { + host = match[1]; + port = match[2] ? parseInt(match[2], 10) : 6379; + } + } + + const socket = new net.Socket(); + socket.setTimeout(3000); + + const cleanup = () => { + socket.removeAllListeners(); + socket.destroy(); + }; + + socket.connect(port, host, () => { + socket.write('PING\r\n'); + }); - return this.getStatus(key, true, { - responseTime: `${responseTime}ms`, + socket.on('data', (data) => { + cleanup(); + const duration = Date.now() - startTime; + resolve( + this.getStatus(key, true, { + responseTime: `${duration}ms`, + }) + ); }); - } catch (error) { - const responseTime = Date.now() - startTime; - this.logger.error(`Redis health check failed: ${error}`); - return this.getStatus(key, false, { - responseTime: `${responseTime}ms`, - error: error instanceof Error ? error.message : 'Unknown error', + socket.on('error', (err) => { + cleanup(); + const duration = Date.now() - startTime; + this.logger.error(`Redis health check failed to connect: ${err.message}`); + resolve( + this.getStatus(key, false, { + responseTime: `${duration}ms`, + error: err.message, + }) + ); }); - } + + socket.on('timeout', () => { + cleanup(); + const duration = Date.now() - startTime; + this.logger.error('Redis health check timed out'); + resolve( + this.getStatus(key, false, { + responseTime: `${duration}ms`, + error: 'Connection timeout', + }) + ); + }); + }); } } diff --git a/backend/src/modules/health/indicators/system.health.ts b/backend/src/modules/health/indicators/system.health.ts new file mode 100644 index 000000000..4aaacb6cd --- /dev/null +++ b/backend/src/modules/health/indicators/system.health.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus'; +import * as os from 'os'; + +@Injectable() +export class SystemHealthIndicator extends HealthIndicator { + async isHealthy(key: string): Promise { + const memoryUsage = process.memoryUsage(); + const freeMem = os.freemem(); + const totalMem = os.totalmem(); + const usedMem = totalMem - freeMem; + const cpuLoad = os.loadavg(); + const uptime = process.uptime(); + + const metrics = { + processMemory: { + rss: `${(memoryUsage.rss / 1024 / 1024).toFixed(2)} MB`, + heapTotal: `${(memoryUsage.heapTotal / 1024 / 1024).toFixed(2)} MB`, + heapUsed: `${(memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB`, + external: `${(memoryUsage.external / 1024 / 1024).toFixed(2)} MB`, + }, + systemMemory: { + total: `${(totalMem / 1024 / 1024 / 1024).toFixed(2)} GB`, + free: `${(freeMem / 1024 / 1024 / 1024).toFixed(2)} GB`, + used: `${(usedMem / 1024 / 1024 / 1024).toFixed(2)} GB`, + utilizationPercentage: `${((usedMem / totalMem) * 100).toFixed(2)}%`, + }, + cpu: { + loadAverage1m: cpuLoad[0].toFixed(2), + loadAverage5m: cpuLoad[1].toFixed(2), + loadAverage15m: cpuLoad[2].toFixed(2), + cores: os.cpus().length, + }, + uptime: `${uptime.toFixed(0)}s`, + }; + + const isHealthy = (usedMem / totalMem) < 0.95; + + return this.getStatus(key, isHealthy, metrics); + } +} diff --git a/backend/src/modules/savings/entities/user-subscription.entity.ts b/backend/src/modules/savings/entities/user-subscription.entity.ts index a95cd0095..0053c4940 100644 --- a/backend/src/modules/savings/entities/user-subscription.entity.ts +++ b/backend/src/modules/savings/entities/user-subscription.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, + Index, } from 'typeorm'; import { SavingsProduct } from './savings-product.entity'; @@ -20,15 +21,18 @@ export class UserSubscription { @PrimaryGeneratedColumn('uuid') id: string; + @Index() @Column('uuid') userId: string; + @Index() @Column('uuid') productId: string; @Column('decimal', { precision: 14, scale: 2 }) amount: number; + @Index() @Column({ type: 'enum', enum: SubscriptionStatus, diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index c194338e5..e2adb8961 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -262,7 +262,7 @@ export class SavingsService { .filter((s) => s.status === SubscriptionStatus.ACTIVE) .reduce((sum, s) => sum + Number(s.amount), 0) : 0; - const capacity = await this.getProductCapacitySnapshot(product.id); + const capacity = await this.getProductCapacitySnapshot(product.id, product); return { id: product.id, @@ -899,8 +899,9 @@ export class SavingsService { async getProductCapacitySnapshot( productId: string, + preFetchedProduct?: SavingsProduct, ): Promise { - const product = await this.findOneProduct(productId); + const product = preFetchedProduct || await this.findOneProduct(productId); const maxCapacity = product.maxCapacity != null ? Number(product.maxCapacity) @@ -927,15 +928,21 @@ export class SavingsService { } if (source === 'database') { - const total = await this.subscriptionRepository - .createQueryBuilder('subscription') - .select('COALESCE(SUM(subscription.amount), 0)', 'total') - .where('subscription.productId = :productId', { productId }) - .andWhere('subscription.status = :status', { - status: SubscriptionStatus.ACTIVE, - }) - .getRawOne<{ total: string }>(); - utilizedCapacity = Number(total?.total ?? 0); + if (product.subscriptions) { + utilizedCapacity = product.subscriptions + .filter((s) => s.status === SubscriptionStatus.ACTIVE) + .reduce((sum, s) => sum + Number(s.amount), 0); + } else { + const total = await this.subscriptionRepository + .createQueryBuilder('subscription') + .select('COALESCE(SUM(subscription.amount), 0)', 'total') + .where('subscription.productId = :productId', { productId: product.id }) + .andWhere('subscription.status = :status', { + status: SubscriptionStatus.ACTIVE, + }) + .getRawOne<{ total: string }>(); + utilizedCapacity = Number(total?.total ?? 0); + } } const availableCapacity =