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.

This commit is contained in:
Yashwin 2026-01-20 10:18:30 +05:30
parent ef0e67e07a
commit 084b09f2d9
8 changed files with 773 additions and 34 deletions

2
.env
View File

@ -1 +1 @@
VITE_API_BASE_URL=http://localhost:3000/api/v1 VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1

View File

@ -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<Module>;
}
export const ViewModuleModal = ({
isOpen,
onClose,
moduleId,
onLoadModule,
}: ViewModuleModalProps): ReactElement | null => {
const [module, setModule] = useState<Module | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Load module data when modal opens
useEffect(() => {
if (isOpen && moduleId) {
const loadModule = async (): Promise<void> => {
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title="View Module Details"
description="View module information"
maxWidth="lg"
footer={
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
Close
</SecondaryButton>
}
>
<div className="p-5">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
</div>
)}
{error && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
)}
{!isLoading && !error && module && (
<div className="flex flex-col gap-6">
{/* Basic Information */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Module ID</label>
<p className="text-sm text-[#0e1b2a] font-mono">{module.module_id}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Name</label>
<p className="text-sm text-[#0e1b2a]">{module.name}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Description</label>
<p className="text-sm text-[#0e1b2a]">{module.description}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Version</label>
<p className="text-sm text-[#0e1b2a]">{module.version}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Status</label>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
</StatusBadge>
</div>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Status</label>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
</StatusBadge>
</div>
</div>
</div>
</div>
{/* Technical Details */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Technical Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Runtime Language</label>
<p className="text-sm text-[#0e1b2a]">{module.runtime_language || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Framework</label>
<p className="text-sm text-[#0e1b2a]">{module.framework || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Base URL</label>
<p className="text-sm text-[#0e1b2a] font-mono break-all">{module.base_url}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Endpoint</label>
<p className="text-sm text-[#0e1b2a]">{module.health_endpoint}</p>
</div>
</div>
</div>
{/* Resource Limits */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Resource Limits</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">CPU Request</label>
<p className="text-sm text-[#0e1b2a]">{module.cpu_request}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">CPU Limit</label>
<p className="text-sm text-[#0e1b2a]">{module.cpu_limit}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Memory Request</label>
<p className="text-sm text-[#0e1b2a]">{module.memory_request}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Memory Limit</label>
<p className="text-sm text-[#0e1b2a]">{module.memory_limit}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Min Replicas</label>
<p className="text-sm text-[#0e1b2a]">{module.min_replicas}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Max Replicas</label>
<p className="text-sm text-[#0e1b2a]">{module.max_replicas}</p>
</div>
</div>
</div>
{/* Additional Information */}
{(module.endpoints || module.kafka_topics || module.consecutive_failures !== null) && (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{module.endpoints && module.endpoints.length > 0 && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Endpoints</label>
<div className="flex flex-col gap-1">
{module.endpoints.map((endpoint, index) => (
<p key={index} className="text-sm text-[#0e1b2a] font-mono">
{endpoint}
</p>
))}
</div>
</div>
)}
{module.kafka_topics && module.kafka_topics.length > 0 && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Kafka Topics</label>
<div className="flex flex-col gap-1">
{module.kafka_topics.map((topic, index) => (
<p key={index} className="text-sm text-[#0e1b2a] font-mono">
{topic}
</p>
))}
</div>
</div>
)}
{module.consecutive_failures !== null && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Consecutive Failures</label>
<p className="text-sm text-[#0e1b2a]">{module.consecutive_failures}</p>
</div>
)}
</div>
</div>
)}
{/* Registration Information */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Registration Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Registered By</label>
<p className="text-sm text-[#0e1b2a]">{module.registered_by_email || module.registered_by}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant ID</label>
<p className="text-sm text-[#0e1b2a] font-mono">{module.tenant_id}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Last Health Check</label>
<p className="text-sm text-[#0e1b2a]">{formatDate(module.last_health_check)}</p>
</div>
</div>
</div>
{/* Timestamps */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
<p className="text-sm text-[#0e1b2a]">{formatDate(module.created_at)}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
<p className="text-sm text-[#0e1b2a]">{formatDate(module.updated_at)}</p>
</div>
</div>
</div>
</div>
)}
</div>
</Modal>
);
};

View File

@ -21,5 +21,6 @@ export { EditUserModal } from './EditUserModal';
export { NewRoleModal } from './NewRoleModal'; export { NewRoleModal } from './NewRoleModal';
export { ViewRoleModal } from './ViewRoleModal'; export { ViewRoleModal } from './ViewRoleModal';
export { EditRoleModal } from './EditRoleModal'; export { EditRoleModal } from './EditRoleModal';
export { ViewModuleModal } from './ViewModuleModal';
export { PageHeader } from './PageHeader'; export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader'; export type { TabItem } from './PageHeader';

View File

@ -50,29 +50,36 @@ const Login = (): ReactElement => {
} }
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
// Clear errors when component mounts // Clear errors only on component mount, not on every auth state change
useEffect(() => { useEffect(() => {
// Only clear errors on initial mount
dispatch(clearError()); dispatch(clearError());
setGeneralError(''); setGeneralError('');
}, [dispatch]); clearErrors();
// eslint-disable-next-line react-hooks/exhaustive-deps
const onSubmit = async (data: LoginFormData, event?: React.BaseSyntheticEvent): Promise<void> => { }, []); // Empty dependency array - only run on mount
// Explicitly prevent form default submission
event?.preventDefault();
event?.stopPropagation();
const onSubmit = async (data: LoginFormData): Promise<void> => {
// Clear previous errors
setGeneralError(''); setGeneralError('');
clearErrors(); clearErrors();
dispatch(clearError());
try { try {
const result = await dispatch(loginAsync(data)).unwrap(); const result = await dispatch(loginAsync(data)).unwrap();
if (result) { if (result) {
// Only navigate on success
navigate('/dashboard'); navigate('/dashboard');
} }
} catch (error: any) { } catch (error: any) {
if (error?.payload) { // Clear Redux error state since we're handling errors locally
const loginError = error.payload as LoginError; 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)) { if ('details' in loginError && Array.isArray(loginError.details)) {
// Validation errors from server - set field-specific errors // Validation errors from server - set field-specific errors
loginError.details.forEach((detail) => { loginError.details.forEach((detail) => {
@ -81,16 +88,32 @@ const Login = (): ReactElement => {
type: 'server', type: 'server',
message: detail.message, 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') { } else if ('error' in loginError) {
// General error from server // Check if error is an object with message property
setGeneralError(loginError.error.message || 'Login failed'); 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 { } 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 { } 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 => {
</p> </p>
</div> </div>
{/* General Error Message */} {/* General Error Message - Prioritize local error over Redux error */}
{(generalError || error) && ( {generalError && (
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md"> <div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{generalError || error}</p> <p className="text-sm text-[#ef4444]">{generalError}</p>
</div>
)}
{/* Show Redux error only if no local error and no field errors */}
{!generalError && error && !errors.email && !errors.password && (
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div> </div>
)} )}
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
handleSubmit(onSubmit)(e); handleSubmit(onSubmit)(e);
}} }}
className="space-y-4" className="space-y-4"
noValidate
> >
{/* Email Field */} {/* Email Field */}
<FormField <FormField

View File

@ -1,18 +1,368 @@
import { Layout } from "@/components/layout/Layout" import { useState, useEffect } from 'react';
import type { ReactElement } from "react" import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import {
StatusBadge,
ViewModuleModal,
DataTable,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { Download, ArrowUpDown } from 'lucide-react';
import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module';
// Helper function to format date
const formatDate = (dateString: string): string => {
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 = (): ReactElement => {
const [modules, setModules] = useState<Module[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(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<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
const fetchModules = async (
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
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<Module> => {
const response = await moduleService.getById(id);
return response.data;
};
// Define table columns
const columns: Column<Module>[] = [
{
key: 'name',
label: 'Module Name',
render: (module) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{module.name.substring(0, 2).toUpperCase()}
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
<span className="text-xs text-[#6b7280] font-mono">{module.module_id}</span>
</div>
</div>
),
mobileLabel: 'Name',
},
{
key: 'description',
label: 'Description',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724] line-clamp-1">{module.description}</span>
),
},
{
key: 'version',
label: 'Version',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">{module.version}</span>
),
},
{
key: 'status',
label: 'Status',
render: (module) => (
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
</StatusBadge>
),
},
{
key: 'health_status',
label: 'Health Status',
render: (module) => (
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
</StatusBadge>
),
},
{
key: 'runtime_language',
label: 'Runtime',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">
{module.runtime_language || 'N/A'}
</span>
),
},
{
key: 'created_at',
label: 'Registered Date',
render: (module) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(module.created_at)}</span>
),
mobileLabel: 'Registered',
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (module) => (
<div className="flex justify-end">
<button
type="button"
onClick={() => handleViewModule(module.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
>
View
</button>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (module: Module) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{module.name.substring(0, 2).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{module.name}</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">{module.module_id}</p>
</div>
</div>
<button
type="button"
onClick={() => handleViewModule(module.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
>
View
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Health:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Version:</span>
<p className="text-[#0f1724] font-normal mt-1">{module.version}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Runtime:</span>
<p className="text-[#0f1724] font-normal mt-1">{module.runtime_language || 'N/A'}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Registered:</span>
<p className="text-[#6b7280] font-normal mt-1">{formatDate(module.created_at)}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Description:</span>
<p className="text-[#0f1724] font-normal mt-1 line-clamp-2">{module.description}</p>
</div>
</div>
</div>
);
return ( return (
<Layout <Layout
currentPage="Modules" currentPage="Modules"
pageHeader={{ pageHeader={{
title: 'module List', title: 'Module List',
description: 'View and manage all system modules registered in the QAssure platform.', description: 'View and manage all system modules registered in the QAssure platform.',
}} }}
> >
<div>Modules</div> {/* Table Container */}
</Layout> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
) {/* Table Header with Filters */}
} <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Status Filter */}
<FilterDropdown
label="Status"
options={[
{ value: 'running', label: 'Running' },
{ value: 'stopped', label: 'Stopped' },
{ value: 'failed', label: 'Failed' },
]}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
export default Modules {/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
{ value: ['module_id', 'asc'], label: 'Module ID (A-Z)' },
{ value: ['module_id', 'desc'], label: 'Module ID (Z-A)' },
{ value: ['status', 'asc'], label: 'Status (A-Z)' },
{ value: ['status', 'desc'], label: 'Status (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
</div>
</div>
{/* Table */}
<DataTable
columns={columns}
data={modules}
keyExtractor={(module) => module.id}
isLoading={isLoading}
error={error}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No modules found"
/>
{/* Pagination */}
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => setCurrentPage(page)}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
{/* View Module Modal */}
<ViewModuleModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedModuleId(null);
}}
moduleId={selectedModuleId}
onLoadModule={loadModule}
/>
</Layout>
);
};
export default Modules;

View File

@ -43,17 +43,24 @@ apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError) => { (error: AxiosError) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Handle unauthorized - clear auth and redirect to login // Skip redirect for login/auth endpoints (401 is expected for failed login)
try { const requestUrl = error.config?.url || '';
const store = (window as any).__REDUX_STORE__; const isAuthEndpoint = requestUrl.includes('/auth/login') || requestUrl.includes('/auth/logout');
if (store) {
store.dispatch({ type: '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 = '/'; 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); return Promise.reject(error);
} }

View File

@ -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<ModulesResponse> => {
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<ModulesResponse>(`/modules?${params.toString()}`);
return response.data;
},
getById: async (id: string): Promise<GetModuleResponse> => {
const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`);
return response.data;
},
};

48
src/types/module.ts Normal file
View File

@ -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<string, unknown> | 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;
}