diff --git a/web/package-lock.json b/web/package-lock.json index 5af736d..4019587 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.90.7", "axios": "^1.13.2", "daisyui": "^5.3.10", + "prop-types": "^15.8.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.66.0", @@ -2784,7 +2785,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3143,6 +3143,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3244,6 +3256,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3383,6 +3404,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/property-expr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", @@ -3451,6 +3483,12 @@ "react": "*" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", diff --git a/web/package.json b/web/package.json index d86172d..c222e1c 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.90.7", "axios": "^1.13.2", "daisyui": "^5.3.10", + "prop-types": "^15.8.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.66.0", diff --git a/web/src/components/ModalForm.jsx b/web/src/components/ModalForm.jsx new file mode 100644 index 0000000..4cdfecb --- /dev/null +++ b/web/src/components/ModalForm.jsx @@ -0,0 +1,189 @@ +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import InputText from './InputText'; +import { modelSchema } from '../schemas/modelSchema'; +import SelectInput from './SelectInput'; + +export default function ModalForm({ isOpen, onClose, mode, OnSubmit, model }) { + + // สถานะ Status Choices จาก AiModel (กำหนดเองใน Frontend) + const STATUS_OPTIONS = [ + { value: 'ACTIVE', name: 'ACTIVE (พร้อมใช้งาน)' }, + { value: 'INACTIVE', name: 'INACTIVE (ไม่ได้ใช้งาน)' }, + { value: 'TESTING', name: 'TESTING (กำลังทดสอบ)' }, + ]; + + // 1. RHF Setup + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting } + } = useForm({ + resolver: yupResolver(modelSchema), // ใช้ Schema สำหรับ Model Registry + // กำหนดค่าเริ่มต้นตาม mode + defaultValues: { + id: '', + name: '', + model_version: 'v1.0.0', + developer: '', + base_url: '', + inference_path: '', + status: 'INACTIVE', + auth_required: false, + } + }); + + // 2. Logic โหลดข้อมูล Model เข้า Form เมื่อเปิดโหมด Edit + useEffect(() => { + if (mode === 'edit' && model) { + // ใช้ reset() ของ RHF เพื่อโหลดข้อมูลลงในฟอร์ม + reset({ + id: model.id || '', // ID เป็น ReadOnly + name: model.name || '', + model_version: model.model_version || 'v1.0.0', + developer: model.developer || '', + base_url: model.base_url || '', + inference_path: model.inference_path || '', + status: model.status || 'INACTIVE', + auth_required: model.auth_required || false, + }); + } else { + // Reset ฟอร์มเป็นค่าเริ่มต้นเมื่อเปิดโหมด Add + reset(); + } + }, [mode, model, reset]); + + + // 3. Logic การ Submit + const onSubmitHandler = (data) => { + // เพิ่ม ID เข้าไปในข้อมูลถ้าเป็นโหมด Edit + if (mode === 'edit') { + data.id = model.id; + } + + // เรียกใช้ OnSubmit ที่มาจาก Page Component (เรียก Mutation) + OnSubmit(data); + + onClose(); // ปิด Modal หลังจาก Submit + }; + + const modalTitle = mode === 'add' ? 'ลงทะเบียน AI Model ใหม่' : `แก้ไข Model: ${model?.name}`; + const submitText = mode === 'add' ? 'ลงทะเบียน' : 'บันทึกการแก้ไข'; + + + return ( + +
{/* เพิ่มขนาด Modal */} +

{modalTitle}

+ + {/* ปุ่มปิด Modal */} + + + {/* 4. Form หลัก (ใช้ handleSubmit ของ RHF) */} +
+
+ + {/* ---------------------------------- */} + {/* กลุ่มที่ 1: ชื่อและสถานะ */} + {/* ---------------------------------- */} +
+
+ +
+
+ +
+
+ + {/* ---------------------------------- */} + {/* กลุ่มที่ 2: Base URL / Inference Path */} + {/* ---------------------------------- */} +
+
+ +
+
+ +
+
+ + {/* ---------------------------------- */} + {/* กลุ่มที่ 3: สถานะและการควบคุม */} + {/* ---------------------------------- */} +
+
+ + {errors.status &&

{errors.status.message}

} +
+ +
+
+ +
+
+
+ +
+ +
+ + +
+ + {/* 5. ปุ่ม Submit และ Cancel */} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/ModelRegistry/ModelTable.jsx b/web/src/components/ModelRegistry/ModelTable.jsx new file mode 100644 index 0000000..d874fc2 --- /dev/null +++ b/web/src/components/ModelRegistry/ModelTable.jsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { useTestConnection } from '../../services/modelRegistryApi'; // Hook สำหรับ Test Connection +import { FaCheckCircle, FaExclamationTriangle, FaTrash, FaEdit } from 'react-icons/fa'; + + +// ---------------------------------------------------- +// Helper Function: แสดง Badge สถานะ +// ---------------------------------------------------- +const getStatusBadge = (status) => { + const baseClass = "badge badge-sm"; + switch (status) { + case 'ACTIVE': return
ACTIVE
; + case 'TESTING': return
TESTING
; + case 'INACTIVE': return
INACTIVE
; + default: return
N/A
; + } +}; + +/** + * Component สำหรับแสดงตาราง Model Registry + * @param {Array} models - ข้อมูล Models ที่กรองแล้ว (จาก useModelList) + * @param {function} handleOpenEdit - Handler สำหรับเปิด Modal แก้ไข + * @param {function} handleDelete - Handler สำหรับลบ Model + * @param {boolean} deleteLoading - สถานะ Loading ของ Delete Mutation + */ +function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) { + // 1. Hook สำหรับทดสอบการเชื่อมต่อ (Test Connection) + // ใช้ Hook ตรงนี้ เพราะเป็น Logic ที่เกี่ยวข้องกับ Action ในตารางโดยตรง + const testConnectionMutation = useTestConnection(); + + const handleTest = (modelId) => { + // เรียกใช้ Mutation เพื่อทดสอบ + testConnectionMutation.mutate(modelId, { + onSuccess: (result) => { + const status = result.status === 'success' ? 'SUCCESS' : 'FAILED'; + alert(`Test Result: ${status}\nMessage: ${result.message || JSON.stringify(result.response_data)}`); + }, + onError: (error) => { + alert(`Test Failed! Error: ${error.message || 'Check network or base URL'}`); + }, + }); + }; + + // 2. Logic การแสดงผลตาราง + return ( + + {/* Table Head */} + + + + + + + + + + + {/* Table Body */} + + {models.map((model) => ( + + + + + + + {/* Actions (CRUD/Control) */} + + + ))} + +
Model/VersionBase URLDeveloperStatusActions
+ {model.name} + {model.model_version} + + {model.full_endpoint} + {model.developer || 'N/A'}{getStatusBadge(model.status)} + {/* Edit/Update Button (Protected by RBAC in Backend) */} + + + {/* Test Connection Button */} + + + {/* Delete Button (Protected by RBAC in Backend) */} + +
+ ); +} + +export default ModelTable; \ No newline at end of file diff --git a/web/src/components/ModelRegistry/ModelTopBar.jsx b/web/src/components/ModelRegistry/ModelTopBar.jsx new file mode 100644 index 0000000..e57be78 --- /dev/null +++ b/web/src/components/ModelRegistry/ModelTopBar.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { FaPlus, FaSearch } from 'react-icons/fa'; + +function ModelTopBar({ onOpenAdd, onSearch }) { + return ( +
+ {/* Search Bar */} +
+ onSearch(e.target.value)} + /> + +
+ + {/* Add Button */} + +
+ ); +} + +export default ModelTopBar; \ No newline at end of file diff --git a/web/src/components/SelectInput.jsx b/web/src/components/SelectInput.jsx new file mode 100644 index 0000000..31e3ba8 --- /dev/null +++ b/web/src/components/SelectInput.jsx @@ -0,0 +1,48 @@ +import React, { forwardRef } from "react"; +import PropTypes from "prop-types"; + +// 1. สร้าง Functional Component ปกติ (ไม่ใช้ forwardRef โดยตรง) +function SelectInputBase({ labelTitle, options, containerStyle, error, ...rest }, ref) { + return ( +
+ + + + + {error &&

{error.message}

} +
+ ); +} + +// 2. กำหนด propTypes ปกติ (จะไม่มี warning อีก) +SelectInputBase.propTypes = { + labelTitle: PropTypes.string.isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }) + ).isRequired, + containerStyle: PropTypes.string, + error: PropTypes.object, +}; + +// 3. ใช้ forwardRef กับตัวหลัก +const SelectInput = forwardRef(SelectInputBase); + +export default SelectInput; diff --git a/web/src/pages/data/ModelRegistry.jsx b/web/src/pages/data/ModelRegistry.jsx new file mode 100644 index 0000000..a8ad2dc --- /dev/null +++ b/web/src/pages/data/ModelRegistry.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import TitleCard from '../../components/TitleCard'; +import ModalForm from '../../components/ModalForm'; + +// Imports สำหรับ Hook API และ Components ย่อย +import { + useModelList, + useTestConnection, + useDeleteModel, + useCreateModel, + useUpdateModel, +} from '../../services/modelRegistryApi'; +import ModelTable from '../../components/ModelRegistry/ModelTable'; +import ModelTopBar from '../../components/ModelRegistry/ModelTopBar'; + + +function ModelRegistry() { + const [isOpen, setIsOpen] = useState(false); + const [modalMode, setModalMode] = useState("add"); + const [selectedModel, setSelectedModel] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); // State สำหรับ Search + + // TanStack Query Hooks + const { data: models, isLoading, isError } = useModelList(); + const deleteMutation = useDeleteModel(); + const createMutation = useCreateModel(); + const updateMutation = useUpdateModel(); + + + // ---------------------------------------------------- + // Logic การจัดการ + // ---------------------------------------------------- + const handleOpen = (mode, model = null) => { + setIsOpen(true); + setModalMode(mode); + setSelectedModel(model); + }; + + const handleSubmit = (newModelData) => { + if (modalMode === 'add') { + createMutation.mutate(newModelData); + } else if (modalMode === 'edit') { + updateMutation.mutate({ id: newModelData.id, data: newModelData }); + } + // ModalForm.jsx ควรจัดการ onClose() เองเมื่อ Submit สำเร็จ + }; + + const handleDelete = (modelId) => { + if (confirm("คุณแน่ใจหรือไม่ที่จะลบ Model ID: " + modelId + " นี้?")) { + deleteMutation.mutate(modelId); + } + }; + + // ---------------------------------------------------- + // Logic การกรองข้อมูล (Filter/Search) + // ---------------------------------------------------- + const filteredModels = models?.filter(model => + model.name.toLowerCase().includes(searchTerm.toLowerCase()) || + model.model_version.toLowerCase().includes(searchTerm.toLowerCase()) || + model.developer?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const isSubmitting = createMutation.isPending || updateMutation.isPending; + + + if (isLoading) return

