เพิ่ม Model Registry

This commit is contained in:
Flook 2025-11-09 16:46:19 +07:00
parent 66b95f6475
commit 119cf91905
10 changed files with 646 additions and 1 deletions

40
web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View File

@ -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: <Dashboard />,
},
// --- Model Registry & Control ---
{
// Path: /dashboard/model-registry
path: 'model-registry',
element: <ModelRegistry />,
},
// Fallback
{
path: '*',

View 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(),
});

View 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 || 'โปรดตรวจสอบข้อมูล'}`);
}
});
};