Source

dashboard/dashboard-provider.ts

import { VerificationEventEmitter } from 'porkate-valid8';
import { MetricsCollector, MetricsSnapshot } from 'porkate-valid8';
import {
  VerificationEventType,
  EventData,
  VerificationEventData,
  ServiceType,
  VerificationStatus,
} from 'porkate-valid8';

export interface DashboardConfig {
  enableRealtime?: boolean;
  historySize?: number;
  metricsInterval?: number;
}

export interface RecentActivity {
  id: string;
  timestamp: Date;
  serviceType: ServiceType;
  adapter: string;
  status: VerificationStatus;
  duration?: number;
  error?: string;
}

export interface ServiceStatusSummary {
  service: ServiceType;
  total: number;
  success: number;
  failed: number;
  successRate: number;
  averageTime: number;
}

export interface AdapterHealthStatus {
  adapter: string;
  isHealthy: boolean;
  uptime: number;
  totalRequests: number;
  errorRate: number;
  lastActivity?: Date;
}

export interface DashboardData {
  summary: {
    totalVerifications: number;
    successfulVerifications: number;
    failedVerifications: number;
    successRate: number;
    averageResponseTime: number;
  };
  recentActivity: RecentActivity[];
  serviceStatus: ServiceStatusSummary[];
  adapterHealth: AdapterHealthStatus[];
  metrics: MetricsSnapshot;
  timeline: Array<{
    timestamp: Date;
    eventType: VerificationEventType;
    count: number;
  }>;
}

/**
 * Dashboard Data Provider - Provides data for Hangfire-style UI dashboard
 */
export class DashboardDataProvider {
  private readonly emitter: VerificationEventEmitter;
  private readonly metricsCollector: MetricsCollector;
  private readonly recentActivities: RecentActivity[] = [];
  private readonly maxActivities: number;
  private updateCallbacks: Set<(data: DashboardData) => void> = new Set();

  constructor(
    emitter: VerificationEventEmitter,
    config: DashboardConfig = {},
  ) {
    this.emitter = emitter;
    this.maxActivities = config.historySize || 100;

    // Initialize metrics collector
    this.metricsCollector = new MetricsCollector(emitter, {
      emitInterval: config.metricsInterval || 60000, // 1 minute
      maxResponseTimeSamples: 100,
    });

    // Start recording events
    this.emitter.startRecording();

    // Setup listeners
    this.setupListeners();
  }

  /**
   * Setup event listeners
   */
  private setupListeners(): void {
    // Listen to all verification events
    this.emitter.on('*', (eventType: VerificationEventType, data: EventData) => {
      if (this.isVerificationEvent(eventType)) {
        this.handleVerificationEvent(data as VerificationEventData);
      }

      // Notify subscribers
      this.notifySubscribers();
    });
  }

  /**
   * Check if event is a verification event
   */
  private isVerificationEvent(eventType: VerificationEventType): boolean {
    return (
      eventType.includes('verification') &&
      !eventType.includes('started') &&
      !eventType.includes('retry')
    );
  }

  /**
   * Handle verification events
   */
  private handleVerificationEvent(data: VerificationEventData): void {
    const activity: RecentActivity = {
      id: data.eventId,
      timestamp: data.timestamp,
      serviceType: data.serviceType,
      adapter: data.adapter,
      status: data.status,
      duration: data.duration,
      error: data.error?.message,
    };

    this.recentActivities.unshift(activity);

    // Maintain max size
    if (this.recentActivities.length > this.maxActivities) {
      this.recentActivities.pop();
    }
  }

  /**
   * Get complete dashboard data
   */
  getDashboardData(): DashboardData {
    const metrics = this.metricsCollector.getSnapshot();
    const summary = this.calculateSummary(metrics);
    const serviceStatus = this.calculateServiceStatus(metrics);
    const adapterHealth = this.calculateAdapterHealth(metrics);
    const timeline = this.calculateTimeline();

    return {
      summary,
      recentActivity: this.recentActivities,
      serviceStatus,
      adapterHealth,
      metrics,
      timeline,
    };
  }

  /**
   * Calculate summary statistics
   */
  private calculateSummary(metrics: MetricsSnapshot) {
    return {
      totalVerifications: metrics.global.totalRequests,
      successfulVerifications: metrics.global.totalSuccesses,
      failedVerifications: metrics.global.totalFailures,
      successRate: metrics.global.errorRate > 0 ? 1 - metrics.global.errorRate : 1,
      averageResponseTime: metrics.global.averageResponseTime,
    };
  }

  /**
   * Calculate service status
   */
  private calculateServiceStatus(metrics: MetricsSnapshot): ServiceStatusSummary[] {
    const serviceMap = new Map<ServiceType, ServiceStatusSummary>();

    Object.entries(metrics.adapters).forEach(([_adapterName, adapterMetrics]) => {
      Object.entries(adapterMetrics).forEach(([serviceType, serviceMetrics]) => {
        if (serviceType === 'overall') return;

        if (!serviceMap.has(serviceType as ServiceType)) {
          serviceMap.set(serviceType as ServiceType, {
            service: serviceType as ServiceType,
            total: 0,
            success: 0,
            failed: 0,
            successRate: 0,
            averageTime: 0,
          });
        }

        const summary = serviceMap.get(serviceType as ServiceType)!;
        summary.total += serviceMetrics.totalRequests;
        summary.success += serviceMetrics.successfulRequests;
        summary.failed += serviceMetrics.failedRequests;
        summary.averageTime =
          (summary.averageTime * (summary.total - serviceMetrics.totalRequests) +
            serviceMetrics.averageResponseTime * serviceMetrics.totalRequests) /
          summary.total;
      });
    });

    // Calculate success rates
    serviceMap.forEach((summary) => {
      summary.successRate = summary.total > 0 ? summary.success / summary.total : 0;
    });

    return Array.from(serviceMap.values()).sort((a, b) => b.total - a.total);
  }

  /**
   * Calculate adapter health
   */
  private calculateAdapterHealth(metrics: MetricsSnapshot): AdapterHealthStatus[] {
    return Object.entries(metrics.adapters).map(([adapter, adapterMetrics]) => {
      const overall = adapterMetrics.overall;
      return {
        adapter,
        isHealthy: overall.errorRate < 0.1, // Less than 10% error rate
        uptime: metrics.global.uptime,
        totalRequests: overall.totalRequests,
        errorRate: overall.errorRate,
        lastActivity: overall.lastRequest,
      };
    });
  }

  /**
   * Calculate timeline data
   */
  private calculateTimeline(): Array<{
    timestamp: Date;
    eventType: VerificationEventType;
    count: number;
  }> {
    const history = this.emitter.getHistory();
    const timelineMap = new Map<string, Map<VerificationEventType, number>>();

    // Group by 5-minute intervals
    history.forEach((event) => {
      const intervalKey = this.getIntervalKey(event.timestamp, 5);

      if (!timelineMap.has(intervalKey)) {
        timelineMap.set(intervalKey, new Map());
      }

      const eventMap = timelineMap.get(intervalKey)!;
      const eventType = this.getEventTypeFromHistory(event);
      eventMap.set(eventType, (eventMap.get(eventType) || 0) + 1);
    });

    // Convert to array
    const timeline: Array<{
      timestamp: Date;
      eventType: VerificationEventType;
      count: number;
    }> = [];

    timelineMap.forEach((eventMap, intervalKey) => {
      const timestamp = new Date(intervalKey);
      eventMap.forEach((count, eventType) => {
        timeline.push({ timestamp, eventType, count });
      });
    });

    return timeline.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, 50);
  }

  /**
   * Get interval key for grouping
   */
  private getIntervalKey(date: Date, minutes: number): string {
    const ms = minutes * 60 * 1000;
    const roundedTime = Math.floor(date.getTime() / ms) * ms;
    return new Date(roundedTime).toISOString();
  }

  /**
   * Get event type from history data
   */
  private getEventTypeFromHistory(data: EventData): VerificationEventType {
    if ('serviceType' in data) {
      return VerificationEventType.VERIFICATION_COMPLETED;
    }
    return VerificationEventType.VERIFICATION_STARTED;
  }

  /**
   * Subscribe to dashboard updates
   */
  subscribe(callback: (data: DashboardData) => void): () => void {
    this.updateCallbacks.add(callback);

    // Return unsubscribe function
    return () => {
      this.updateCallbacks.delete(callback);
    };
  }

  /**
   * Notify all subscribers
   */
  private notifySubscribers(): void {
    const data = this.getDashboardData();
    this.updateCallbacks.forEach((callback) => {
      try {
        callback(data);
      } catch (error) {
        console.error('Error in dashboard subscriber:', error);
      }
    });
  }

  /**
   * Get recent activities
   */
  getRecentActivities(limit: number = 20): RecentActivity[] {
    return this.recentActivities.slice(0, limit);
  }

  /**
   * Get activities by service type
   */
  getActivitiesByService(serviceType: ServiceType, limit: number = 20): RecentActivity[] {
    return this.recentActivities
      .filter((activity) => activity.serviceType === serviceType)
      .slice(0, limit);
  }

  /**
   * Get activities by adapter
   */
  getActivitiesByAdapter(adapter: string, limit: number = 20): RecentActivity[] {
    return this.recentActivities
      .filter((activity) => activity.adapter === adapter)
      .slice(0, limit);
  }

  /**
   * Get activities by status
   */
  getActivitiesByStatus(status: VerificationStatus, limit: number = 20): RecentActivity[] {
    return this.recentActivities
      .filter((activity) => activity.status === status)
      .slice(0, limit);
  }

  /**
   * Clear all data
   */
  clear(): void {
    this.recentActivities.length = 0;
    this.metricsCollector.reset();
    this.emitter.clearHistory();
  }

  /**
   * Cleanup
   */
  destroy(): void {
    this.updateCallbacks.clear();
    this.metricsCollector.destroy();
  }
}