feat: add module update functionality, webhook sync, and API key reissue support with new management modals
This commit is contained in:
parent
92ea75ce77
commit
bcd029950f
208
src/components/superadmin/ApikeyReissueModal.tsx
Normal file
208
src/components/superadmin/ApikeyReissueModal.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||||
|
import { Copy, Check, Key, Loader2 } from 'lucide-react';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
import { moduleService } from '@/services/module-service';
|
||||||
|
import type { Module } from '@/types/module';
|
||||||
|
|
||||||
|
const reissueSchema = z.object({
|
||||||
|
webhookurl: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Webhook URL is required')
|
||||||
|
.url('Invalid URL format')
|
||||||
|
.max(500, 'Webhook URL must be at most 500 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ReissueFormData = z.infer<typeof reissueSchema>;
|
||||||
|
|
||||||
|
interface ApikeyReissueModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
moduleId: string | null;
|
||||||
|
onLoadModule: (id: string) => Promise<Module>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApikeyReissueModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
moduleId,
|
||||||
|
onLoadModule,
|
||||||
|
}: ApikeyReissueModalProps): ReactElement | null => {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ReissueFormData>({
|
||||||
|
resolver: zodResolver(reissueSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && moduleId) {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await onLoadModule(moduleId);
|
||||||
|
reset({ webhookurl: data.webhookurl || '' });
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err?.message || 'Failed to load module details');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
setApiKey(null);
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, moduleId, onLoadModule, reset]);
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: ReissueFormData): Promise<void> => {
|
||||||
|
if (!moduleId) return;
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const response = await moduleService.reissueApiKey(moduleId, data.webhookurl);
|
||||||
|
if (response.success && response.data?.api_key?.key) {
|
||||||
|
setApiKey(response.data.api_key.key);
|
||||||
|
showToast.success(
|
||||||
|
'API Key Reissued',
|
||||||
|
'A new API key has been generated and sent to the webhook URL.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err?.response?.data?.error?.message || err?.message || 'Failed to reissue API key');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyApiKey = async (): Promise<void> => {
|
||||||
|
if (apiKey) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(apiKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
showToast.success('API key copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
showToast.error('Failed to copy API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Reissue X-API-Key"
|
||||||
|
description="Regenerate the module's API key and send it to the configured webhook."
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3 w-full">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
{apiKey ? 'Close' : 'Cancel'}
|
||||||
|
</SecondaryButton>
|
||||||
|
{!apiKey && (
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit(handleFormSubmit)}
|
||||||
|
disabled={isSubmitting || isLoading}
|
||||||
|
size="default"
|
||||||
|
className="px-4 py-2.5 text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Reissuing...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
<span>Reissue & Send Key</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-5">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{apiKey ? (
|
||||||
|
<div className="p-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-2">
|
||||||
|
⚠️ New API Key Generated
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#6b7280] mb-3">
|
||||||
|
The new API key has been generated and sent to the module. Store it securely - this is the only time you will see this key here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
|
||||||
|
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">{apiKey}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#112868] text-white rounded-md text-xs font-medium hover:bg-[#0d1f4e] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-5">
|
||||||
|
<div className="bg-[#f8fafc] border border-[#e2e8f0] rounded-md p-4 flex gap-3">
|
||||||
|
<Key className="w-5 h-5 text-[#112868] shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-[#0f1724]">Security Notice</h4>
|
||||||
|
<p className="text-xs text-[#64748b] mt-1 leading-relaxed">
|
||||||
|
Reissuing the API key will immediately invalidate the previous key. The new key will be sent via POST request to the webhook URL below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="X-API-Key Webhook URL"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://module-app.com/api/v1/webhooks/api-key"
|
||||||
|
error={errors.webhookurl?.message}
|
||||||
|
{...register('webhookurl')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-xs text-[#94a3b8] italic">
|
||||||
|
Note: This URL will be updated in the module configuration if it differs from the current one.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
295
src/components/superadmin/EditModuleModal.tsx
Normal file
295
src/components/superadmin/EditModuleModal.tsx
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||||
|
import { Copy, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
import type { Module, UpdateModuleRequest } from '@/types/module';
|
||||||
|
|
||||||
|
// Validation schema for update - matches backend validation
|
||||||
|
const updateModuleSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'name is required')
|
||||||
|
.min(3, 'name must be at least 3 characters')
|
||||||
|
.max(100, 'name must be at most 100 characters'),
|
||||||
|
description: z.string().max(1000, 'description must be at most 1000 characters').optional().nullable(),
|
||||||
|
frontend_base_url: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'frontend_base_url is required')
|
||||||
|
.max(255, 'frontend_base_url must be at most 255 characters')
|
||||||
|
.url('Invalid URL format'),
|
||||||
|
backend_base_url: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'backend_base_url is required')
|
||||||
|
.max(255, 'backend_base_url must be at most 255 characters')
|
||||||
|
.url('Invalid URL format'),
|
||||||
|
health_endpoint: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'health_endpoint is required')
|
||||||
|
.max(255, 'health_endpoint must be at most 255 characters'),
|
||||||
|
cpu_request: z.string().max(20, 'cpu_request must be at most 20 characters').optional().nullable(),
|
||||||
|
cpu_limit: z.string().max(20, 'cpu_limit must be at most 20 characters').optional().nullable(),
|
||||||
|
memory_request: z.string().max(20, 'memory_request must be at most 20 characters').optional().nullable(),
|
||||||
|
memory_limit: z.string().max(20, 'memory_limit must be at most 20 characters').optional().nullable(),
|
||||||
|
min_replicas: z.number().int().min(1, 'min_replicas must be at least 1').max(50, 'min_replicas must be at most 50').optional().nullable(),
|
||||||
|
max_replicas: z.number().int().min(1, 'max_replicas must be at least 1').max(50, 'max_replicas must be at most 50').optional().nullable(),
|
||||||
|
metadata: z.any().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type EditModuleFormData = z.infer<typeof updateModuleSchema>;
|
||||||
|
|
||||||
|
interface EditModuleModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
module: Module | null;
|
||||||
|
onSubmit: (data: UpdateModuleRequest) => Promise<any>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditModuleModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
module,
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
}: EditModuleModalProps): ReactElement | null => {
|
||||||
|
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<EditModuleFormData>({
|
||||||
|
resolver: zodResolver(updateModuleSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when modal opens or module changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && module) {
|
||||||
|
reset({
|
||||||
|
name: module.name,
|
||||||
|
description: module.description,
|
||||||
|
frontend_base_url: module.frontend_base_url,
|
||||||
|
backend_base_url: module.backend_base_url,
|
||||||
|
health_endpoint: module.health_endpoint,
|
||||||
|
cpu_request: module.cpu_request,
|
||||||
|
cpu_limit: module.cpu_limit,
|
||||||
|
memory_request: module.memory_request,
|
||||||
|
memory_limit: module.memory_limit,
|
||||||
|
min_replicas: module.min_replicas,
|
||||||
|
max_replicas: module.max_replicas,
|
||||||
|
metadata: module.metadata,
|
||||||
|
});
|
||||||
|
clearErrors();
|
||||||
|
setApiKey(null);
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, module, reset, clearErrors]);
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: EditModuleFormData): Promise<void> => {
|
||||||
|
if (!module) return;
|
||||||
|
clearErrors();
|
||||||
|
try {
|
||||||
|
const response = await onSubmit(data);
|
||||||
|
// Backend might return a new API key if webhookurl changed
|
||||||
|
if (response?.data?.api_key?.key) {
|
||||||
|
setApiKey(response.data.api_key.key);
|
||||||
|
showToast.success(
|
||||||
|
'Module updated successfully',
|
||||||
|
'Webhook URL changed. Your new API key has been generated and sent to the module. Please copy and store it securely.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast.success('Module updated successfully');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.response?.data?.error?.message || error?.message || 'Failed to update module';
|
||||||
|
setError('root', { type: 'server', message: errorMessage });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyApiKey = async (): Promise<void> => {
|
||||||
|
if (apiKey) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(apiKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
showToast.success('API key copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
showToast.error('Failed to copy API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setApiKey(null);
|
||||||
|
setCopied(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!module) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Edit Module Configuration"
|
||||||
|
description={`Update configuration for ${module.name} (${module.module_id})`}
|
||||||
|
maxWidth="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3 w-full">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
{apiKey ? 'Close' : 'Cancel'}
|
||||||
|
</SecondaryButton>
|
||||||
|
{!apiKey && (
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit(handleFormSubmit)}
|
||||||
|
disabled={isLoading}
|
||||||
|
size="default"
|
||||||
|
className="px-4 py-2.5 text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{isLoading ? 'Updating...' : 'Save Changes'}
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-5">
|
||||||
|
{/* API Key Display Section (Only if webhookurl changed) */}
|
||||||
|
{apiKey && (
|
||||||
|
<div className="mb-6 p-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-2">
|
||||||
|
⚠️ New API Key Generated
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#6b7280] mb-3">
|
||||||
|
Since the Webhook URL was changed, a new API key has been generated and sent to the module. Store it securely - this is the only time you will see this key here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
|
||||||
|
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">{apiKey}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#112868] text-white rounded-md text-xs font-medium hover:bg-[#0d1f4e] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.root && (
|
||||||
|
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||||
|
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!apiKey && (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Basic Information</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-5">
|
||||||
|
<FormField
|
||||||
|
label="Module Name"
|
||||||
|
required
|
||||||
|
placeholder="Enter module name"
|
||||||
|
error={errors.name?.message}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[13px] font-medium text-[#0e1b2a]">Module ID (Read Only)</label>
|
||||||
|
<div className="h-10 px-3.5 py-2 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-gray-500 font-mono">
|
||||||
|
{module.module_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter module description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Endpoint Configuration</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-5">
|
||||||
|
<FormField
|
||||||
|
label="Frontend Base URL"
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
placeholder="https://frontend.example.com"
|
||||||
|
error={errors.frontend_base_url?.message}
|
||||||
|
{...register('frontend_base_url')}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Backend Base URL"
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
placeholder="https://backend.example.com"
|
||||||
|
error={errors.backend_base_url?.message}
|
||||||
|
{...register('backend_base_url')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="Health Endpoint"
|
||||||
|
required
|
||||||
|
placeholder="/health"
|
||||||
|
error={errors.health_endpoint?.message}
|
||||||
|
{...register('health_endpoint')}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Resource Limits (Optional)</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-5">
|
||||||
|
<FormField
|
||||||
|
label="CPU Request"
|
||||||
|
placeholder="e.g., 100m"
|
||||||
|
error={errors.cpu_request?.message}
|
||||||
|
{...register('cpu_request')}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="CPU Limit"
|
||||||
|
placeholder="e.g., 500m"
|
||||||
|
error={errors.cpu_limit?.message}
|
||||||
|
{...register('cpu_limit')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-5 mt-4">
|
||||||
|
<FormField
|
||||||
|
label="Memory Request"
|
||||||
|
placeholder="e.g., 128Mi"
|
||||||
|
error={errors.memory_request?.message}
|
||||||
|
{...register('memory_request')}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Memory Limit"
|
||||||
|
placeholder="e.g., 512Mi"
|
||||||
|
error={errors.memory_limit?.message}
|
||||||
|
{...register('memory_limit')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -37,6 +37,13 @@ const newModuleSchema = z.object({
|
|||||||
z.null(),
|
z.null(),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
sync_webhook_url: z
|
||||||
|
.union([
|
||||||
|
z.string().url("Invalid URL format").max(500, "sync_webhook_url must be at most 500 characters"),
|
||||||
|
z.literal("").transform(() => null),
|
||||||
|
z.null(),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
frontend_base_url: z
|
frontend_base_url: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'frontend_base_url is required')
|
.min(1, 'frontend_base_url is required')
|
||||||
@ -110,6 +117,7 @@ export const NewModuleModal = ({
|
|||||||
registered_by: null,
|
registered_by: null,
|
||||||
tenant_id: null,
|
tenant_id: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
|
sync_webhook_url: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,6 +148,7 @@ export const NewModuleModal = ({
|
|||||||
registered_by: null,
|
registered_by: null,
|
||||||
tenant_id: null,
|
tenant_id: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
|
sync_webhook_url: null,
|
||||||
});
|
});
|
||||||
clearErrors();
|
clearErrors();
|
||||||
setApiKey(null);
|
setApiKey(null);
|
||||||
@ -167,7 +176,7 @@ export const NewModuleModal = ({
|
|||||||
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
|
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
|
||||||
const validationErrors = error.response.data.details;
|
const validationErrors = error.response.data.details;
|
||||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||||
if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'frontend_base_url' || detail.path === 'backend_base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
|
if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'sync_webhook_url' || detail.path === 'frontend_base_url' || detail.path === 'backend_base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
|
||||||
setError(detail.path as keyof NewModuleFormData, {
|
setError(detail.path as keyof NewModuleFormData, {
|
||||||
type: 'server',
|
type: 'server',
|
||||||
message: detail.message,
|
message: detail.message,
|
||||||
@ -367,13 +376,21 @@ export const NewModuleModal = ({
|
|||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
label="Webhook URL"
|
label="X-API-Key Webhook URL"
|
||||||
placeholder="e.g., https://example.com/webhook"
|
placeholder="e.g., https://example.com/webhook/api-key"
|
||||||
error={errors.webhookurl?.message}
|
error={errors.webhookurl?.message}
|
||||||
|
helperText="URL to receive the system API key"
|
||||||
{...register('webhookurl')}
|
{...register('webhookurl')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<FormField
|
||||||
|
label="Identity Sync Webhook URL"
|
||||||
|
placeholder="e.g., https://example.com/webhook/sync"
|
||||||
|
error={errors.sync_webhook_url?.message}
|
||||||
|
helperText="URL to receive identity data (tenants, users, etc.)"
|
||||||
|
{...register('sync_webhook_url')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -157,8 +157,12 @@ export const ViewModuleModal = ({
|
|||||||
<p className="text-sm font-medium text-[#0e1b2a]">{module.health_endpoint}</p>
|
<p className="text-sm font-medium text-[#0e1b2a]">{module.health_endpoint}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Webhook URL</label>
|
<label className="text-xs font-medium text-[#6b7280] mb-1 block">X-API-Key Webhook URL</label>
|
||||||
<p className="text-sm font-medium text-[#0e1b2a]">{module.webhookurl || '-'}</p>
|
<p className="text-sm font-medium text-[#0e1b2a] break-all">{module.webhookurl || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Identity Sync Webhook URL</label>
|
||||||
|
<p className="text-sm font-medium text-[#0e1b2a] break-all">{module.sync_webhook_url || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
186
src/components/superadmin/WebhookSyncModal.tsx
Normal file
186
src/components/superadmin/WebhookSyncModal.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Loader2, Send } from 'lucide-react';
|
||||||
|
import { Modal, SecondaryButton, PrimaryButton, FormField } from '@/components/shared';
|
||||||
|
import { moduleService } from '@/services/module-service';
|
||||||
|
import type { Module } from '@/types/module';
|
||||||
|
|
||||||
|
const webhookSyncSchema = z.object({
|
||||||
|
sync_webhook_url: z.string().min(1, 'Webhook URL is required').url('Please enter a valid URL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type WebhookSyncFormData = z.infer<typeof webhookSyncSchema>;
|
||||||
|
|
||||||
|
interface WebhookSyncModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
moduleId: string | null;
|
||||||
|
onLoadModule: (id: string) => Promise<Module>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebhookSyncModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
moduleId,
|
||||||
|
onLoadModule,
|
||||||
|
}: WebhookSyncModalProps): ReactElement | null => {
|
||||||
|
const [module, setModule] = useState<Module | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm<WebhookSyncFormData>({
|
||||||
|
resolver: zodResolver(webhookSyncSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load module data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && moduleId) {
|
||||||
|
const loadModule = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
const data = await onLoadModule(moduleId);
|
||||||
|
setModule(data);
|
||||||
|
setValue('sync_webhook_url', data.sync_webhook_url || data.webhookurl || '', { shouldValidate: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error?.message || 'Failed to load module details');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadModule();
|
||||||
|
} else {
|
||||||
|
setModule(null);
|
||||||
|
reset({ sync_webhook_url: '' });
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, moduleId, onLoadModule, setValue, reset]);
|
||||||
|
|
||||||
|
const handleSync = async (data: WebhookSyncFormData): Promise<void> => {
|
||||||
|
if (!moduleId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
const response = await moduleService.syncWebhookData(moduleId, data.sync_webhook_url);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setSuccess('Identity data sync triggered successfully. The module application will receive the data shortly.');
|
||||||
|
} else {
|
||||||
|
setError('Failed to trigger sync');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error?.message || 'Failed to trigger identity sync');
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Identity Data Webhook Sync"
|
||||||
|
description="Sync identity data (tenants, roles, departments, designations, suppliers, users) to the module's webhook URL."
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3 w-full">
|
||||||
|
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
|
||||||
|
Close
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit(handleSync)}
|
||||||
|
disabled={isSyncing || isLoading || !isValid}
|
||||||
|
className="px-4 py-2.5 text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSyncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
Sync Data
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-5">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||||
|
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-4 mb-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
|
||||||
|
<p className="text-sm text-[#22c55e]">{success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && module && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5 mb-4">
|
||||||
|
<label className="text-sm font-medium text-[#0e1b2a]">Module</label>
|
||||||
|
<div className="p-3 bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-md">
|
||||||
|
<p className="text-sm font-semibold text-[#0e1b2a]">{module.name}</p>
|
||||||
|
<p className="text-xs text-[#64748b] font-mono">{module.module_id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Identity Sync Webhook URL"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://module-app.com/api/v1/webhooks/identity"
|
||||||
|
error={errors.sync_webhook_url?.message}
|
||||||
|
helperText="The super admin can trigger this to send the current state of all identity tables. Any changes to this URL will be saved back to the module registration."
|
||||||
|
{...register('sync_webhook_url')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4 bg-[rgba(17,40,104,0.04)] border border-[rgba(17,40,104,0.1)] rounded-md mt-2">
|
||||||
|
<h4 className="text-xs font-semibold text-[#112868] uppercase tracking-wider mb-2">Data Synchronized:</h4>
|
||||||
|
<ul className="grid grid-cols-2 gap-y-2 gap-x-4">
|
||||||
|
<li className="text-xs text-[#475569] flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Tenants
|
||||||
|
</li>
|
||||||
|
<li className="text-xs text-[#475569] flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Roles
|
||||||
|
</li>
|
||||||
|
<li className="text-xs text-[#475569] flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Departments
|
||||||
|
</li>
|
||||||
|
<li className="text-xs text-[#475569] flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Designations
|
||||||
|
</li>
|
||||||
|
<li className="text-xs text-[#475569] flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Suppliers
|
||||||
|
</li>
|
||||||
|
<li className="text-xs text-[#475569] flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-[#112868] rounded-full" /> Users
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,6 +3,9 @@
|
|||||||
// export { EditTenantModal } from './EditTenantModal';
|
// export { EditTenantModal } from './EditTenantModal';
|
||||||
export { NewModuleModal } from './NewModuleModal';
|
export { NewModuleModal } from './NewModuleModal';
|
||||||
export { ViewModuleModal } from './ViewModuleModal';
|
export { ViewModuleModal } from './ViewModuleModal';
|
||||||
|
export { EditModuleModal } from './EditModuleModal';
|
||||||
|
export { WebhookSyncModal } from './WebhookSyncModal';
|
||||||
|
export { ApikeyReissueModal } from './ApikeyReissueModal';
|
||||||
export { UsersTable } from './UsersTable';
|
export { UsersTable } from './UsersTable';
|
||||||
export { RolesTable } from './RolesTable';
|
export { RolesTable } from './RolesTable';
|
||||||
export { default as DepartmentsTable } from './DepartmentsTable';
|
export { default as DepartmentsTable } from './DepartmentsTable';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from "react";
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@ -8,32 +8,46 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
ActionDropdown,
|
||||||
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
|
// SecondaryButton,
|
||||||
import { Plus, ArrowUpDown } from 'lucide-react';
|
} from "@/components/shared";
|
||||||
import { moduleService } from '@/services/module-service';
|
import {
|
||||||
import type { Module } from '@/types/module';
|
ViewModuleModal,
|
||||||
|
NewModuleModal,
|
||||||
|
EditModuleModal,
|
||||||
|
WebhookSyncModal,
|
||||||
|
ApikeyReissueModal,
|
||||||
|
} from "@/components/superadmin";
|
||||||
|
import { Plus, ArrowUpDown, Eye, CloudSync, Edit, Key } from "lucide-react";
|
||||||
|
import { moduleService } from "@/services/module-service";
|
||||||
|
import type { Module } from "@/types/module";
|
||||||
|
|
||||||
// Helper function to format date
|
// Helper function to format date
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get status badge variant
|
// Helper function to get status badge variant
|
||||||
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
|
const getStatusVariant = (
|
||||||
if (!status) return 'process';
|
status: string | null,
|
||||||
|
): "success" | "failure" | "process" => {
|
||||||
|
if (!status) return "process";
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'running':
|
case "running":
|
||||||
case 'active':
|
case "active":
|
||||||
case 'healthy':
|
case "healthy":
|
||||||
return 'success';
|
return "success";
|
||||||
case 'stopped':
|
case "stopped":
|
||||||
case 'failed':
|
case "failed":
|
||||||
case 'unhealthy':
|
case "unhealthy":
|
||||||
return 'failure';
|
return "failure";
|
||||||
default:
|
default:
|
||||||
return 'process';
|
return "process";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,25 +82,35 @@ const Modules = (): ReactElement => {
|
|||||||
// View modal
|
// View modal
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||||
|
const [selectedModuleForEdit, setSelectedModuleForEdit] = useState<Module | null>(null);
|
||||||
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
|
const [webhookModalOpen, setWebhookModalOpen] = useState<boolean>(false);
|
||||||
|
const [reissueModalOpen, setReissueModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const fetchModules = async (
|
const fetchModules = async (
|
||||||
page: number,
|
page: number,
|
||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
status: string | null = null,
|
status: string | null = null,
|
||||||
sortBy: string[] | null = null
|
sortBy: string[] | null = null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await moduleService.getAll(page, itemsPerPage, status, sortBy);
|
const response = await moduleService.getAll(
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
status,
|
||||||
|
sortBy,
|
||||||
|
);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setModules(response.data);
|
setModules(response.data);
|
||||||
setPagination(response.pagination);
|
setPagination(response.pagination);
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to load modules');
|
setError("Failed to load modules");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error?.message || 'Failed to load modules');
|
setError(err?.response?.data?.error?.message || "Failed to load modules");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -103,8 +127,22 @@ const Modules = (): ReactElement => {
|
|||||||
setViewModalOpen(true);
|
setViewModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Webhook sync handler
|
||||||
|
const handleOpenWebhookSync = (moduleId: string): void => {
|
||||||
|
setSelectedModuleId(moduleId);
|
||||||
|
setWebhookModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reissue API key handler
|
||||||
|
const handleOpenReissueApiKey = (moduleId: string): void => {
|
||||||
|
setSelectedModuleId(moduleId);
|
||||||
|
setReissueModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Create module handler
|
// Create module handler
|
||||||
const handleCreateModule = async (data: any): Promise<{ api_key: { key: string } }> => {
|
const handleCreateModule = async (
|
||||||
|
data: any,
|
||||||
|
): Promise<{ api_key: { key: string } }> => {
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
const response = await moduleService.create(data);
|
const response = await moduleService.create(data);
|
||||||
@ -117,6 +155,27 @@ const Modules = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Edit module handler
|
||||||
|
const handleEditModule = (module: Module): void => {
|
||||||
|
setSelectedModuleForEdit(module);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update module handler
|
||||||
|
const handleUpdateModule = async (data: any): Promise<any> => {
|
||||||
|
if (!selectedModuleForEdit) return;
|
||||||
|
try {
|
||||||
|
setIsUpdating(true);
|
||||||
|
const response = await moduleService.update(selectedModuleForEdit.id, data);
|
||||||
|
await fetchModules(currentPage, limit, statusFilter, orderBy);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Load module for view
|
// Load module for view
|
||||||
const loadModule = async (id: string): Promise<Module> => {
|
const loadModule = async (id: string): Promise<Module> => {
|
||||||
const response = await moduleService.getById(id);
|
const response = await moduleService.getById(id);
|
||||||
@ -126,8 +185,8 @@ const Modules = (): ReactElement => {
|
|||||||
// Define table columns
|
// Define table columns
|
||||||
const columns: Column<Module>[] = [
|
const columns: Column<Module>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: "name",
|
||||||
label: 'Module Name',
|
label: "Module Name",
|
||||||
render: (module) => (
|
render: (module) => (
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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">
|
||||||
@ -136,78 +195,128 @@ const Modules = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<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]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{module.runtime_language || 'N/A'}
|
{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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created_at',
|
key: "version",
|
||||||
label: 'Registered Date',
|
label: "Version",
|
||||||
render: (module) => (
|
render: (module) => (
|
||||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(module.created_at)}</span>
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
|
{module.version}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
mobileLabel: 'Registered',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: "status",
|
||||||
label: 'Actions',
|
label: "Status",
|
||||||
align: 'right',
|
|
||||||
render: (module) => (
|
render: (module) => (
|
||||||
<div className="flex justify-end gap-3">
|
<StatusBadge variant={getStatusVariant(module.status)}>
|
||||||
<button
|
{module.status || "Unknown"}
|
||||||
type="button"
|
</StatusBadge>
|
||||||
onClick={() => handleViewModule(module.id)}
|
),
|
||||||
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
|
},
|
||||||
>
|
{
|
||||||
View
|
key: "health_status",
|
||||||
</button>
|
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">
|
||||||
|
<ActionDropdown
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
icon: <Eye className="w-3.5 h-3.5 shrink-0" />,
|
||||||
|
label: "View",
|
||||||
|
onClick: () => handleViewModule(module.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Edit className="w-3.5 h-3.5 shrink-0" />,
|
||||||
|
label: "Edit",
|
||||||
|
onClick: () => handleEditModule(module),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Key className="w-3.5 h-3.5 shrink-0" />,
|
||||||
|
label: "Reissue API Key",
|
||||||
|
onClick: () => handleOpenReissueApiKey(module.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CloudSync className="w-3.5 h-3.5 shrink-0" />,
|
||||||
|
label: "Webhook Sync",
|
||||||
|
onClick: () => handleOpenWebhookSync(module.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// key: "actions",
|
||||||
|
// label: "Actions",
|
||||||
|
// align: "right",
|
||||||
|
// render: (module) => (
|
||||||
|
// <div className="flex justify-end gap-3">
|
||||||
|
// <button
|
||||||
|
// type="button"
|
||||||
|
// onClick={() => handleViewModule(module.id)}
|
||||||
|
// className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
|
||||||
|
// >
|
||||||
|
// View
|
||||||
|
// </button>
|
||||||
|
// <PrimaryButton
|
||||||
|
// size="default"
|
||||||
|
// type="button"
|
||||||
|
// onClick={() => handleOpenWebhookSync(module.id)}
|
||||||
|
// >
|
||||||
|
// WebhookSync
|
||||||
|
// </PrimaryButton>
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mobile card renderer
|
// Mobile card renderer
|
||||||
@ -221,8 +330,12 @@ const Modules = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{module.name}</h3>
|
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">{module.module_id}</p>
|
{module.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">
|
||||||
|
{module.module_id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -238,7 +351,7 @@ const Modules = (): ReactElement => {
|
|||||||
<span className="text-[#9aa6b2]">Status:</span>
|
<span className="text-[#9aa6b2]">Status:</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StatusBadge variant={getStatusVariant(module.status)}>
|
<StatusBadge variant={getStatusVariant(module.status)}>
|
||||||
{module.status || 'Unknown'}
|
{module.status || "Unknown"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -246,7 +359,7 @@ const Modules = (): ReactElement => {
|
|||||||
<span className="text-[#9aa6b2]">Health:</span>
|
<span className="text-[#9aa6b2]">Health:</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StatusBadge variant={getStatusVariant(module.health_status)}>
|
<StatusBadge variant={getStatusVariant(module.health_status)}>
|
||||||
{module.health_status || 'N/A'}
|
{module.health_status || "N/A"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -256,15 +369,21 @@ const Modules = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Runtime:</span>
|
<span className="text-[#9aa6b2]">Runtime:</span>
|
||||||
<p className="text-[#0f1724] font-normal mt-1">{module.runtime_language || 'N/A'}</p>
|
<p className="text-[#0f1724] font-normal mt-1">
|
||||||
|
{module.runtime_language || "N/A"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Registered:</span>
|
<span className="text-[#9aa6b2]">Registered:</span>
|
||||||
<p className="text-[#6b7280] font-normal mt-1">{formatDate(module.created_at)}</p>
|
<p className="text-[#6b7280] font-normal mt-1">
|
||||||
|
{formatDate(module.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Description:</span>
|
<span className="text-[#9aa6b2]">Description:</span>
|
||||||
<p className="text-[#0f1724] font-normal mt-1 line-clamp-2">{module.description}</p>
|
<p className="text-[#0f1724] font-normal mt-1 line-clamp-2">
|
||||||
|
{module.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -274,8 +393,9 @@ const Modules = (): ReactElement => {
|
|||||||
<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.",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Table Container */}
|
{/* Table Container */}
|
||||||
@ -288,9 +408,9 @@ const Modules = (): ReactElement => {
|
|||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'running', label: 'Running' },
|
{ value: "running", label: "Running" },
|
||||||
{ value: 'stopped', label: 'Stopped' },
|
{ value: "stopped", label: "Stopped" },
|
||||||
{ value: 'failed', label: 'Failed' },
|
{ value: "failed", label: "Failed" },
|
||||||
]}
|
]}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@ -304,16 +424,16 @@ const Modules = (): ReactElement => {
|
|||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Sort by"
|
label="Sort by"
|
||||||
options={[
|
options={[
|
||||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
{ value: ["name", "asc"], label: "Name (A-Z)" },
|
||||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
{ value: ["name", "desc"], label: "Name (Z-A)" },
|
||||||
{ value: ['module_id', 'asc'], label: 'Module ID (A-Z)' },
|
{ value: ["module_id", "asc"], label: "Module ID (A-Z)" },
|
||||||
{ value: ['module_id', 'desc'], label: 'Module ID (Z-A)' },
|
{ value: ["module_id", "desc"], label: "Module ID (Z-A)" },
|
||||||
{ value: ['status', 'asc'], label: 'Status (A-Z)' },
|
{ value: ["status", "asc"], label: "Status (A-Z)" },
|
||||||
{ value: ['status', 'desc'], label: 'Status (Z-A)' },
|
{ value: ["status", "desc"], label: "Status (Z-A)" },
|
||||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
|
||||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
{ value: ["created_at", "desc"], label: "Created (Newest)" },
|
||||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
|
||||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
|
||||||
]}
|
]}
|
||||||
value={orderBy}
|
value={orderBy}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@ -396,6 +516,40 @@ const Modules = (): ReactElement => {
|
|||||||
moduleId={selectedModuleId}
|
moduleId={selectedModuleId}
|
||||||
onLoadModule={loadModule}
|
onLoadModule={loadModule}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Edit Module Modal */}
|
||||||
|
<EditModuleModal
|
||||||
|
isOpen={editModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setEditModalOpen(false);
|
||||||
|
setSelectedModuleForEdit(null);
|
||||||
|
}}
|
||||||
|
module={selectedModuleForEdit}
|
||||||
|
onSubmit={handleUpdateModule}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Webhook Sync Modal */}
|
||||||
|
<WebhookSyncModal
|
||||||
|
isOpen={webhookModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setWebhookModalOpen(false);
|
||||||
|
setSelectedModuleId(null);
|
||||||
|
}}
|
||||||
|
moduleId={selectedModuleId}
|
||||||
|
onLoadModule={loadModule}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Reissue API Key Modal */}
|
||||||
|
<ApikeyReissueModal
|
||||||
|
isOpen={reissueModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setReissueModalOpen(false);
|
||||||
|
setSelectedModuleId(null);
|
||||||
|
}}
|
||||||
|
moduleId={selectedModuleId}
|
||||||
|
onLoadModule={loadModule}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import apiClient from './api-client';
|
import apiClient from './api-client';
|
||||||
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse } from '@/types/module';
|
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse, UpdateModuleRequest } from '@/types/module';
|
||||||
|
|
||||||
export const moduleService = {
|
export const moduleService = {
|
||||||
getDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => {
|
getDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => {
|
||||||
@ -70,6 +70,10 @@ export const moduleService = {
|
|||||||
const response = await apiClient.post<CreateModuleResponse>('/modules', data);
|
const response = await apiClient.post<CreateModuleResponse>('/modules', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
update: async (id: string, data: UpdateModuleRequest): Promise<{ success: boolean; data: any }> => {
|
||||||
|
const response = await apiClient.put<{ success: boolean; data: any }>(`/modules/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
launch: async (id: string, tenantId?: string | null): Promise<LaunchModuleResponse> => {
|
launch: async (id: string, tenantId?: string | null): Promise<LaunchModuleResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
@ -88,4 +92,12 @@ export const moduleService = {
|
|||||||
const response = await apiClient.get<MyModulesResponse>(url);
|
const response = await apiClient.get<MyModulesResponse>(url);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
syncWebhookData: async (id: string, sync_webhook_url?: string): Promise<{ success: boolean; data: any }> => {
|
||||||
|
const response = await apiClient.post<{ success: boolean; data: any }>(`/modules/${id}/webhook-sync`, { sync_webhook_url });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
reissueApiKey: async (id: string, webhookurl?: string): Promise<{ success: boolean; data: { api_key: { key: string } } }> => {
|
||||||
|
const response = await apiClient.post<{ success: boolean; data: { api_key: { key: string } } }>(`/modules/${id}/reissue-key`, { webhookurl });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface Module {
|
|||||||
runtime_language: string | null;
|
runtime_language: string | null;
|
||||||
framework: string | null;
|
framework: string | null;
|
||||||
webhookurl: string | null;
|
webhookurl: string | null;
|
||||||
|
sync_webhook_url: string | null;
|
||||||
frontend_base_url: string;
|
frontend_base_url: string;
|
||||||
backend_base_url: string;
|
backend_base_url: string;
|
||||||
health_endpoint: string;
|
health_endpoint: string;
|
||||||
@ -54,11 +55,14 @@ export interface CreateModuleRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
version: string;
|
version: string;
|
||||||
status?: 'PENDING' | 'ACTIVE' | 'DEGRADED' | 'SUSPENDED' | 'DEPRECATED' | 'RETIRED';
|
status?: string;
|
||||||
runtime_language: string;
|
runtime_language: string;
|
||||||
framework?: string | null;
|
framework?: string | null;
|
||||||
base_url: string;
|
frontend_base_url: string;
|
||||||
|
backend_base_url: string;
|
||||||
health_endpoint: string;
|
health_endpoint: string;
|
||||||
|
webhookurl?: string | null;
|
||||||
|
sync_webhook_url?: string | null;
|
||||||
endpoints?: any | null;
|
endpoints?: any | null;
|
||||||
kafka_topics?: any | null;
|
kafka_topics?: any | null;
|
||||||
cpu_request?: string | null;
|
cpu_request?: string | null;
|
||||||
@ -75,6 +79,8 @@ export interface CreateModuleRequest {
|
|||||||
metadata?: any | null;
|
metadata?: any | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateModuleRequest extends Partial<Omit<CreateModuleRequest, 'module_id' | 'version' | 'status' | 'runtime_language' | 'framework' | 'webhookurl' | 'sync_webhook_url'>> {}
|
||||||
|
|
||||||
export interface CreateModuleResponse {
|
export interface CreateModuleResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user