diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 6519d2d..45e6b2f 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -4,6 +4,7 @@ import { Admin } from './entities/admin.entity'; import { Incident } from './entities/incident.entity'; import { ModerationReport } from './entities/moderation-report.entity'; import { AuditLog } from '../audit/entities/audit-log.entity'; +import { Claim } from '../claims/entities/claim.entity'; import { AdminService } from './admin.service'; import { AdminController } from './admin.controller'; import { ModerationService } from './moderation/moderation.service'; @@ -14,9 +15,18 @@ import { DashboardService } from './dashboard/dashboard.service'; import { DashboardController } from './dashboard/dashboard.controller'; import { RolesGuard } from './guards/roles.guard'; import { AdminGuard } from './guards/admin.guard'; +import { JobsModule } from '../jobs/jobs.module'; +import { NotificationModule } from '../notifications/notification.module'; +import { RedisModule } from '../redis/redis.module'; +import { MetricsModule } from '../metrics/metrics.module'; + @Module({ imports: [ - TypeOrmModule.forFeature([Admin, Incident, ModerationReport, AuditLog]), + TypeOrmModule.forFeature([Admin, Incident, ModerationReport, AuditLog, Claim]), + JobsModule, + NotificationModule, + RedisModule, + MetricsModule, ], controllers: [ AdminController, diff --git a/src/admin/dashboard/dashboard.controller.ts b/src/admin/dashboard/dashboard.controller.ts index c58ee1d..ba3b5e5 100644 --- a/src/admin/dashboard/dashboard.controller.ts +++ b/src/admin/dashboard/dashboard.controller.ts @@ -5,6 +5,21 @@ import { AdminGuard } from '../guards/admin.guard'; import { RolesGuard } from '../guards/roles.guard'; import { Roles } from '../decorators/roles.decorator'; import { AdminRole } from '../entities/admin.entity'; +import { + DashboardOverviewDto, + HealthStatusDto, + OperationalDashboardDto, + InfrastructureHealthDto, + QueueMetricsDto, + WorkerStatusDto, + ApiMetricsDto, + CacheStatisticsDto, + DatabaseMetricsDto, + NotificationMetricsDto, + WebhookMetricsDto, + BackgroundJobsDto, + ProtocolActivityDto, +} from '../dto/dashboard.dto'; @ApiTags('admin / dashboard') @ApiBearerAuth() @@ -16,7 +31,7 @@ export class DashboardController { @Get('overview') @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR) @ApiOperation({ summary: 'Get main dashboard overview' }) - @ApiResponse({ status: 200, description: 'Dashboard overview data' }) + @ApiResponse({ status: 200, description: 'Dashboard overview data', type: DashboardOverviewDto }) async getOverview() { return this.dashboardService.getOverview(); } @@ -24,10 +39,99 @@ export class DashboardController { @Get('health') @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.SECURITY_ANALYST, AdminRole.AUDITOR) @ApiOperation({ summary: 'Get protocol health status' }) + @ApiResponse({ status: 200, description: 'System health summary', type: HealthStatusDto }) async getHealth() { return this.dashboardService.getHealth(); } + @Get('operational-summary') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get aggregated operational dashboard summary' }) + @ApiResponse({ status: 200, description: 'Aggregated operational dashboard data', type: OperationalDashboardDto }) + async getOperationalSummary() { + return this.dashboardService.getOperationalSummary(); + } + + @Get('infrastructure') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get infrastructure health and queue status' }) + @ApiResponse({ status: 200, description: 'Infrastructure health summary', type: InfrastructureHealthDto }) + async getInfrastructureHealth() { + return this.dashboardService.getInfrastructureHealth(); + } + + @Get('queues') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get queue statistics across background workers' }) + @ApiResponse({ status: 200, description: 'Queue metrics', type: QueueMetricsDto }) + async getQueueStatistics() { + return this.dashboardService.getQueueStatistics(); + } + + @Get('workers') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get worker health and utilisation' }) + @ApiResponse({ status: 200, description: 'Worker status summary', type: WorkerStatusDto }) + async getWorkerStatus() { + return this.dashboardService.getWorkerStatus(); + } + + @Get('api-metrics') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get API traffic and latency metrics' }) + @ApiResponse({ status: 200, description: 'API metrics summary', type: ApiMetricsDto }) + async getApiMetrics() { + return this.dashboardService.getApiMetrics(); + } + + @Get('cache') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get cache and Redis status' }) + @ApiResponse({ status: 200, description: 'Cache summary', type: CacheStatisticsDto }) + async getCacheStatistics() { + return this.dashboardService.getCacheStatistics(); + } + + @Get('database') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get database health and table statistics' }) + @ApiResponse({ status: 200, description: 'Database metrics', type: DatabaseMetricsDto }) + async getDatabaseMetrics() { + return this.dashboardService.getDatabaseMetrics(); + } + + @Get('notifications') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get notification queue and delivery metrics' }) + @ApiResponse({ status: 200, description: 'Notification metrics', type: NotificationMetricsDto }) + async getNotificationMetrics() { + return this.dashboardService.getNotificationMetrics(); + } + + @Get('webhooks') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get webhook delivery metrics' }) + @ApiResponse({ status: 200, description: 'Webhook metrics', type: WebhookMetricsDto }) + async getWebhookMetrics() { + return this.dashboardService.getWebhookMetrics(); + } + + @Get('jobs') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get background job queue overview' }) + @ApiResponse({ status: 200, description: 'Background job overview', type: BackgroundJobsDto }) + async getBackgroundJobs() { + return this.dashboardService.getBackgroundJobs(); + } + + @Get('protocol') + @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.AUDITOR, AdminRole.SECURITY_ANALYST) + @ApiOperation({ summary: 'Get protocol activity summary' }) + @ApiResponse({ status: 200, description: 'Protocol activity summary', type: ProtocolActivityDto }) + async getProtocolActivity() { + return this.dashboardService.getProtocolActivity(); + } + @Get('moderation') @Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.MODERATOR, AdminRole.AUDITOR) @ApiOperation({ summary: 'Get moderation workload statistics' }) diff --git a/src/admin/dashboard/dashboard.service.spec.ts b/src/admin/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..b306885 --- /dev/null +++ b/src/admin/dashboard/dashboard.service.spec.ts @@ -0,0 +1,78 @@ +import { DashboardService } from './dashboard.service'; + +describe('DashboardService', () => { + let service: DashboardService; + let adminRepo: any; + let incidentRepo: any; + let reportRepo: any; + let auditLogRepo: any; + let jobsService: any; + let notificationService: any; + let redisService: any; + let metricsService: any; + + beforeEach(() => { + adminRepo = { + count: jest.fn(), + query: jest.fn(), + }; + incidentRepo = { + count: jest.fn(), + find: jest.fn(), + }; + reportRepo = { + count: jest.fn(), + find: jest.fn(), + }; + auditLogRepo = { + createQueryBuilder: jest.fn(), + }; + jobsService = { + getAllQueueMetrics: jest.fn(), + }; + notificationService = { + getMetrics: jest.fn(), + }; + redisService = { + isHealthy: jest.fn(), + getStatus: jest.fn(), + }; + metricsService = { + getSummary: jest.fn(), + }; + + service = new DashboardService( + adminRepo, + incidentRepo, + reportRepo, + auditLogRepo, + jobsService, + notificationService, + redisService, + metricsService, + ); + }); + + it('should aggregate operational summary data from backend services', async () => { + adminRepo.count.mockResolvedValueOnce(4).mockResolvedValueOnce(2); + incidentRepo.count.mockResolvedValueOnce(3).mockResolvedValueOnce(1); + reportRepo.count.mockResolvedValueOnce(2).mockResolvedValueOnce(1).mockResolvedValueOnce(3); + reportRepo.find.mockResolvedValue([{ createdAt: new Date(), resolvedAt: new Date() }]); + jobsService.getAllQueueMetrics.mockResolvedValue([ + { name: 'default', waiting: 2, active: 1, completed: 10, failed: 0, delayed: 1, paused: false }, + ]); + notificationService.getMetrics.mockResolvedValue({ queued: 5, delivered: 4, failed: 1, queueDepth: 2 }); + redisService.isHealthy.mockResolvedValue(true); + redisService.getStatus.mockReturnValue({ connected: true, enabled: true }); + metricsService.getSummary.mockResolvedValue({ totalRequests: 120, errorCount: 3, averageLatencyMs: 40 }); + + const summary = await service.getOperationalSummary(); + + expect(summary.system.status).toBe('healthy'); + expect(summary.infrastructure.database.status).toBe('healthy'); + expect(summary.queues.totalWaiting).toBe(2); + expect(summary.notifications.delivered).toBe(4); + expect(summary.api.totalRequests).toBe(120); + expect(summary.protocol.activeClaims).toBe(3); + }); +}); diff --git a/src/admin/dashboard/dashboard.service.ts b/src/admin/dashboard/dashboard.service.ts index 8a5d2d1..f9aa444 100644 --- a/src/admin/dashboard/dashboard.service.ts +++ b/src/admin/dashboard/dashboard.service.ts @@ -3,8 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Admin, AdminRole } from '../entities/admin.entity'; import { Incident, IncidentStatus, IncidentSeverity } from '../entities/incident.entity'; -import { ModerationReport, ReportStatus, ReportType } from '../entities/moderation-report.entity'; +import { ModerationReport, ReportStatus } from '../entities/moderation-report.entity'; import { AuditLog } from '../../audit/entities/audit-log.entity'; +import { Claim } from '../../claims/entities/claim.entity'; +import { JobsService } from '../../jobs/jobs.service'; +import { NotificationService } from '../../notifications/services/notification.service'; +import { RedisService } from '../../redis/redis.service'; +import { MetricsService } from '../../metrics/metrics.service'; @Injectable() export class DashboardService { @@ -19,6 +24,12 @@ export class DashboardService { private readonly reportRepo: Repository, @InjectRepository(AuditLog) private readonly auditLogRepo: Repository, + @InjectRepository(Claim) + private readonly claimRepo: Repository, + private readonly jobsService: JobsService, + private readonly notificationService: NotificationService, + private readonly redisService: RedisService, + private readonly metricsService: MetricsService, ) {} async getOverview(): Promise<{ @@ -64,7 +75,7 @@ export class DashboardService { today.setHours(0, 0, 0, 0); const resolvedToday = await this.incidentRepo.count({ - where: { resolvedAt: today.toISOString() as any }, + where: { resolvedAt: today as any }, }); const reports = await this.reportRepo.find({ @@ -179,23 +190,47 @@ export class DashboardService { uptime: number; timestamp: string; database: string; + redis: string; + queues: string; }> { + let database = 'disconnected'; + let redis = 'disconnected'; + let queues = 'degraded'; + let status = 'degraded'; + try { await this.adminRepo.query('SELECT 1'); - return { - status: 'healthy', - uptime: process.uptime(), - timestamp: new Date().toISOString(), - database: 'connected', - }; + database = 'connected'; } catch { - return { - status: 'degraded', - uptime: process.uptime(), - timestamp: new Date().toISOString(), - database: 'disconnected', - }; + database = 'disconnected'; + } + + try { + const redisHealthy = await this.redisService.isHealthy(); + redis = redisHealthy ? 'connected' : 'disconnected'; + } catch { + redis = 'disconnected'; + } + + try { + const queueMetrics = await this.jobsService.getAllQueueMetrics(); + queues = queueMetrics.some((queue) => queue.failed > 0) ? 'degraded' : 'healthy'; + } catch { + queues = 'degraded'; + } + + if (database === 'connected' && redis === 'connected' && queues === 'healthy') { + status = 'healthy'; } + + return { + status, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + database, + redis, + queues, + }; } async getAuditSummary(days = 7): Promise> { @@ -217,4 +252,183 @@ export class DashboardService { return summary; } + + async getOperationalSummary() { + const [overview, queueMetrics, notificationMetrics, webhookMetrics, metricsSummary, redisStatus] = await Promise.all([ + this.getOverview(), + this.jobsService.getAllQueueMetrics(), + this.notificationService.getMetrics(), + this.notificationService.getWebhookMetrics(), + this.metricsService.getSummary(), + this.redisService.getStatus(), + ]); + + const totalWaiting = queueMetrics.reduce((sum, queue) => sum + queue.waiting, 0); + const totalActive = queueMetrics.reduce((sum, queue) => sum + queue.active, 0); + const totalFailed = queueMetrics.reduce((sum, queue) => sum + queue.failed, 0); + const totalCompleted = queueMetrics.reduce((sum, queue) => sum + queue.completed, 0); + const totalDelayed = queueMetrics.reduce((sum, queue) => sum + queue.delayed, 0); + + const databaseHealthy = await this.checkDatabaseHealth(); + const redisHealthy = await this.redisService.isHealthy(); + const systemStatus = databaseHealthy && redisHealthy && totalFailed === 0 ? 'healthy' : 'degraded'; + + const totalClaims = await this.claimRepo.count(); + const activeClaims = await this.claimRepo.count({ where: { finalized: false } }); + const finalizedClaims = await this.claimRepo.count({ where: { finalized: true } }); + const pendingClaims = Math.max(activeClaims - finalizedClaims, 0); + + return { + system: { + status: systemStatus, + uptimeSeconds: Math.round(process.uptime()), + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || 'dev', + }, + admin: { + totalAdmins: overview.totalAdmins, + activeAdmins: await this.adminRepo.count({ where: { isActive: true } }), + pendingReports: overview.pendingReports, + openIncidents: overview.openIncidents, + criticalIncidents: overview.criticalIncidents, + moderationBacklog: overview.moderationBacklog, + }, + infrastructure: { + database: { + status: databaseHealthy ? 'healthy' : 'degraded', + latencyMs: 0, + }, + redis: { + status: redisHealthy ? 'healthy' : 'degraded', + connected: redisStatus.connected, + enabled: redisStatus.enabled, + }, + queues: { + status: totalFailed > 0 ? 'degraded' : 'healthy', + totalQueues: queueMetrics.length, + totalWaiting, + totalActive, + totalFailed, + totalCompleted, + totalDelayed, + }, + workers: { + status: totalActive > 0 ? 'active' : 'idle', + activeWorkers: totalActive, + totalWorkers: Math.max(queueMetrics.length, 1), + queueDepth: totalWaiting + totalActive + totalDelayed, + }, + }, + api: { + totalRequests: metricsSummary.totalRequests, + errorCount: metricsSummary.errorCount, + averageLatencyMs: metricsSummary.averageLatencyMs, + statusCodes: metricsSummary.statusCodes, + requestsByRoute: metricsSummary.requestsByRoute, + }, + notifications: { + queued: notificationMetrics.queued, + delivered: notificationMetrics.delivered, + failed: notificationMetrics.failed, + queueDepth: notificationMetrics.queueDepth, + webhooks: webhookMetrics, + }, + jobs: { + totalQueues: queueMetrics.length, + failedJobs: totalFailed, + queues: queueMetrics, + }, + protocol: { + totalClaims, + activeClaims, + finalizedClaims, + pendingClaims, + resolvedClaims: totalClaims - pendingClaims - finalizedClaims, + }, + }; + } + + async getInfrastructureHealth() { + const summary = await this.getOperationalSummary(); + return { + status: summary.system.status, + timestamp: summary.system.timestamp, + database: summary.infrastructure.database, + redis: summary.infrastructure.redis, + queues: summary.infrastructure.queues, + workers: summary.infrastructure.workers, + }; + } + + async getQueueStatistics() { + const summary = await this.getOperationalSummary(); + return summary.infrastructure.queues; + } + + async getWorkerStatus() { + const summary = await this.getOperationalSummary(); + return summary.infrastructure.workers; + } + + async getApiMetrics() { + const summary = await this.getOperationalSummary(); + return summary.api; + } + + async getCacheStatistics() { + const summary = await this.getOperationalSummary(); + return { + status: summary.infrastructure.redis.status, + connected: summary.infrastructure.redis.connected, + enabled: summary.infrastructure.redis.enabled, + queueDepth: summary.infrastructure.workers.queueDepth, + }; + } + + async getDatabaseMetrics() { + const [totalAdmins, totalClaims, totalIncidents, totalReports] = await Promise.all([ + this.adminRepo.count(), + this.claimRepo.count(), + this.incidentRepo.count(), + this.reportRepo.count(), + ]); + + return { + status: (await this.checkDatabaseHealth()) ? 'healthy' : 'degraded', + totalAdmins, + totalClaims, + totalIncidents, + totalReports, + timestamp: new Date().toISOString(), + }; + } + + async getNotificationMetrics() { + const summary = await this.getOperationalSummary(); + return summary.notifications; + } + + async getWebhookMetrics() { + const summary = await this.getOperationalSummary(); + return summary.notifications.webhooks; + } + + async getBackgroundJobs() { + const summary = await this.getOperationalSummary(); + return summary.jobs; + } + + async getProtocolActivity() { + const summary = await this.getOperationalSummary(); + return summary.protocol; + } + + private async checkDatabaseHealth(): Promise { + try { + await this.adminRepo.query('SELECT 1'); + return true; + } catch { + return false; + } + } } diff --git a/src/admin/dto/dashboard.dto.ts b/src/admin/dto/dashboard.dto.ts index af4b5b6..054c1cd 100644 --- a/src/admin/dto/dashboard.dto.ts +++ b/src/admin/dto/dashboard.dto.ts @@ -17,7 +17,7 @@ export class HealthStatusDto { redis: string; @ApiPropertyOptional() - blockchain: string; + queues: string; } export class DashboardOverviewDto { @@ -127,6 +127,196 @@ export class MonitoringMetricsDto { timestamp: string; } +export class OperationalDashboardDto { + @ApiProperty() + system: Record; + + @ApiProperty() + admin: Record; + + @ApiProperty() + infrastructure: Record; + + @ApiProperty() + api: Record; + + @ApiProperty() + notifications: Record; + + @ApiProperty() + jobs: Record; + + @ApiProperty() + protocol: Record; +} + +export class InfrastructureHealthDto { + @ApiProperty() + status: string; + + @ApiProperty() + timestamp: string; + + @ApiProperty() + database: Record; + + @ApiProperty() + redis: Record; + + @ApiProperty() + queues: Record; + + @ApiProperty() + workers: Record; +} + +export class QueueMetricsDto { + @ApiProperty() + status: string; + + @ApiProperty() + totalQueues: number; + + @ApiProperty() + totalWaiting: number; + + @ApiProperty() + totalActive: number; + + @ApiProperty() + totalFailed: number; + + @ApiProperty() + totalCompleted: number; + + @ApiProperty() + totalDelayed: number; +} + +export class WorkerStatusDto { + @ApiProperty() + status: string; + + @ApiProperty() + activeWorkers: number; + + @ApiProperty() + totalWorkers: number; + + @ApiProperty() + queueDepth: number; +} + +export class ApiMetricsDto { + @ApiProperty() + totalRequests: number; + + @ApiProperty() + errorCount: number; + + @ApiProperty() + averageLatencyMs: number; + + @ApiProperty() + statusCodes: Record; + + @ApiProperty() + requestsByRoute: Record; +} + +export class CacheStatisticsDto { + @ApiProperty() + status: string; + + @ApiProperty() + connected: boolean; + + @ApiProperty() + enabled: boolean; + + @ApiProperty() + queueDepth: number; +} + +export class DatabaseMetricsDto { + @ApiProperty() + status: string; + + @ApiProperty() + totalAdmins: number; + + @ApiProperty() + totalClaims: number; + + @ApiProperty() + totalIncidents: number; + + @ApiProperty() + totalReports: number; + + @ApiProperty() + timestamp: string; +} + +export class NotificationMetricsDto { + @ApiProperty() + queued: number; + + @ApiProperty() + delivered: number; + + @ApiProperty() + failed: number; + + @ApiProperty() + queueDepth: number; + + @ApiProperty() + webhooks: Record; +} + +export class WebhookMetricsDto { + @ApiProperty() + total: number; + + @ApiProperty() + delivered: number; + + @ApiProperty() + pending: number; + + @ApiProperty() + failed: number; +} + +export class BackgroundJobsDto { + @ApiProperty() + totalQueues: number; + + @ApiProperty() + failedJobs: number; + + @ApiProperty() + queues: Record[]; +} + +export class ProtocolActivityDto { + @ApiProperty() + totalClaims: number; + + @ApiProperty() + activeClaims: number; + + @ApiProperty() + finalizedClaims: number; + + @ApiProperty() + pendingClaims: number; + + @ApiProperty() + resolvedClaims: number; +} + export class PaginatedResponseDto { data: T[]; pagination: { diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index e1b31e4..ebd4e07 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -1,33 +1,57 @@ -import { Counter, Histogram, register } from "prom-client"; +import { Counter, Histogram, register } from 'prom-client'; export class MetricsService { private readonly requestCounter: Counter; private readonly latencyHistogram: Histogram; + private totalRequests = 0; + private errorCount = 0; + private totalLatencyMs = 0; + private latencySamples = 0; + private readonly requestsByRoute = new Map(); + private readonly statusCodes = new Map(); constructor() { this.requestCounter = new Counter({ - name: "http_requests_total", - help: "Total number of HTTP requests", - labelNames: ["method", "route", "status"], + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], }); this.latencyHistogram = new Histogram({ - name: "http_request_duration_seconds", - help: "Duration of HTTP requests in seconds", - labelNames: ["method", "route", "status"], + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], buckets: [0.1, 0.3, 1.5, 10], }); } incrementRequest(method: string, route: string, status: string) { this.requestCounter.inc({ method, route, status }); + this.totalRequests += 1; + this.requestsByRoute.set(route, (this.requestsByRoute.get(route) || 0) + 1); + this.statusCodes.set(status, (this.statusCodes.get(status) || 0) + 1); + if (Number(status) >= 500) { + this.errorCount += 1; + } } observeLatency(method: string, route: string, status: string, duration: number) { this.latencyHistogram.observe({ method, route, status }, duration); + this.totalLatencyMs += duration * 1000; + this.latencySamples += 1; } async getMetrics() { return await register.metrics(); } + + getSummary() { + return { + totalRequests: this.totalRequests, + errorCount: this.errorCount, + averageLatencyMs: this.latencySamples > 0 ? Math.round(this.totalLatencyMs / this.latencySamples) : 0, + requestsByRoute: Object.fromEntries(this.requestsByRoute), + statusCodes: Object.fromEntries(this.statusCodes), + }; + } } diff --git a/src/notifications/services/notification.service.ts b/src/notifications/services/notification.service.ts index e52ae97..c00127c 100644 --- a/src/notifications/services/notification.service.ts +++ b/src/notifications/services/notification.service.ts @@ -376,6 +376,27 @@ export class NotificationService implements OnModuleInit { }; } + async getWebhookMetrics(): Promise<{ + total: number; + delivered: number; + pending: number; + failed: number; + }> { + const [total, delivered, pending, failed] = await Promise.all([ + this.deliveryRepo.count({ where: { channel: DeliveryChannel.WEBHOOK } }), + this.deliveryRepo.count({ where: { channel: DeliveryChannel.WEBHOOK, status: DeliveryStatus.DELIVERED } }), + this.deliveryRepo.count({ where: { channel: DeliveryChannel.WEBHOOK, status: DeliveryStatus.PENDING } }), + this.deliveryRepo.count({ where: { channel: DeliveryChannel.WEBHOOK, status: DeliveryStatus.FAILED } }), + ]); + + return { + total, + delivered, + pending, + failed, + }; + } + private async resolveChannels( userId: string, type: NotificationType,