From 084b09f2d9c0f6289a3aa706102016440bc7533d Mon Sep 17 00:00:00 2001 From: Yashwin Date: Tue, 20 Jan 2026 10:18:30 +0530 Subject: [PATCH] Update environment configuration for API base URL, add ViewModuleModal export in shared components, enhance Login component error handling, and implement Modules page with data fetching, filtering, and pagination features. --- .env | 2 +- src/components/shared/ViewModuleModal.tsx | 274 ++++++++++++++++ src/components/shared/index.ts | 1 + src/pages/Login.tsx | 65 +++- src/pages/Modules.tsx | 366 +++++++++++++++++++++- src/services/api-client.ts | 23 +- src/services/module-service.ts | 28 ++ src/types/module.ts | 48 +++ 8 files changed, 773 insertions(+), 34 deletions(-) create mode 100644 src/components/shared/ViewModuleModal.tsx create mode 100644 src/services/module-service.ts create mode 100644 src/types/module.ts diff --git a/.env b/.env index e4d05f7..2ae02c5 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VITE_API_BASE_URL=http://localhost:3000/api/v1 +VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1 diff --git a/src/components/shared/ViewModuleModal.tsx b/src/components/shared/ViewModuleModal.tsx new file mode 100644 index 0000000..8642ca1 --- /dev/null +++ b/src/components/shared/ViewModuleModal.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Modal, SecondaryButton, StatusBadge } from '@/components/shared'; +import type { Module } from '@/types/module'; + +// Helper function to get status badge variant +const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => { + if (!status) return 'process'; + switch (status.toLowerCase()) { + case 'running': + case 'active': + case 'healthy': + return 'success'; + case 'stopped': + case 'failed': + case 'unhealthy': + return 'failure'; + default: + return 'process'; + } +}; + +// Helper function to format date +const formatDate = (dateString: string | null): string => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +interface ViewModuleModalProps { + isOpen: boolean; + onClose: () => void; + moduleId: string | null; + onLoadModule: (id: string) => Promise; +} + +export const ViewModuleModal = ({ + isOpen, + onClose, + moduleId, + onLoadModule, +}: ViewModuleModalProps): ReactElement | null => { + const [module, setModule] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Load module data when modal opens + useEffect(() => { + if (isOpen && moduleId) { + const loadModule = async (): Promise => { + try { + setIsLoading(true); + setError(null); + const data = await onLoadModule(moduleId); + setModule(data); + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load module details'); + } finally { + setIsLoading(false); + } + }; + loadModule(); + } else { + setModule(null); + setError(null); + } + }, [isOpen, moduleId, onLoadModule]); + + return ( + + Close + + } + > +
+ {isLoading && ( +
+ +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!isLoading && !error && module && ( +
+ {/* Basic Information */} +
+

Basic Information

+
+
+ +

{module.module_id}

+
+
+ +

{module.name}

+
+
+ +

{module.description}

+
+
+ +

{module.version}

+
+
+ +
+ + {module.status || 'Unknown'} + +
+
+
+ +
+ + {module.health_status || 'N/A'} + +
+
+
+
+ + {/* Technical Details */} +
+

Technical Details

+
+
+ +

{module.runtime_language || 'N/A'}

+
+
+ +

{module.framework || 'N/A'}

+
+
+ +

{module.base_url}

+
+
+ +

{module.health_endpoint}

+
+
+
+ + {/* Resource Limits */} +
+

Resource Limits

+
+
+ +

{module.cpu_request}

+
+
+ +

{module.cpu_limit}

+
+
+ +

{module.memory_request}

+
+
+ +

{module.memory_limit}

+
+
+ +

{module.min_replicas}

+
+
+ +

{module.max_replicas}

+
+
+
+ + {/* Additional Information */} + {(module.endpoints || module.kafka_topics || module.consecutive_failures !== null) && ( +
+

Additional Information

+
+ {module.endpoints && module.endpoints.length > 0 && ( +
+ +
+ {module.endpoints.map((endpoint, index) => ( +

+ {endpoint} +

+ ))} +
+
+ )} + {module.kafka_topics && module.kafka_topics.length > 0 && ( +
+ +
+ {module.kafka_topics.map((topic, index) => ( +

+ {topic} +

+ ))} +
+
+ )} + {module.consecutive_failures !== null && ( +
+ +

{module.consecutive_failures}

+
+ )} +
+
+ )} + + {/* Registration Information */} +
+

Registration Information

+
+
+ +

{module.registered_by_email || module.registered_by}

+
+
+ +

{module.tenant_id}

+
+
+ +

{formatDate(module.last_health_check)}

+
+
+
+ + {/* Timestamps */} +
+

Timestamps

+
+
+ +

{formatDate(module.created_at)}

+
+
+ +

{formatDate(module.updated_at)}

+
+
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 70cf955..56ed604 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -21,5 +21,6 @@ export { EditUserModal } from './EditUserModal'; export { NewRoleModal } from './NewRoleModal'; export { ViewRoleModal } from './ViewRoleModal'; export { EditRoleModal } from './EditRoleModal'; +export { ViewModuleModal } from './ViewModuleModal'; export { PageHeader } from './PageHeader'; export type { TabItem } from './PageHeader'; \ No newline at end of file diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index fb2bc6d..5945340 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -50,29 +50,36 @@ const Login = (): ReactElement => { } }, [isAuthenticated, navigate]); - // Clear errors when component mounts + // Clear errors only on component mount, not on every auth state change useEffect(() => { + // Only clear errors on initial mount dispatch(clearError()); setGeneralError(''); - }, [dispatch]); - - const onSubmit = async (data: LoginFormData, event?: React.BaseSyntheticEvent): Promise => { - // Explicitly prevent form default submission - event?.preventDefault(); - event?.stopPropagation(); + clearErrors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty dependency array - only run on mount + const onSubmit = async (data: LoginFormData): Promise => { + // Clear previous errors setGeneralError(''); clearErrors(); + dispatch(clearError()); try { const result = await dispatch(loginAsync(data)).unwrap(); if (result) { + // Only navigate on success navigate('/dashboard'); } } catch (error: any) { - if (error?.payload) { - const loginError = error.payload as LoginError; + // Clear Redux error state since we're handling errors locally + dispatch(clearError()); + // Handle error from unwrap() - error is the rejected value from rejectWithValue + const loginError = error as LoginError; + + if (loginError && typeof loginError === 'object') { + // Check for validation errors with details array if ('details' in loginError && Array.isArray(loginError.details)) { // Validation errors from server - set field-specific errors loginError.details.forEach((detail) => { @@ -81,16 +88,32 @@ const Login = (): ReactElement => { type: 'server', message: detail.message, }); + } else { + // If error is for a field we don't handle, show as general error + setGeneralError(detail.message); } }); - } else if ('error' in loginError && typeof loginError.error === 'object') { - // General error from server - setGeneralError(loginError.error.message || 'Login failed'); + } else if ('error' in loginError) { + // Check if error is an object with message property + if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) { + // General error from server with object structure + setGeneralError(loginError.error.message || 'Login failed'); + } else if (typeof loginError.error === 'string') { + // Error is a string + setGeneralError(loginError.error); + } else { + setGeneralError('Login failed. Please check your credentials.'); + } } else { - setGeneralError('An unexpected error occurred'); + // Fallback for unknown error structure + setGeneralError('An unexpected error occurred. Please try again.'); } + } else if (error?.message) { + // Network error or other error + setGeneralError(error.message); } else { - setGeneralError(error?.message || 'Login failed'); + // Complete fallback + setGeneralError('Login failed. Please check your credentials and try again.'); } } }; @@ -120,19 +143,27 @@ const Login = (): ReactElement => {

- {/* General Error Message */} - {(generalError || error) && ( + {/* General Error Message - Prioritize local error over Redux error */} + {generalError && (
-

{generalError || error}

+

{generalError}

+
+ )} + {/* Show Redux error only if no local error and no field errors */} + {!generalError && error && !errors.email && !errors.password && ( +
+

{error}

)}
{ e.preventDefault(); + e.stopPropagation(); handleSubmit(onSubmit)(e); }} className="space-y-4" + noValidate > {/* Email Field */} { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +}; + +// Helper function to get status badge variant +const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => { + if (!status) return 'process'; + switch (status.toLowerCase()) { + case 'running': + case 'active': + case 'healthy': + return 'success'; + case 'stopped': + case 'failed': + case 'unhealthy': + return 'failure'; + default: + return 'process'; + } +}; const Modules = (): ReactElement => { + const [modules, setModules] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(5); + const [pagination, setPagination] = useState<{ + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }>({ + page: 1, + limit: 5, + total: 0, + totalPages: 1, + hasMore: false, + }); + + // Filter state + const [statusFilter, setStatusFilter] = useState(null); + const [orderBy, setOrderBy] = useState(null); + + // View modal + const [viewModalOpen, setViewModalOpen] = useState(false); + const [selectedModuleId, setSelectedModuleId] = useState(null); + + const fetchModules = async ( + page: number, + itemsPerPage: number, + status: string | null = null, + sortBy: string[] | null = null + ): Promise => { + try { + setIsLoading(true); + setError(null); + const response = await moduleService.getAll(page, itemsPerPage, status, sortBy); + if (response.success) { + setModules(response.data); + setPagination(response.pagination); + } else { + setError('Failed to load modules'); + } + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load modules'); + } finally { + setIsLoading(false); + } + }; + + // Fetch modules on mount and when pagination/filters change + useEffect(() => { + fetchModules(currentPage, limit, statusFilter, orderBy); + }, [currentPage, limit, statusFilter, orderBy]); + + // View module handler + const handleViewModule = (moduleId: string): void => { + setSelectedModuleId(moduleId); + setViewModalOpen(true); + }; + + // Load module for view + const loadModule = async (id: string): Promise => { + const response = await moduleService.getById(id); + return response.data; + }; + + // Define table columns + const columns: Column[] = [ + { + key: 'name', + label: 'Module Name', + render: (module) => ( +
+
+ + {module.name.substring(0, 2).toUpperCase()} + +
+
+ {module.name} + {module.module_id} +
+
+ ), + mobileLabel: 'Name', + }, + { + key: 'description', + label: 'Description', + render: (module) => ( + {module.description} + ), + }, + { + key: 'version', + label: 'Version', + render: (module) => ( + {module.version} + ), + }, + { + key: 'status', + label: 'Status', + render: (module) => ( + + {module.status || 'Unknown'} + + ), + }, + { + key: 'health_status', + label: 'Health Status', + render: (module) => ( + + {module.health_status || 'N/A'} + + ), + }, + { + key: 'runtime_language', + label: 'Runtime', + render: (module) => ( + + {module.runtime_language || 'N/A'} + + ), + }, + { + key: 'created_at', + label: 'Registered Date', + render: (module) => ( + {formatDate(module.created_at)} + ), + mobileLabel: 'Registered', + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (module) => ( +
+ +
+ ), + }, + ]; + + // Mobile card renderer + const mobileCardRenderer = (module: Module) => ( +
+
+
+
+ + {module.name.substring(0, 2).toUpperCase()} + +
+
+