กำลังโหลดรายการ Model Registry...

; + if (isError) return

ไม่สามารถดึงข้อมูล Model Registry ได้

; + + return ( +
+ handleOpen('add')} onSearch={setSearchTerm} />} + > +
+ {/* ส่ง useTestConnection ลงไปใน ModelTable เพื่อใช้จริง */} + handleOpen('edit', model)} + handleDelete={handleDelete} + deleteLoading={deleteMutation.isPending} + + // ส่ง Hook ไปให้ Component ย่อยใช้งาน + useTestConnectionHook={useTestConnection} + /> +
+
Total Models: {filteredModels?.length || 0}
+
+ + {/* Modal Form */} + setIsOpen(false)} + OnSubmit={handleSubmit} + mode={modalMode} + model={selectedModel} + isSubmitting={isSubmitting} + /> +
+ ); +} + +export default ModelRegistry; \ No newline at end of file diff --git a/web/src/routes/pageRoutes.jsx b/web/src/routes/pageRoutes.jsx index 3febe42..8c4aab3 100644 --- a/web/src/routes/pageRoutes.jsx +++ b/web/src/routes/pageRoutes.jsx @@ -1,5 +1,6 @@ import Dashboard from '../pages/Dashboard'; import BlankPage from '../pages/BlankPage'; +import ModelRegistry from '../pages/data/ModelRegistry'; // Array ของเส้นทางย่อยภายใต้ /dashboard/ const pageRoutes = [ @@ -8,6 +9,12 @@ const pageRoutes = [ path: '', // ตรงกับ /dashboard element: , }, + // --- Model Registry & Control --- + { + // Path: /dashboard/model-registry + path: 'model-registry', + element: , + }, // Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ { path: '*', diff --git a/web/src/schemas/modelSchema.js b/web/src/schemas/modelSchema.js new file mode 100644 index 0000000..2769391 --- /dev/null +++ b/web/src/schemas/modelSchema.js @@ -0,0 +1,11 @@ +import * as yup from 'yup'; + +export const modelSchema = yup.object().shape({ + name: yup.string().required('ต้องระบุชื่อโมเดล'), + model_version: yup.string().required('ต้องระบุเวอร์ชัน'), + developer: yup.string().nullable(), + base_url: yup.string().url('ต้องเป็นรูปแบบ URL ที่ถูกต้อง').required('ต้องระบุ Internal Base URL'), + inference_path: yup.string().required('ต้องระบุ Inference Path'), + status: yup.string().oneOf(['ACTIVE', 'INACTIVE', 'TESTING']).required(), + auth_required: yup.boolean(), +}); diff --git a/web/src/services/modelRegistryApi.js b/web/src/services/modelRegistryApi.js new file mode 100644 index 0000000..adf4450 --- /dev/null +++ b/web/src/services/modelRegistryApi.js @@ -0,0 +1,108 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axiosClient from './axiosClient'; // Axios Client ที่มี JWT Interceptor + +const STALE_TIME = 60 * 1000; // 1 นาที + +// ---------------------------------------------------- +// Model Registry Queries (GET) +// ---------------------------------------------------- + +/** + * Hook สำหรับดึงรายการ AI Model ทั้งหมด + * Endpoint: GET /api/v1/models/ (Protected by IsAuthenticated) + */ +export const useModelList = () => { + return useQuery({ + queryKey: ['modelList'], + queryFn: async () => { + const response = await axiosClient.get('/api/v1/models/'); + return response.data; // คาดหวัง Array ของ Model Objects + }, + staleTime: STALE_TIME, + }); +}; + +// ---------------------------------------------------- +// Model Control Mutations (POST, PATCH, DELETE) +// ---------------------------------------------------- + +/** + * Hook สำหรับลงทะเบียน Model ใหม่ (POST) + * Endpoint: POST /api/v1/models/ (Protected by IsAdminOrManager) + */ +export const useCreateModel = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (modelData) => { + // modelData: { name, model_version, base_url, inference_path, ... } + const response = await axiosClient.post('/api/v1/models/', modelData); + return response.data; + }, + onSuccess: () => { + alert('Model ถูกลงทะเบียนสำเร็จแล้ว!'); + queryClient.invalidateQueries({ queryKey: ['modelList'] }); + }, + onError: (error) => { + alert(`การลงทะเบียนล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`); + } + }); +}; + +/** + * Hook สำหรับลบ Model (DELETE) + * Endpoint: DELETE /api/v1/models/{id}/ (Protected by IsAdminOrManager) + */ +export const useDeleteModel = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (modelId) => { + const response = await axiosClient.delete(`/api/v1/models/${modelId}/`); + return response.data; + }, + onSuccess: () => { + alert('Model ถูกลบสำเร็จแล้ว!'); + queryClient.invalidateQueries({ queryKey: ['modelList'] }); + }, + onError: (error) => { + alert(`การลบล้มเหลว: ${error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์'}`); + } + }); +}; + +/** + * Hook สำหรับทดสอบการเชื่อมต่อ (POST /test-connection/) + * Endpoint: POST /api/v1/models/{id}/test-connection/ (Protected by IsAuthenticated) + */ +export const useTestConnection = () => { + return useMutation({ + mutationFn: async (modelId) => { + // Note: Endpoints นี้ต้องการ Body แต่ถ้า Backend ไม่ได้ใช้ เราส่ง Body เปล่า + const response = await axiosClient.post(`/api/v1/models/${modelId}/test-connection/`, {}); + return response.data; + }, + }); +}; + +/** + * Hook สำหรับแก้ไข Model ที่มีอยู่ (PUT/PATCH) + * Endpoint: PATCH /api/v1/models/{id}/ (Protected by IsAdminOrManager) + */ +export const useUpdateModel = () => { + const queryClient = useQueryClient(); + return useMutation({ + // mutationFn รับ object ที่มี { id: number, data: object } + mutationFn: async ({ id, data }) => { + // เราใช้ PATCH เพื่อส่งเฉพาะฟิลด์ที่มีการเปลี่ยนแปลง + const response = await axiosClient.patch(`/api/v1/models/${id}/`, data); + return response.data; + }, + onSuccess: () => { + alert('Model ถูกแก้ไขสำเร็จแล้ว!'); + // Invalidate query list เพื่อบังคับให้ตารางอัปเดตข้อมูล + queryClient.invalidateQueries({ queryKey: ['modelList'] }); + }, + onError: (error) => { + alert(`การแก้ไขล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`); + } + }); +}; \ No newline at end of file