เพิ่ม Model Registry
This commit is contained in:
parent
66b95f6475
commit
119cf91905
40
web/package-lock.json
generated
40
web/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.7",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"daisyui": "^5.3.10",
|
"daisyui": "^5.3.10",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
@ -2784,7 +2785,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@ -3143,6 +3143,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@ -3244,6 +3256,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -3383,6 +3404,17 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/property-expr": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||||
@ -3451,6 +3483,12 @@
|
|||||||
"react": "*"
|
"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": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.7",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"daisyui": "^5.3.10",
|
"daisyui": "^5.3.10",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
|
|||||||
189
web/src/components/ModalForm.jsx
Normal file
189
web/src/components/ModalForm.jsx
Normal file
@ -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 (
|
||||||
|
<dialog id="model_form_modal" className="modal" open={isOpen}>
|
||||||
|
<div className="modal-box w-11/12 max-w-4xl"> {/* เพิ่มขนาด Modal */}
|
||||||
|
<h3 className="font-bold text-xl py-4">{modalTitle}</h3>
|
||||||
|
|
||||||
|
{/* ปุ่มปิด Modal */}
|
||||||
|
<button type="button" className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={onClose}>✕</button>
|
||||||
|
|
||||||
|
{/* 4. Form หลัก (ใช้ handleSubmit ของ RHF) */}
|
||||||
|
<form onSubmit={handleSubmit(onSubmitHandler)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
{/* กลุ่มที่ 1: ชื่อและสถานะ */}
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<InputText
|
||||||
|
labelTitle="ชื่อโมเดล (เช่น Spleen Segmentation)"
|
||||||
|
placeholder="Model Name"
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<InputText
|
||||||
|
labelTitle="เวอร์ชัน"
|
||||||
|
placeholder="v1.0.0"
|
||||||
|
type="text"
|
||||||
|
{...register('model_version')}
|
||||||
|
error={errors.model_version}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
{/* กลุ่มที่ 2: Base URL / Inference Path */}
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="w-full md:w-3/5">
|
||||||
|
<InputText
|
||||||
|
labelTitle="Base URL (Internal Service Address)"
|
||||||
|
placeholder="http://ai_model_server:8001"
|
||||||
|
type="url"
|
||||||
|
{...register('base_url')}
|
||||||
|
error={errors.base_url}
|
||||||
|
labelStyle='text-warning'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-2/5">
|
||||||
|
<InputText
|
||||||
|
labelTitle="Inference Path"
|
||||||
|
placeholder="inference/spleen/"
|
||||||
|
type="text"
|
||||||
|
{...register('inference_path')}
|
||||||
|
error={errors.inference_path}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
{/* กลุ่มที่ 3: สถานะและการควบคุม */}
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<SelectInput
|
||||||
|
labelTitle="สถานะบริการ"
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
{...register('status')} // RHF register
|
||||||
|
error={errors.status} // RHF error
|
||||||
|
/>
|
||||||
|
{errors.status && <p className="text-error text-xs mt-1">{errors.status.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-1/2 flex items-end">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label cursor-pointer space-x-2">
|
||||||
|
<span className="label-text">ต้องการ Internal Auth Key?</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
{...register('auth_required')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<InputText
|
||||||
|
labelTitle="ผู้พัฒนา/ทีม"
|
||||||
|
placeholder="Core AI Team"
|
||||||
|
type="text"
|
||||||
|
{...register('developer')}
|
||||||
|
error={errors.developer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. ปุ่ม Submit และ Cancel */}
|
||||||
|
<div className="modal-action mt-6">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={isSubmitting}>ยกเลิก</button>
|
||||||
|
<button type="submit" className={"btn btn-success" + (isSubmitting ? " loading" : "")} disabled={isSubmitting}>
|
||||||
|
{submitText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
web/src/components/ModelRegistry/ModelTable.jsx
Normal file
113
web/src/components/ModelRegistry/ModelTable.jsx
Normal file
@ -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 <div className={`${baseClass} badge-success font-medium`}>ACTIVE</div>;
|
||||||
|
case 'TESTING': return <div className={`${baseClass} badge-warning font-medium`}>TESTING</div>;
|
||||||
|
case 'INACTIVE': return <div className={`${baseClass} badge-info font-medium`}>INACTIVE</div>;
|
||||||
|
default: return <div className={`${baseClass} badge-neutral font-medium`}>N/A</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 className="table w-full table-zebra table-pin-rows">
|
||||||
|
{/* Table Head */}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className='w-1/6'>Model/Version</th>
|
||||||
|
<th className='w-1/4'>Base URL</th>
|
||||||
|
<th>Developer</th>
|
||||||
|
<th className='w-1/12'>Status</th>
|
||||||
|
<th className='w-1/6'>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<tbody>
|
||||||
|
{models.map((model) => (
|
||||||
|
<tr className="hover:bg-base-300" key={model.id}>
|
||||||
|
<td className='font-semibold'>
|
||||||
|
{model.name}
|
||||||
|
<span className="badge badge-xs badge-ghost ml-2">{model.model_version}</span>
|
||||||
|
</td>
|
||||||
|
<td className='text-xs max-w-xs overflow-hidden truncate tooltip' data-tip={model.full_endpoint}>
|
||||||
|
{model.full_endpoint}
|
||||||
|
</td>
|
||||||
|
<td>{model.developer || 'N/A'}</td>
|
||||||
|
<td>{getStatusBadge(model.status)}</td>
|
||||||
|
|
||||||
|
{/* Actions (CRUD/Control) */}
|
||||||
|
<td className='flex space-x-1'>
|
||||||
|
{/* Edit/Update Button (Protected by RBAC in Backend) */}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-secondary btn-xs tooltip"
|
||||||
|
data-tip="Edit Model Metadata"
|
||||||
|
onClick={() => handleOpenEdit(model)}
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Test Connection Button */}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-info btn-xs tooltip"
|
||||||
|
data-tip="Test Connection"
|
||||||
|
onClick={() => handleTest(model.id)}
|
||||||
|
disabled={testConnectionMutation.isPending}
|
||||||
|
>
|
||||||
|
{testConnectionMutation.isPending ?
|
||||||
|
<span className="loading loading-spinner loading-xs"></span> :
|
||||||
|
<FaCheckCircle className="w-3 h-3" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete Button (Protected by RBAC in Backend) */}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-error btn-xs tooltip"
|
||||||
|
data-tip="Delete Model"
|
||||||
|
onClick={() => handleDelete(model.id)}
|
||||||
|
disabled={deleteLoading || testConnectionMutation.isPending}
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelTable;
|
||||||
26
web/src/components/ModelRegistry/ModelTopBar.jsx
Normal file
26
web/src/components/ModelRegistry/ModelTopBar.jsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaPlus, FaSearch } from 'react-icons/fa';
|
||||||
|
|
||||||
|
function ModelTopBar({ onOpenAdd, onSearch }) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center space-x-3'>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search models..."
|
||||||
|
className="input input-bordered input-sm w-32 md:w-auto"
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-square btn-sm"><FaSearch /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Button */}
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={onOpenAdd}>
|
||||||
|
<FaPlus className="w-4 h-4" /> Register New Model
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelTopBar;
|
||||||
48
web/src/components/SelectInput.jsx
Normal file
48
web/src/components/SelectInput.jsx
Normal file
@ -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 (
|
||||||
|
<div className={`form-control w-full ${containerStyle || ""}`}>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text text-base-content">{labelTitle}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
{...rest}
|
||||||
|
className={`select select-bordered w-full ${error ? "select-error" : ""}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
--- กรุณาเลือก ---
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{error && <p className="text-error text-xs mt-1">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
104
web/src/pages/data/ModelRegistry.jsx
Normal file
104
web/src/pages/data/ModelRegistry.jsx
Normal file
@ -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 <p className="text-center p-8">กำลังโหลดรายการ Model Registry...</p>;
|
||||||
|
if (isError) return <p className="text-center p-8 text-error">ไม่สามารถดึงข้อมูล Model Registry ได้</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-0">
|
||||||
|
<TitleCard
|
||||||
|
title="AI Model Registry & Control"
|
||||||
|
topMargin="mt-0"
|
||||||
|
TopSideButtons={<ModelTopBar onOpenAdd={() => handleOpen('add')} onSearch={setSearchTerm} />}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto w-full">
|
||||||
|
{/* ส่ง useTestConnection ลงไปใน ModelTable เพื่อใช้จริง */}
|
||||||
|
<ModelTable
|
||||||
|
models={filteredModels || []}
|
||||||
|
handleOpenEdit={(model) => handleOpen('edit', model)}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
deleteLoading={deleteMutation.isPending}
|
||||||
|
|
||||||
|
// ส่ง Hook ไปให้ Component ย่อยใช้งาน
|
||||||
|
useTestConnectionHook={useTestConnection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='mt-4 text-xs text-base-content/70'>Total Models: {filteredModels?.length || 0}</div>
|
||||||
|
</TitleCard>
|
||||||
|
|
||||||
|
{/* Modal Form */}
|
||||||
|
<ModalForm
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
OnSubmit={handleSubmit}
|
||||||
|
mode={modalMode}
|
||||||
|
model={selectedModel}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelRegistry;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import Dashboard from '../pages/Dashboard';
|
import Dashboard from '../pages/Dashboard';
|
||||||
import BlankPage from '../pages/BlankPage';
|
import BlankPage from '../pages/BlankPage';
|
||||||
|
import ModelRegistry from '../pages/data/ModelRegistry';
|
||||||
|
|
||||||
// Array ของเส้นทางย่อยภายใต้ /dashboard/
|
// Array ของเส้นทางย่อยภายใต้ /dashboard/
|
||||||
const pageRoutes = [
|
const pageRoutes = [
|
||||||
@ -8,6 +9,12 @@ const pageRoutes = [
|
|||||||
path: '', // ตรงกับ /dashboard
|
path: '', // ตรงกับ /dashboard
|
||||||
element: <Dashboard />,
|
element: <Dashboard />,
|
||||||
},
|
},
|
||||||
|
// --- Model Registry & Control ---
|
||||||
|
{
|
||||||
|
// Path: /dashboard/model-registry
|
||||||
|
path: 'model-registry',
|
||||||
|
element: <ModelRegistry />,
|
||||||
|
},
|
||||||
// Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
|
// Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
|
|||||||
11
web/src/schemas/modelSchema.js
Normal file
11
web/src/schemas/modelSchema.js
Normal file
@ -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(),
|
||||||
|
});
|
||||||
108
web/src/services/modelRegistryApi.js
Normal file
108
web/src/services/modelRegistryApi.js
Normal file
@ -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 || 'โปรดตรวจสอบข้อมูล'}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user