{module.name}

+

{module.module_id}

+
+
+ +
+
+
+ Status: +
+ + {module.status || 'Unknown'} + +
+
+
+ Health: +
+ + {module.health_status || 'N/A'} + +
+
+
+ Version: +

{module.version}

+
+
+ Runtime: +

{module.runtime_language || 'N/A'}

+
+
+ Registered: +

{formatDate(module.created_at)}

+
+
+ Description: +

{module.description}

+
+
+
+ ); + return ( -
Modules
-
- ) -} + {/* Table Container */} +
+ {/* Table Header with Filters */} +
+ {/* Filters */} +
+ {/* Status Filter */} + { + setStatusFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All" + /> -export default Modules \ No newline at end of file + {/* Sort Filter */} + { + setOrderBy(value as string[] | null); + setCurrentPage(1); + }} + placeholder="Default" + showIcon + icon={} + /> +
+ + {/* Actions */} +
+ {/* Export Button */} + +
+
+ + {/* Table */} + module.id} + isLoading={isLoading} + error={error} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No modules found" + /> + + {/* Pagination */} + {pagination.total > 0 && ( +
+ setCurrentPage(page)} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> +
+ )} +
+ + {/* View Module Modal */} + { + setViewModalOpen(false); + setSelectedModuleId(null); + }} + moduleId={selectedModuleId} + onLoadModule={loadModule} + /> + + ); +}; + +export default Modules; diff --git a/src/services/api-client.ts b/src/services/api-client.ts index dcc4c14..1161e1c 100644 --- a/src/services/api-client.ts +++ b/src/services/api-client.ts @@ -43,17 +43,24 @@ apiClient.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response?.status === 401) { - // Handle unauthorized - clear auth and redirect to login - try { - const store = (window as any).__REDUX_STORE__; - if (store) { - store.dispatch({ type: 'auth/logout' }); + // Skip redirect for login/auth endpoints (401 is expected for failed login) + const requestUrl = error.config?.url || ''; + const isAuthEndpoint = requestUrl.includes('/auth/login') || requestUrl.includes('/auth/logout'); + + if (!isAuthEndpoint) { + // Handle unauthorized - clear auth and redirect to login (only for non-auth endpoints) + try { + const store = (window as any).__REDUX_STORE__; + if (store) { + store.dispatch({ type: 'auth/logout' }); + window.location.href = '/'; + } + } catch (e) { + // Silently fail if store is not available window.location.href = '/'; } - } catch (e) { - // Silently fail if store is not available - window.location.href = '/'; } + // For auth endpoints, just reject the promise so the component can handle the error } return Promise.reject(error); } diff --git a/src/services/module-service.ts b/src/services/module-service.ts new file mode 100644 index 0000000..83643d5 --- /dev/null +++ b/src/services/module-service.ts @@ -0,0 +1,28 @@ +import apiClient from './api-client'; +import type { ModulesResponse, GetModuleResponse } from '@/types/module'; + +export const moduleService = { + getAll: async ( + page: number = 1, + limit: number = 20, + status?: string | null, + orderBy?: string[] | null + ): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + if (status) { + params.append('status', status); + } + if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) { + params.append('orderBy[]', orderBy[0]); + params.append('orderBy[]', orderBy[1]); + } + const response = await apiClient.get(`/modules?${params.toString()}`); + return response.data; + }, + getById: async (id: string): Promise => { + const response = await apiClient.get(`/modules/${id}`); + return response.data; + }, +}; diff --git a/src/types/module.ts b/src/types/module.ts new file mode 100644 index 0000000..f6cea7d --- /dev/null +++ b/src/types/module.ts @@ -0,0 +1,48 @@ +export interface Module { + id: string; + module_id: string; + name: string; + description: string; + version: string; + status: string; + runtime_language: string | null; + framework: string | null; + base_url: string; + health_endpoint: string; + endpoints: string[] | null; + kafka_topics: string[] | null; + cpu_request: string; + cpu_limit: string; + memory_request: string; + memory_limit: string; + min_replicas: number; + max_replicas: number; + last_health_check: string | null; + health_status: string | null; + consecutive_failures: number | null; + registered_by: string; + tenant_id: string; + metadata: Record | null; + created_at: string; + updated_at: string; + registered_by_email: string; +} + +export interface Pagination { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; +} + +export interface ModulesResponse { + success: boolean; + data: Module[]; + pagination: Pagination; +} + +export interface GetModuleResponse { + success: boolean; + data: Module; +}