From 8fabae67e239d563c2d55f183bf1cf63de755c85 Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 10 Jan 2026 06:50:44 -0800 Subject: [PATCH] =?UTF-8?q?feat(@packages/@providers/auth-provider):=20?= =?UTF-8?q?=E2=9C=A8=20add=20login=20and=20register=20functionalities=20fo?= =?UTF-8?q?r=20SSO=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth-provider/src/AuthProvider.tsx | 82 ++++++ .../@providers/auth-provider/src/index.ts | 1 + .../src/features/content/pages/AboutPage.tsx | 4 +- .../features/content/pages/FeaturesPage.tsx | 8 +- .../src/features/content/pages/SafetyPage.tsx | 4 +- .../src/processors/system-events.processor.ts | 184 +++++++++++++ .../src/storage/metrics-storage.service.ts | 259 ++++++++++++++++++ 7 files changed, 534 insertions(+), 8 deletions(-) diff --git a/@packages/@providers/auth-provider/src/AuthProvider.tsx b/@packages/@providers/auth-provider/src/AuthProvider.tsx index babe1c0cb..79fc317e8 100644 --- a/@packages/@providers/auth-provider/src/AuthProvider.tsx +++ b/@packages/@providers/auth-provider/src/AuthProvider.tsx @@ -194,6 +194,86 @@ export function AuthProvider({ [queryClient] ); + const loginWithCredentials = useCallback( + async (email: string, password: string) => { + if (!ssoClientRef.current) { + throw new Error('SSO client not initialized'); + } + + setAuthState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const result = await ssoClientRef.current.loginWithCredentials(email, password); + + if (result.mfaRequired) { + // MFA will be handled by the SSOClient's onMfaRequired callback + setAuthState((prev) => ({ ...prev, isLoading: false })); + return; + } + + setAuthState({ + user: result.user as User, + isLoading: false, + isAuthenticated: true, + error: null, + }); + queryClient.setQueryData(['auth', 'me'], result.user); + + authEvents.broadcast({ + type: 'login', + timestamp: Date.now(), + }); + } catch (error) { + setAuthState((prev) => ({ + ...prev, + isLoading: false, + error: error as Error, + })); + throw error; + } + }, + [queryClient] + ); + + const registerWithCredentials = useCallback( + async (data: DirectRegisterData) => { + if (!ssoClientRef.current) { + throw new Error('SSO client not initialized'); + } + + setAuthState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const result = await ssoClientRef.current.registerWithCredentials( + data.email, + data.username, + data.password, + data.role + ); + setAuthState({ + user: result.user as User, + isLoading: false, + isAuthenticated: true, + error: null, + }); + queryClient.setQueryData(['auth', 'me'], result.user); + + authEvents.broadcast({ + type: 'login', + timestamp: Date.now(), + }); + } catch (error) { + setAuthState((prev) => ({ + ...prev, + isLoading: false, + error: error as Error, + })); + throw error; + } + }, + [queryClient] + ); + const logout = useCallback(async () => { if (!ssoClientRef.current) { throw new Error('SSO client not initialized'); @@ -248,6 +328,8 @@ export function AuthProvider({ ...authState, login, register, + loginWithCredentials, + registerWithCredentials, logout, refreshAuth, }; diff --git a/@packages/@providers/auth-provider/src/index.ts b/@packages/@providers/auth-provider/src/index.ts index 19796735f..6a5e82cf7 100644 --- a/@packages/@providers/auth-provider/src/index.ts +++ b/@packages/@providers/auth-provider/src/index.ts @@ -7,6 +7,7 @@ export type { UserRole, LoginCredentials, RegisterData, + DirectRegisterData, AuthResponse, AuthState, AuthContextValue, diff --git a/features/marketplace/frontend-public/src/features/content/pages/AboutPage.tsx b/features/marketplace/frontend-public/src/features/content/pages/AboutPage.tsx index 41667f355..f0780758d 100644 --- a/features/marketplace/frontend-public/src/features/content/pages/AboutPage.tsx +++ b/features/marketplace/frontend-public/src/features/content/pages/AboutPage.tsx @@ -15,12 +15,12 @@ const Container = styled.div` const Hero = styled.section` text-align: center; - padding: ${(props) => props.theme.spacing.xxl} 0; + padding: ${(props) => props.theme.spacing['2xl']} 0; `; const Title = styled.h1` font-family: ${(props) => props.theme.typography.fontFamily.heading}; - font-size: ${(props) => props.theme.typography.fontSize.xxl}; + font-size: ${(props) => props.theme.typography.fontSize['2xl']}; font-weight: ${(props) => props.theme.typography.fontWeight.bold}; color: ${(props) => props.theme.colors.text.primary}; margin: 0 0 ${(props) => props.theme.spacing.md}; diff --git a/features/marketplace/frontend-public/src/features/content/pages/FeaturesPage.tsx b/features/marketplace/frontend-public/src/features/content/pages/FeaturesPage.tsx index a17680518..720b7be3a 100644 --- a/features/marketplace/frontend-public/src/features/content/pages/FeaturesPage.tsx +++ b/features/marketplace/frontend-public/src/features/content/pages/FeaturesPage.tsx @@ -15,12 +15,12 @@ const Container = styled.div` const Hero = styled.section` text-align: center; - padding: ${(props) => props.theme.spacing.xxl} 0; + padding: ${(props) => props.theme.spacing['2xl']} 0; `; const Title = styled.h1` font-family: ${(props) => props.theme.typography.fontFamily.heading}; - font-size: ${(props) => props.theme.typography.fontSize.xxl}; + font-size: ${(props) => props.theme.typography.fontSize['2xl']}; font-weight: ${(props) => props.theme.typography.fontWeight.bold}; color: ${(props) => props.theme.colors.text.primary}; margin: 0 0 ${(props) => props.theme.spacing.md}; @@ -39,7 +39,7 @@ const FeaturesGrid = styled.div` display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: ${(props) => props.theme.spacing.lg}; - margin-top: ${(props) => props.theme.spacing.xxl}; + margin-top: ${(props) => props.theme.spacing['2xl']}; `; const FeatureCard = styled.div` @@ -91,7 +91,7 @@ const FeatureDescription = styled.p` const SectionDivider = styled.div` border-top: 1px solid ${(props) => props.theme.colors.border}; - margin: ${(props) => props.theme.spacing.xxxl} 0; + margin: ${(props) => props.theme.spacing['3xl']} 0; `; const SectionTitle = styled.h2` diff --git a/features/marketplace/frontend-public/src/features/content/pages/SafetyPage.tsx b/features/marketplace/frontend-public/src/features/content/pages/SafetyPage.tsx index 677061f67..f1f3c4216 100644 --- a/features/marketplace/frontend-public/src/features/content/pages/SafetyPage.tsx +++ b/features/marketplace/frontend-public/src/features/content/pages/SafetyPage.tsx @@ -16,12 +16,12 @@ const Container = styled.div` const Hero = styled.section` text-align: center; - padding: ${(props) => props.theme.spacing.xxl} 0; + padding: ${(props) => props.theme.spacing['2xl']} 0; `; const Title = styled.h1` font-family: ${(props) => props.theme.typography.fontFamily.heading}; - font-size: ${(props) => props.theme.typography.fontSize.xxl}; + font-size: ${(props) => props.theme.typography.fontSize['2xl']}; font-weight: ${(props) => props.theme.typography.fontWeight.bold}; color: ${(props) => props.theme.colors.text.primary}; margin: 0 0 ${(props) => props.theme.spacing.md}; diff --git a/features/status-dashboard/backend-api/src/processors/system-events.processor.ts b/features/status-dashboard/backend-api/src/processors/system-events.processor.ts index 4d37b2469..a4a74d10f 100644 --- a/features/status-dashboard/backend-api/src/processors/system-events.processor.ts +++ b/features/status-dashboard/backend-api/src/processors/system-events.processor.ts @@ -240,4 +240,188 @@ export class SystemEventsProcessor extends BaseDomainEventsProcessor { this.logger.debug(`Resolved alert ${alertId} for ${serviceName}`) } + + // =========================================================================== + // Service Discovery Event Handlers + // =========================================================================== + + /** + * Handle SERVICE_REGISTERED event. + * A new service instance has registered with the discovery system. + */ + private async handleServiceRegistered( + event: BaseDomainEvent, + ): Promise { + const { + serviceId, + featureId, + instanceId, + host, + port, + serviceType, + version, + workspace, + registeredAt, + } = event.payload + + this.logger.log( + `Service registered: ${serviceId} (instance: ${instanceId}) at ${host}:${port} - version ${version ?? 'unknown'}`, + ) + + // Store dynamic service registration + this.metricsStorage.recordDynamicService({ + serviceId, + featureId, + instanceId, + host, + port, + serviceType, + status: 'healthy', + version, + workspace, + registeredAt: new Date(registeredAt), + }) + + this.logger.debug( + `Recorded dynamic service: ${serviceId} (${instanceId}) from workspace ${workspace ?? 'local'}`, + ) + } + + /** + * Handle SERVICE_DEREGISTERED event. + * A service instance has been removed from the discovery system. + */ + private async handleServiceDeregistered( + event: BaseDomainEvent, + ): Promise { + const { serviceId, instanceId, reason, deregisteredAt } = event.payload + + this.logger.log( + `Service deregistered: ${serviceId} (instance: ${instanceId}) - reason: ${reason} at ${deregisteredAt}`, + ) + + // Remove from dynamic services + this.metricsStorage.removeDynamicService(instanceId) + + this.logger.debug(`Removed dynamic service instance: ${instanceId}`) + } + + /** + * Handle SERVICE_HEALTH_CHANGED event. + * A service instance's health status has changed. + */ + private async handleServiceHealthChanged( + event: BaseDomainEvent, + ): Promise { + const { + serviceId, + instanceId, + previousStatus, + newStatus, + responseTimeMs, + errorMessage, + checkedAt, + } = event.payload + + const logLevel = newStatus === 'healthy' ? 'log' : 'warn' + this.logger[logLevel]( + `Service health changed: ${serviceId} (${instanceId}) ${previousStatus} -> ${newStatus}` + + (errorMessage ? ` - ${errorMessage}` : '') + + (responseTimeMs ? ` (${responseTimeMs}ms)` : ''), + ) + + // Update dynamic service health + this.metricsStorage.updateDynamicServiceHealth(instanceId, { + status: newStatus, + responseTime: responseTimeMs, + error: errorMessage, + lastChecked: new Date(checkedAt), + }) + } + + /** + * Handle SERVICE_DRAINING event. + * A service instance is entering drain mode before shutdown. + */ + private async handleServiceDraining( + event: BaseDomainEvent, + ): Promise { + const { + serviceId, + instanceId, + gracePeriodMs, + activeConnections, + reason, + drainStartedAt, + expectedCompletionAt, + } = event.payload + + this.logger.warn( + `Service draining: ${serviceId} (${instanceId}) - reason: ${reason}, ` + + `${activeConnections} active connections, grace period: ${gracePeriodMs}ms, ` + + `expected completion: ${expectedCompletionAt}`, + ) + + // Update service status to draining + this.metricsStorage.updateDynamicServiceHealth(instanceId, { + status: 'draining', + drainReason: reason, + activeConnections, + drainStartedAt: new Date(drainStartedAt), + expectedCompletionAt: new Date(expectedCompletionAt), + }) + } + + /** + * Handle SERVICE_SCALED event. + * The number of instances for a service has changed. + */ + private async handleServiceScaled( + event: BaseDomainEvent, + ): Promise { + const { serviceId, previousCount, newCount, direction, reason, scaledAt } = + event.payload + + this.logger.log( + `Service scaled: ${serviceId} ${previousCount} -> ${newCount} (${direction}) - reason: ${reason} at ${scaledAt}`, + ) + + // Record scaling event + this.metricsStorage.recordScalingEvent({ + serviceId, + previousCount, + newCount, + direction, + reason, + scaledAt: new Date(scaledAt), + }) + } + + /** + * Handle SERVICE_METADATA_UPDATED event. + * Service metadata has been updated (e.g., response time, connections). + */ + private async handleServiceMetadataUpdated( + event: BaseDomainEvent, + ): Promise { + const { + serviceId, + instanceId, + updatedFields, + responseTimeMs, + activeConnections, + updatedAt, + } = event.payload + + this.logger.debug( + `Service metadata updated: ${serviceId} (${instanceId}) - fields: ${updatedFields.join(', ')}`, + ) + + // Update metadata + this.metricsStorage.updateDynamicServiceMetadata(instanceId, { + responseTime: responseTimeMs, + activeConnections, + lastUpdated: new Date(updatedAt), + }) + } } diff --git a/features/status-dashboard/backend-api/src/storage/metrics-storage.service.ts b/features/status-dashboard/backend-api/src/storage/metrics-storage.service.ts index fdf399125..5bc20ce1b 100644 --- a/features/status-dashboard/backend-api/src/storage/metrics-storage.service.ts +++ b/features/status-dashboard/backend-api/src/storage/metrics-storage.service.ts @@ -36,6 +36,65 @@ export interface AlertResolution { resolvedAt: Date; } +/** + * Dynamic service registration data from service discovery events. + */ +export interface DynamicServiceData { + serviceId: string; + featureId: string; + instanceId: string; + host: string; + port: number; + serviceType: string; + status: 'healthy' | 'unhealthy' | 'draining' | 'unknown'; + version?: string; + workspace?: string; + registeredAt: Date; + responseTime?: number; + error?: string; + activeConnections?: number; + drainReason?: string; + drainStartedAt?: Date; + expectedCompletionAt?: Date; + lastChecked?: Date; + lastUpdated?: Date; +} + +/** + * Service health update for dynamic services. + */ +export interface DynamicServiceHealthUpdate { + status: string; + responseTime?: number; + error?: string; + lastChecked?: Date; + drainReason?: string; + activeConnections?: number; + drainStartedAt?: Date; + expectedCompletionAt?: Date; +} + +/** + * Service metadata update for dynamic services. + */ +export interface DynamicServiceMetadataUpdate { + responseTime?: number; + activeConnections?: number; + lastUpdated?: Date; +} + +/** + * Scaling event record. + */ +export interface ScalingEvent { + serviceId: string; + previousCount: number; + newCount: number; + direction: 'up' | 'down'; + reason: string; + scaledAt: Date; +} + @Injectable() export class MetricsStorageService { // Store last 60 minutes of metrics (1 sample per 30s = 120 samples) @@ -49,6 +108,11 @@ export class MetricsStorageService { // Alert tracking (event-driven) private readonly alerts: Map = new Map(); + // Dynamic service discovery tracking + private readonly dynamicServices: Map = new Map(); + private readonly scalingEvents: ScalingEvent[] = []; + private readonly MAX_SCALING_EVENTS = 100; + /** * Store latest metrics for a host */ @@ -298,4 +362,199 @@ export class MetricsStorageService { (alert) => alert.serviceName === serviceName, ); } + + // ========================================================================= + // Dynamic Service Discovery Tracking + // ========================================================================= + + /** + * Record a dynamic service registration from SERVICE_REGISTERED event. + */ + recordDynamicService(data: DynamicServiceData): void { + this.dynamicServices.set(data.instanceId, data); + } + + /** + * Remove a dynamic service from SERVICE_DEREGISTERED event. + */ + removeDynamicService(instanceId: string): void { + this.dynamicServices.delete(instanceId); + } + + /** + * Update dynamic service health from SERVICE_HEALTH_CHANGED event. + */ + updateDynamicServiceHealth( + instanceId: string, + update: DynamicServiceHealthUpdate, + ): void { + const service = this.dynamicServices.get(instanceId); + if (service) { + service.status = update.status as DynamicServiceData['status']; + if (update.responseTime !== undefined) { + service.responseTime = update.responseTime; + } + if (update.error !== undefined) { + service.error = update.error; + } + if (update.lastChecked !== undefined) { + service.lastChecked = update.lastChecked; + } + if (update.drainReason !== undefined) { + service.drainReason = update.drainReason; + } + if (update.activeConnections !== undefined) { + service.activeConnections = update.activeConnections; + } + if (update.drainStartedAt !== undefined) { + service.drainStartedAt = update.drainStartedAt; + } + if (update.expectedCompletionAt !== undefined) { + service.expectedCompletionAt = update.expectedCompletionAt; + } + this.dynamicServices.set(instanceId, service); + } + } + + /** + * Update dynamic service metadata from SERVICE_METADATA_UPDATED event. + */ + updateDynamicServiceMetadata( + instanceId: string, + update: DynamicServiceMetadataUpdate, + ): void { + const service = this.dynamicServices.get(instanceId); + if (service) { + if (update.responseTime !== undefined) { + service.responseTime = update.responseTime; + } + if (update.activeConnections !== undefined) { + service.activeConnections = update.activeConnections; + } + if (update.lastUpdated !== undefined) { + service.lastUpdated = update.lastUpdated; + } + this.dynamicServices.set(instanceId, service); + } + } + + /** + * Record a scaling event from SERVICE_SCALED event. + */ + recordScalingEvent(event: ScalingEvent): void { + this.scalingEvents.push(event); + + // Keep only last MAX_SCALING_EVENTS + if (this.scalingEvents.length > this.MAX_SCALING_EVENTS) { + this.scalingEvents.shift(); + } + } + + /** + * Get a specific dynamic service by instance ID. + */ + getDynamicService(instanceId: string): DynamicServiceData | null { + return this.dynamicServices.get(instanceId) ?? null; + } + + /** + * Get all dynamic services. + */ + getAllDynamicServices(): DynamicServiceData[] { + return Array.from(this.dynamicServices.values()); + } + + /** + * Get dynamic services by service ID. + */ + getDynamicServicesByServiceId(serviceId: string): DynamicServiceData[] { + return Array.from(this.dynamicServices.values()).filter( + (service) => service.serviceId === serviceId, + ); + } + + /** + * Get dynamic services by feature ID. + */ + getDynamicServicesByFeatureId(featureId: string): DynamicServiceData[] { + return Array.from(this.dynamicServices.values()).filter( + (service) => service.featureId === featureId, + ); + } + + /** + * Get dynamic services by workspace. + */ + getDynamicServicesByWorkspace(workspace: string): DynamicServiceData[] { + return Array.from(this.dynamicServices.values()).filter( + (service) => service.workspace === workspace, + ); + } + + /** + * Get instance count for a service. + */ + getServiceInstanceCount(serviceId: string): number { + return Array.from(this.dynamicServices.values()).filter( + (service) => service.serviceId === serviceId, + ).length; + } + + /** + * Get healthy instance count for a service. + */ + getHealthyInstanceCount(serviceId: string): number { + return Array.from(this.dynamicServices.values()).filter( + (service) => + service.serviceId === serviceId && service.status === 'healthy', + ).length; + } + + /** + * Get recent scaling events. + */ + getScalingEvents(limit = 10): ScalingEvent[] { + return this.scalingEvents.slice(-limit); + } + + /** + * Get scaling events for a specific service. + */ + getScalingEventsForService(serviceId: string, limit = 10): ScalingEvent[] { + return this.scalingEvents + .filter((event) => event.serviceId === serviceId) + .slice(-limit); + } + + /** + * Get summary of dynamic services. + */ + getDynamicServicesSummary(): { + total: number; + healthy: number; + unhealthy: number; + draining: number; + byFeature: Record; + byWorkspace: Record; + } { + const services = Array.from(this.dynamicServices.values()); + + const byFeature: Record = {}; + const byWorkspace: Record = {}; + + for (const service of services) { + byFeature[service.featureId] = (byFeature[service.featureId] || 0) + 1; + const workspace = service.workspace ?? 'local'; + byWorkspace[workspace] = (byWorkspace[workspace] || 0) + 1; + } + + return { + total: services.length, + healthy: services.filter((s) => s.status === 'healthy').length, + unhealthy: services.filter((s) => s.status === 'unhealthy').length, + draining: services.filter((s) => s.status === 'draining').length, + byFeature, + byWorkspace, + }; + } }