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 (
+
+ );
+}
\ 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 */}
+
+
+ | Model/Version |
+ Base URL |
+ Developer |
+ Status |
+ Actions |
+
+
+
+ {/* Table Body */}
+
+ {models.map((model) => (
+
+ |
+ {model.name}
+ {model.model_version}
+ |
+
+ {model.full_endpoint}
+ |
+ {model.developer || 'N/A'} |
+ {getStatusBadge(model.status)} |
+
+ {/* Actions (CRUD/Control) */}
+
+ {/* 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