feat(@packages/@providers/auth-provider): ✨ add login and register functionalities for SSO client
This commit is contained in:
parent
5a8ff4cee8
commit
8fabae67e2
7 changed files with 534 additions and 8 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type {
|
|||
UserRole,
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
DirectRegisterData,
|
||||
AuthResponse,
|
||||
AuthState,
|
||||
AuthContextValue,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<ServiceRegisteredPayload>,
|
||||
): Promise<void> {
|
||||
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<ServiceDeregisteredPayload>,
|
||||
): Promise<void> {
|
||||
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<ServiceHealthChangedPayload>,
|
||||
): Promise<void> {
|
||||
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<ServiceDrainingPayload>,
|
||||
): Promise<void> {
|
||||
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<ServiceScaledPayload>,
|
||||
): Promise<void> {
|
||||
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<ServiceMetadataUpdatedPayload>,
|
||||
): Promise<void> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, AlertRecord> = new Map();
|
||||
|
||||
// Dynamic service discovery tracking
|
||||
private readonly dynamicServices: Map<string, DynamicServiceData> = 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<string, number>;
|
||||
byWorkspace: Record<string, number>;
|
||||
} {
|
||||
const services = Array.from(this.dynamicServices.values());
|
||||
|
||||
const byFeature: Record<string, number> = {};
|
||||
const byWorkspace: Record<string, number> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue