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:
parent
ef0e67e07a
commit
084b09f2d9
2
.env
2
.env
@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1
|
||||||
|
|||||||
274
src/components/shared/ViewModuleModal.tsx
Normal file
274
src/components/shared/ViewModuleModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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';
|
||||||
@ -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
|
||||||
|
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');
|
setGeneralError(loginError.error.message || 'Login failed');
|
||||||
|
} else if (typeof loginError.error === 'string') {
|
||||||
|
// Error is a string
|
||||||
|
setGeneralError(loginError.error);
|
||||||
} else {
|
} else {
|
||||||
setGeneralError('An unexpected error occurred');
|
setGeneralError('Login failed. Please check your credentials.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setGeneralError(error?.message || 'Login failed');
|
// 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 {
|
||||||
|
// 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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -43,7 +43,12 @@ 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)
|
||||||
|
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 {
|
try {
|
||||||
const store = (window as any).__REDUX_STORE__;
|
const store = (window as any).__REDUX_STORE__;
|
||||||
if (store) {
|
if (store) {
|
||||||
@ -55,6 +60,8 @@ apiClient.interceptors.response.use(
|
|||||||
window.location.href = '/';
|
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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
28
src/services/module-service.ts
Normal file
28
src/services/module-service.ts
Normal 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
48
src/types/module.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user