พัฒนา Frontend (Web) และ Backend (Django DRF)
This commit is contained in:
parent
e49d6fabbd
commit
8a2a4db69a
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
9
.idea/misc.xml
generated
Normal file
9
.idea/misc.xml
generated
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="Python 3.12 (2)" project-jdk-type="Python SDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/monorepo-starter-template.iml" filepath="$PROJECT_DIR$/.idea/monorepo-starter-template.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/monorepo-starter-template.iml
generated
Normal file
9
.idea/monorepo-starter-template.iml
generated
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,11 +1,34 @@
|
||||
# users/admin.py
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin # เปลี่ยนชื่อเป็น BaseUserAdmin เพื่อป้องกันการซ้ำซ้อน
|
||||
from .models import CustomUser
|
||||
|
||||
# ใช้ UserAdmin เพื่อให้ได้ UI ที่ครบถ้วนสำหรับการจัดการผู้ใช้
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
# Fieldsets หรือ list_display (ถ้าต้องการปรับแต่ง UI)
|
||||
pass
|
||||
# สร้าง Custom User Admin เพื่อจัดการฟิลด์เพิ่มเติม
|
||||
class CustomUserAdmin(BaseUserAdmin):
|
||||
# 1. กำหนดฟิลด์ที่จะแสดงในหน้าลิสต์ผู้ใช้
|
||||
list_display = (
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'role'
|
||||
)
|
||||
|
||||
# ลงทะเบียน CustomUser ด้วย CustomUserAdmin
|
||||
# 2. กำหนดโครงสร้างและการจัดกลุ่มฟิลด์ในหน้าแก้ไขผู้ใช้
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Personal info', {'fields': ('first_name', 'last_name', 'email', 'phone_number')}), # 🔑 เพิ่ม phone_number
|
||||
('Role and Permissions', { # สร้างกลุ่มใหม่เพื่อจัดระเบียบ
|
||||
'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
|
||||
}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
# 3. กำหนดฟิลด์ที่ใช้ในการค้นหาในหน้าลิสต์ผู้ใช้
|
||||
search_fields = ('username', 'email', 'phone_number')
|
||||
|
||||
# ลงทะเบียน CustomUser ด้วย CustomUserAdmin ที่ถูกปรับแต่ง
|
||||
admin.site.register(CustomUser, CustomUserAdmin)
|
||||
18
backend/accounts/migrations/0002_customuser_role.py
Normal file
18
backend/accounts/migrations/0002_customuser_role.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-08 22:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('ADMIN', 'Administrator'), ('OPERATOR', 'Operator'), ('VIEWER', 'Viewer')], default='VIEWER', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -1,20 +1,19 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
# เพิ่มฟิลด์ที่ต้องการ เช่น phone_number
|
||||
phone_number = models.CharField(max_length=20, blank=True, null=True, unique=True)
|
||||
# ตัวอย่าง:
|
||||
# is_customer = models.BooleanField(default=False)
|
||||
|
||||
# ถ้าต้องการให้ email เป็น Unique (ไม่ซ้ำกัน)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
# Field ที่ใช้สำหรับการล็อกอิน (ถ้าไม่ใช่ username)
|
||||
# USERNAME_FIELD = 'email'
|
||||
# REQUIRED_FIELDS = ['username'] # ถ้าเปลี่ยน USERNAME_FIELD ต้องกำหนด REQUIRED_FIELDS
|
||||
# เพิ่มฟิลด์ Role สำหรับ RBAC ที่กำหนดเอง
|
||||
ROLE_CHOICES = [
|
||||
('ADMIN', 'Administrator'),
|
||||
('OPERATOR', 'Operator'),
|
||||
('VIEWER', 'Viewer'),
|
||||
]
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='VIEWER')
|
||||
|
||||
# ไม่ต้องใส่ pass เพราะมันสืบทอดมาจาก AbstractUser
|
||||
# ไม่ต้องเปลี่ยนส่วนอื่นๆ ถ้าสืบทอดจาก AbstractUser
|
||||
pass
|
||||
|
||||
@ -12,5 +12,12 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
# Serializer สำหรับการดึงข้อมูล (ใช้แสดงข้อมูลผู้ใช้ปัจจุบัน)
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ('id', 'username', 'email', 'phone_number', 'first_name', 'last_name')
|
||||
read_only_fields = ('id', 'username')
|
||||
fields = (
|
||||
'id', 'username', 'email', 'phone_number', 'first_name', 'last_name',
|
||||
# เพิ่มฟิลด์สถานะสิทธิ์/Role สำหรับ RBAC
|
||||
'is_active', 'is_staff', 'is_superuser',
|
||||
# เพิ่ม 'role' ใน model
|
||||
'role',
|
||||
)
|
||||
# ตั้งค่า is_active, is_staff, is_superuser เป็น read_only
|
||||
read_only_fields = ('id', 'username', 'is_active', 'is_staff', 'is_superuser', 'role')
|
||||
|
||||
@ -50,6 +50,8 @@ THIRD_PARTY_APPS = [
|
||||
'django_bolt',
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'drf_spectacular',
|
||||
'drf_spectacular_sidecar',
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
@ -57,6 +59,7 @@ LOCAL_APPS = [
|
||||
'accounts',
|
||||
'user_profile',
|
||||
'permissions',
|
||||
'model_registry'
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
@ -171,6 +174,8 @@ AUTH_USER_MODEL = 'accounts.CustomUser' # ต้องชี้ไปที่ M
|
||||
|
||||
# 2. ตั้งค่า REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
# ใช้ JWT เป็นวิธีการยืนยันตัวตนหลัก
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
@ -228,4 +233,61 @@ CELERY_RESULT_BACKEND = f'redis://{REDIS_HOST}:{REDIS_PORT}/0'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'Asia/Bangkok'
|
||||
CELERY_TIMEZONE = 'Asia/Bangkok'
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# SPECTACULAR CONFIGURATION
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
# ชื่อโครงการของคุณ
|
||||
'TITLE': 'MONAI MLOps Service API',
|
||||
|
||||
# คำอธิบายสั้นๆ ของโครงการ
|
||||
'DESCRIPTION': 'API Gateway and Model Registry for Medical AI Inference Services (Django DRF + FastAPI/MONAI).',
|
||||
|
||||
# เวอร์ชัน API
|
||||
'VERSION': 'v1',
|
||||
|
||||
# กำหนด URL ที่ใช้ในการสร้าง Schema (เพื่อให้รวมทุก Endpoints)
|
||||
'SERVE_URLCONF': 'core.urls',
|
||||
|
||||
# กำหนดให้ใช้ Sidecar เพื่อการแสดงผลแบบออฟไลน์
|
||||
'SWAGGER_UI_DIST': 'SIDECAR',
|
||||
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
|
||||
'REDOC_DIST': 'SIDECAR',
|
||||
|
||||
# กำหนด Authentication สำหรับ Doc (แสดงช่องใส่ JWT Token ใน Swagger)
|
||||
'SECURITY': [
|
||||
{
|
||||
'BearerAuth': { # ชื่อนี้จะใช้ใน Components/SecuritySchemes
|
||||
'type': 'http',
|
||||
'scheme': 'bearer',
|
||||
'bearerFormat': 'JWT',
|
||||
}
|
||||
}
|
||||
],
|
||||
# -----------------------------------------------------------
|
||||
# การจัดเรียงและกำหนดชื่อ Tags
|
||||
# -----------------------------------------------------------
|
||||
'TAGS': [
|
||||
{
|
||||
'name': '1. Authentication & User Management',
|
||||
'description': 'Endpoints for JWT token management, login, and user profile operations (Djoser).',
|
||||
#'exclude': False, # ไม่ต้องระบุ, แต่ DRF Spectacular จะรู้ว่าต้องใช้ Tag นี้แทน 'v1'
|
||||
},
|
||||
{
|
||||
# ให้เป็นชื่อใหม่ที่ไม่ซ้ำกัน หรือจัดการลำดับใหม่**
|
||||
'name': '2. Model Registry & Metadata Management',
|
||||
'description': 'CRUD operations for managing AI Service metadata, URLs, and versions.',
|
||||
},
|
||||
{
|
||||
'name': '3. MLOps Control & Service Orchestration',
|
||||
'description': 'Actions to test service health, set model status (Hot Swap), and proxy inference requests.',
|
||||
},
|
||||
],
|
||||
'POSTPROCESSING_HOOKS': [
|
||||
'core.spectacular_hooks.rename_djoser_tags',
|
||||
],
|
||||
# ตั้งค่าอื่น ๆ (ถ้าจำเป็น)
|
||||
}
|
||||
13
backend/core/spectacular_hooks.py
Normal file
13
backend/core/spectacular_hooks.py
Normal file
@ -0,0 +1,13 @@
|
||||
# core/spectacular_hooks.py
|
||||
def rename_djoser_tags(result, generator, request, public):
|
||||
"""
|
||||
เปลี่ยน Tag 'v1' ของ Djoser ให้เป็นชื่อ Tag ที่ต้องการ
|
||||
"""
|
||||
# result คือ OpenAPI spec เป็น dict
|
||||
paths = result.get('paths', {})
|
||||
for path, path_item in paths.items():
|
||||
for method, operation in path_item.items():
|
||||
tags = operation.get('tags', [])
|
||||
if 'v1' in tags:
|
||||
operation['tags'] = ['1. Authentication & User Management']
|
||||
return result
|
||||
@ -18,13 +18,34 @@ from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from model_registry.views.ai_model_viewset import AiModelRegistryViewSet
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||
|
||||
# 1. กำหนดตัวแปร router ก่อนใช้งาน
|
||||
router = DefaultRouter()
|
||||
|
||||
# 2. ลงทะเบียน API ViewSets (Project-Level Routing)
|
||||
# URL: /api/v1/models/
|
||||
router.register(
|
||||
r'models',
|
||||
AiModelRegistryViewSet,
|
||||
basename='aimodel' # basename จำเป็นเมื่อ ViewSet ไม่ได้สืบทอดจาก Model
|
||||
)
|
||||
|
||||
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
# 2. Endpoints สำหรับการยืนยันตัวตน (Login, Logout, Register)
|
||||
# Schema OpenAPI
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
|
||||
# Swagger UI
|
||||
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
|
||||
# Redoc UI
|
||||
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||
|
||||
# Endpoints สำหรับการยืนยันตัวตน (Login, Logout, Register)
|
||||
path('api/v1/auth/', include('djoser.urls')), # /users/ (Register/Update/Me), /users/set_password
|
||||
path('api/v1/auth/', include('djoser.urls.jwt')), # /jwt/create (Login), /jwt/refresh (Refresh Token)
|
||||
|
||||
|
||||
0
backend/model_registry/__init__.py
Normal file
0
backend/model_registry/__init__.py
Normal file
3
backend/model_registry/admin.py
Normal file
3
backend/model_registry/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/model_registry/apps.py
Normal file
6
backend/model_registry/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModelRegistryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'model_registry'
|
||||
33
backend/model_registry/migrations/0001_initial.py
Normal file
33
backend/model_registry/migrations/0001_initial.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-08 03:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AiModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True, verbose_name='ชื่อโมเดล')),
|
||||
('model_version', models.CharField(default='v1.0.0', max_length=50, verbose_name='เวอร์ชัน')),
|
||||
('developer', models.CharField(blank=True, max_length=255, null=True, verbose_name='ผู้พัฒนา/ทีม')),
|
||||
('base_url', models.URLField(max_length=500, verbose_name='Base URL ของ AI Service (Internal)')),
|
||||
('inference_path', models.CharField(max_length=255, verbose_name='Endpoint Path สำหรับ Inference')),
|
||||
('status', models.CharField(choices=[('ACTIVE', 'ใช้งาน'), ('INACTIVE', 'ไม่ใช้งาน'), ('TESTING', 'กำลังทดสอบ')], default='INACTIVE', max_length=20, verbose_name='สถานะบริการ')),
|
||||
('auth_required', models.BooleanField(default=False, verbose_name='ต้องการ Internal Auth Key')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AI Model Registry',
|
||||
'verbose_name_plural': 'AI Model Registry',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/model_registry/migrations/__init__.py
Normal file
0
backend/model_registry/migrations/__init__.py
Normal file
37
backend/model_registry/models.py
Normal file
37
backend/model_registry/models.py
Normal file
@ -0,0 +1,37 @@
|
||||
from django.db import models
|
||||
|
||||
class AiModel(models.Model):
|
||||
# 1. ข้อมูลพื้นฐานและการลงทะเบียน
|
||||
name = models.CharField(max_length=255, unique=True, verbose_name="ชื่อโมเดล")
|
||||
model_version = models.CharField(max_length=50, default="v1.0.0", verbose_name="เวอร์ชัน")
|
||||
developer = models.CharField(max_length=255, blank=True, null=True, verbose_name="ผู้พัฒนา/ทีม")
|
||||
|
||||
# 2. ข้อมูลการเรียกใช้ (API Management)
|
||||
base_url = models.URLField(max_length=500, verbose_name="Base URL ของ AI Service (Internal)")
|
||||
inference_path = models.CharField(max_length=255, verbose_name="Endpoint Path สำหรับ Inference")
|
||||
# ตัวอย่าง: base_url="http://ai_model_server:8001/", inference_path="inference/spleen/"
|
||||
|
||||
# 3. สถานะและการควบคุม
|
||||
status_choices = [
|
||||
('ACTIVE', 'ใช้งาน'),
|
||||
('INACTIVE', 'ไม่ใช้งาน'),
|
||||
('TESTING', 'กำลังทดสอบ'),
|
||||
]
|
||||
status = models.CharField(max_length=20, choices=status_choices, default='INACTIVE', verbose_name="สถานะบริการ")
|
||||
|
||||
auth_required = models.BooleanField(default=False, verbose_name="ต้องการ Internal Auth Key")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def full_inference_url(self):
|
||||
# สร้าง Full URL สำหรับ DRF ใช้เรียก (Proxy)
|
||||
return f"{self.base_url.rstrip('/')}/{self.inference_path.lstrip('/')}"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.model_version}) - {self.status}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "AI Model Registry"
|
||||
verbose_name_plural = "AI Model Registry"
|
||||
# สามารถเพิ่ม unique_together (name, model_version) ได้ในภายหลัง
|
||||
0
backend/model_registry/repositories/__init__.py
Normal file
0
backend/model_registry/repositories/__init__.py
Normal file
23
backend/model_registry/repositories/ai_model_repository.py
Normal file
23
backend/model_registry/repositories/ai_model_repository.py
Normal file
@ -0,0 +1,23 @@
|
||||
from ..models import AiModel
|
||||
from typing import List, Optional
|
||||
|
||||
class AiModelRepository:
|
||||
"""จัดการการเข้าถึงฐานข้อมูลสำหรับ AiModel โดยเฉพาะ"""
|
||||
|
||||
def get_all(self) -> List[AiModel]:
|
||||
return AiModel.objects.all().order_by('name', '-model_version')
|
||||
|
||||
def get_by_id(self, pk: int) -> Optional[AiModel]:
|
||||
try:
|
||||
return AiModel.objects.get(pk=pk)
|
||||
except AiModel.DoesNotExist:
|
||||
return None
|
||||
|
||||
def save(self, model_instance: AiModel) -> AiModel:
|
||||
"""ใช้สำหรับ Create และ Update"""
|
||||
model_instance.save()
|
||||
return model_instance
|
||||
|
||||
def delete(self, model_instance: AiModel):
|
||||
model_instance.delete()
|
||||
# Logic อื่น ๆ ที่เกี่ยวข้องกับการเข้าถึง DB
|
||||
0
backend/model_registry/serializers/__init__.py
Normal file
0
backend/model_registry/serializers/__init__.py
Normal file
16
backend/model_registry/serializers/ai_model_serializer.py
Normal file
16
backend/model_registry/serializers/ai_model_serializer.py
Normal file
@ -0,0 +1,16 @@
|
||||
from rest_framework import serializers
|
||||
from ..models import AiModel
|
||||
|
||||
class AiModelSerializer(serializers.ModelSerializer):
|
||||
# Read-only field สำหรับแสดงผล Full URL ที่ DRF จะใช้ Proxy
|
||||
full_endpoint = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AiModel
|
||||
fields = '__all__'
|
||||
read_only_fields = ('created_at', 'updated_at', 'full_endpoint')
|
||||
|
||||
def get_full_endpoint(self, obj: AiModel) -> str:
|
||||
return obj.full_inference_url()
|
||||
|
||||
# สามารถเพิ่ม Custom Validation ที่นี่: เช่น ตรวจสอบความถูกต้องของ base_url format
|
||||
0
backend/model_registry/services/__init__.py
Normal file
0
backend/model_registry/services/__init__.py
Normal file
58
backend/model_registry/services/ai_model_service.py
Normal file
58
backend/model_registry/services/ai_model_service.py
Normal file
@ -0,0 +1,58 @@
|
||||
from ..repositories.ai_model_repository import AiModelRepository
|
||||
from ..models import AiModel
|
||||
from typing import Optional, List
|
||||
import requests
|
||||
from django.conf import settings # ใช้สำหรับ Configs ภายนอก/Secrets
|
||||
|
||||
class ConnectionError(Exception):
|
||||
"""Custom Exception สำหรับการเชื่อมต่อล้มเหลว"""
|
||||
pass
|
||||
|
||||
class AiModelService:
|
||||
def __init__(self, repository: AiModelRepository):
|
||||
self.repo = repository
|
||||
|
||||
def get_all_models(self) -> List[AiModel]:
|
||||
return self.repo.get_all()
|
||||
|
||||
def create_model(self, validated_data: dict) -> AiModel:
|
||||
# Business Logic: ตรวจสอบความซ้ำซ้อน, การตั้งค่าเริ่มต้น
|
||||
new_model = AiModel(**validated_data)
|
||||
return self.repo.save(new_model)
|
||||
|
||||
def test_connection(self, pk: int) -> dict:
|
||||
"""Logic สำหรับเรียก HTTP Ping ไปยัง AI Service ภายนอก"""
|
||||
model = self.repo.get_by_id(pk)
|
||||
if not model:
|
||||
raise ValueError("Model not found")
|
||||
|
||||
# ใช้ Root URL ของ Service สำหรับ Ping
|
||||
test_url = model.base_url.rstrip('/') + '/'
|
||||
|
||||
try:
|
||||
# ใช้ requests เพื่อเรียก HTTP (ใช้ httpx ถ้าต้องการ Async)
|
||||
response = requests.get(test_url, timeout=5)
|
||||
response.raise_for_status() # Raise error for 4xx or 5xx
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully pinged {test_url}",
|
||||
"response_status": response.status_code,
|
||||
"response_data": response.json()
|
||||
}
|
||||
except requests.exceptions.RequestException as e:
|
||||
# แปลง HTTP Error เป็น Custom Exception ของ Business Logic
|
||||
raise ConnectionError(f"Connection failed to {test_url}: {e}")
|
||||
|
||||
def set_status(self, pk: int, new_status: str) -> Optional[AiModel]:
|
||||
# Business Logic: การเปลี่ยนสถานะต้องผ่าน Service
|
||||
model = self.repo.get_by_id(pk)
|
||||
if model:
|
||||
# Logic: มีเงื่อนไขว่าต้องผ่าน TESTING ก่อนไป ACTIVE
|
||||
if model.status == 'TESTING' and new_status == 'ACTIVE':
|
||||
# Logic: Trigger Hot Swap ใน FastAPI Service ก่อนเปลี่ยนสถานะ (ถ้าต้องการ)
|
||||
pass
|
||||
|
||||
model.status = new_status
|
||||
return self.repo.save(model)
|
||||
return None
|
||||
3
backend/model_registry/tests.py
Normal file
3
backend/model_registry/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
backend/model_registry/views.py
Normal file
3
backend/model_registry/views.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
backend/model_registry/views/__init__.py
Normal file
0
backend/model_registry/views/__init__.py
Normal file
72
backend/model_registry/views/ai_model_viewset.py
Normal file
72
backend/model_registry/views/ai_model_viewset.py
Normal file
@ -0,0 +1,72 @@
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from ..models import AiModel
|
||||
from ..serializers.ai_model_serializer import AiModelSerializer
|
||||
from ..repositories.ai_model_repository import AiModelRepository
|
||||
from ..services.ai_model_service import AiModelService, ConnectionError
|
||||
|
||||
# Dependency Injection: สร้าง Instance ของ Repository และ Service
|
||||
# สำหรับโปรเจกต์ขนาดเล็กสามารถทำแบบนี้ได้
|
||||
repo = AiModelRepository()
|
||||
service = AiModelService(repository=repo)
|
||||
|
||||
@extend_schema_view(
|
||||
# 1. การดำเนินการ CRUD ปกติ: จัดอยู่ใน Model Registry
|
||||
list=extend_schema(tags=['2. Model Registry & Metadata Management']),
|
||||
retrieve=extend_schema(tags=['2. Model Registry & Metadata Management']),
|
||||
create=extend_schema(tags=['2. Model Registry & Metadata Management']),
|
||||
update=extend_schema(tags=['2. Model Registry & Metadata Management']),
|
||||
partial_update=extend_schema(tags=['2. Model Registry & Metadata Management']),
|
||||
destroy=extend_schema(tags=['2. Model Registry & Metadata Management']),
|
||||
)
|
||||
class AiModelRegistryViewSet(viewsets.ModelViewSet):
|
||||
queryset = AiModel.objects.all()
|
||||
serializer_class = AiModelSerializer
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
# -----------------------------------------------
|
||||
# Override Create/List (เรียกใช้ Service Layer)
|
||||
# -----------------------------------------------
|
||||
def get_queryset(self):
|
||||
# List/Retrieve จะเรียก Service Layer แทนการเรียก ORM โดยตรง
|
||||
return service.get_all_models()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
new_model = service.create_model(serializer.validated_data)
|
||||
return Response(self.get_serializer(new_model).data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# -----------------------------------------------
|
||||
# Custom Action: ทดสอบการเชื่อมต่อ
|
||||
# -----------------------------------------------
|
||||
@extend_schema(tags=['3. MLOps Control & Service Orchestration'])
|
||||
@action(detail=True, methods=['post'], url_path='test-connection')
|
||||
def test_connection(self, request, pk=None):
|
||||
try:
|
||||
result = service.test_connection(pk=int(pk))
|
||||
return Response(result)
|
||||
except ValueError as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||
except ConnectionError as e:
|
||||
# Response ด้วย error ที่ชัดเจนจากการเชื่อมต่อ
|
||||
return Response({"status": "error", "detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(tags=['3. MLOps Control & Service Orchestration'])
|
||||
@action(detail=True, methods=['patch'], url_path='set-status')
|
||||
def set_status(self, request, pk=None):
|
||||
new_status = request.data.get('status')
|
||||
if not new_status or new_status not in [choice[0] for choice in AiModel.status_choices]:
|
||||
return Response({"detail": "Invalid status provided."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
updated_model = service.set_status(pk=int(pk), new_status=new_status)
|
||||
if updated_model:
|
||||
return Response(self.get_serializer(updated_model).data)
|
||||
return Response({"detail": "Model not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
@ -11,4 +11,6 @@ django-redis # สำหรับเชื่อมต่อ Django
|
||||
redis # ไคลเอนต์ Python สำหรับ Redis
|
||||
celery # ตัว Worker
|
||||
boto3
|
||||
python-dotenv
|
||||
python-dotenv
|
||||
drf-spectacular
|
||||
drf-spectacular-sidecar
|
||||
569
web/package-lock.json
generated
569
web/package-lock.json
generated
@ -8,11 +8,21 @@
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@reduxjs/toolkit": "^2.10.1",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"axios": "^1.13.2",
|
||||
"daisyui": "^5.3.10",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
@ -907,6 +917,27 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
||||
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">= 16 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@ -1004,6 +1035,32 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
|
||||
"integrity": "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^10.2.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.43",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
|
||||
@ -1297,6 +1354,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||
@ -1554,6 +1623,32 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz",
|
||||
"integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz",
|
||||
"integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@ -1616,7 +1711,7 @@
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@ -1632,6 +1727,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
|
||||
@ -1716,6 +1817,23 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -1778,6 +1896,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@ -1846,6 +1977,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -1860,6 +2003,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -1879,7 +2031,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
@ -1916,6 +2068,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@ -1925,6 +2086,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.243",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.243.tgz",
|
||||
@ -1945,6 +2120,51 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
|
||||
@ -2275,6 +2495,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -2289,6 +2545,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@ -2299,6 +2564,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@ -2325,6 +2627,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@ -2341,6 +2655,45 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -2351,6 +2704,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@ -2799,6 +3162,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@ -2990,6 +3383,18 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/property-expr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@ -3021,6 +3426,54 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.66.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@ -3031,6 +3484,65 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
|
||||
"integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
|
||||
"integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@ -3098,6 +3610,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@ -3175,6 +3693,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -3191,6 +3715,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/toposort": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@ -3204,6 +3734,18 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
||||
@ -3245,6 +3787,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||
@ -3364,6 +3915,18 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yup": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
|
||||
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
"toposort": "^2.0.2",
|
||||
"type-fest": "^2.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,11 +10,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@reduxjs/toolkit": "^2.10.1",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"axios": "^1.13.2",
|
||||
"daisyui": "^5.3.10",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
16
web/public/favicon-32x32.svg
Normal file
16
web/public/favicon-32x32.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="34" height="34" viewBox="0 0 34 34" xml:space="preserve">
|
||||
<desc>Created with Fabric.js 2.4.6</desc>
|
||||
<defs>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="#ffffff"></rect>
|
||||
<g transform="matrix(1 0 0 1 17.18 16.5)" style="" >
|
||||
<g transform="matrix(1 0 0 1 -0.68 0)" >
|
||||
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(104,66,255); fill-rule: nonzero; opacity: 1;" x="-18" y="-18" rx="0" ry="0" width="36" height="36" />
|
||||
</g>
|
||||
<g transform="matrix(0.36 0 0 0.36 0 0.48)" style="" >
|
||||
<text xml:space="preserve" font-family="Poppins-SemiBold" font-size="40" font-style="normal" font-weight="normal" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(252,248,248); fill-rule: nonzero; opacity: 1; white-space: pre;" ><tspan x="-44.88" y="12.57" >DDO</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/intro.png
Normal file
BIN
web/public/intro.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
BIN
web/public/logo192.png
Normal file
BIN
web/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@ -1,11 +1,22 @@
|
||||
function App() {
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import AuthRoutes from "./routes/AuthRoutes.jsx";
|
||||
import PrivateRoutes from "./routes/PrivateRoutes.jsx";
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<h1 className={"text-lg"}>ทดสอบนำเข้าฟอนต์ในโปรเจกต์ Daisy UI</h1>
|
||||
<button className="btn btn-neutral">Neutral</button>
|
||||
</>
|
||||
// ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
|
||||
<Route path="/dashboard/*" element={<PrivateRoutes/>}/>
|
||||
|
||||
{/* Auth Routes: สำหรับ /login, /register และเป็น Fallback หลัก */}
|
||||
<Route path="/*" element={<AuthRoutes/>}/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
23
web/src/app/store.js
Normal file
23
web/src/app/store.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from '../features/auth/authSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
// กำหนด Reducer หลัก
|
||||
auth: authReducer,
|
||||
// [เพิ่ม Reducer อื่น ๆ ที่นี่]
|
||||
},
|
||||
// ปิด serializableCheck ใน Production เพื่อประสิทธิภาพ
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// อาจจะต้องยกเว้น action types บางตัวที่ไม่ serialize ได้
|
||||
},
|
||||
}),
|
||||
// ใน Vite ตัวแปร import.meta.env.MODE จะถูก ตั้งค่าให้อัตโนมัติ ตามคำสั่งที่ใช้ตอนรัน เช่น
|
||||
// vite หรือ npm run dev "development"
|
||||
// vite build หรือ npm run build "production"
|
||||
devTools: import.meta.env.MODE !== 'production',
|
||||
});
|
||||
|
||||
// Export ฟังก์ชันเพื่อให้ Components ภายนอก (เช่น axiosClient) เข้าถึง store instance ได้
|
||||
export const getStore = () => store;
|
||||
17
web/src/components/ErrorText.jsx
Normal file
17
web/src/components/ErrorText.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
function ErrorText({ children, styleClass }) {
|
||||
const defaultClasses = "p-3 bg-red-100 text-red-600 rounded-lg text-sm font-medium border border-red-300 break-words whitespace-normal";
|
||||
|
||||
return (
|
||||
<>
|
||||
{children && (
|
||||
<p className={`${defaultClasses} ${styleClass || ""}`}>
|
||||
{children}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorText;
|
||||
28
web/src/components/InputText.jsx
Normal file
28
web/src/components/InputText.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
// ใช้ forwardRef เพื่อให้ RHF สามารถส่ง ref มาที่ input ได้
|
||||
const InputText = forwardRef(
|
||||
// รับ Props ที่จำเป็นสำหรับ RHF (ref, name, value, onChange, onBlur)
|
||||
// และรับ error object เพื่อแสดงข้อความ Validation
|
||||
({ labelTitle, labelStyle, type, containerStyle, placeholder, error, ...rest }, ref) => {
|
||||
|
||||
return(
|
||||
<div className={`form-control w-full ${containerStyle || ''}`}>
|
||||
<label className="label">
|
||||
<span className={"label-text text-base-content " + (labelStyle || '')}>{labelTitle}</span>
|
||||
</label>
|
||||
<input
|
||||
type={type || "text"}
|
||||
ref={ref} // รับ ref จาก RHF register
|
||||
{...rest} // รับ Props ที่เหลือจาก RHF register (value, onChange, name, etc.)
|
||||
placeholder={placeholder || ""}
|
||||
className={`input input-bordered w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
{/* แสดงข้อความ Error ที่ส่งมาจาก RHF errors object */}
|
||||
{error && <p className="text-error text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default InputText;
|
||||
31
web/src/components/LandingIntro.jsx
Normal file
31
web/src/components/LandingIntro.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import TemplatePointers from "./TemplatePointers.jsx"
|
||||
|
||||
function LandingIntro(){
|
||||
|
||||
return(
|
||||
<div className="hero min-h-full rounded-l-xl bg-base-200">
|
||||
<div className="hero-content py-6">
|
||||
<div className="max-w-md">
|
||||
|
||||
<h1 className='text-3xl text-center font-bold '>
|
||||
{/* ไฟล์ logo192.png ใน /public */}
|
||||
<img src="/logo192.png" className="w-12 inline-block mr-2 mask mask-circle" alt="App Logo" />
|
||||
DDO Console
|
||||
</h1>
|
||||
|
||||
{/* ไฟล์ intro.png ใน /public */}
|
||||
<div className="text-center mt-12">
|
||||
<img src="./intro.png" alt="Admin Template" className="w-48 inline-block"></img>
|
||||
</div>
|
||||
|
||||
<TemplatePointers />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default LandingIntro
|
||||
122
web/src/components/LoginForm.jsx
Normal file
122
web/src/components/LoginForm.jsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// นำเข้า Hooks และ Library ที่จำเป็น
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
// นำเข้า Hook API และ Components
|
||||
import { useLoginMutation } from '../services/authApi';
|
||||
import LandingIntro from './LandingIntro';
|
||||
import InputText from './InputText';
|
||||
|
||||
import { loginSchema } from '../schemas/authSchema';
|
||||
|
||||
export default function LoginForm() {
|
||||
// 1. Hook สำหรับเรียก API ล็อกอิน
|
||||
const loginMutation = useLoginMutation();
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState("");
|
||||
|
||||
// 2. Hook Form Setup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors }
|
||||
} = useForm({
|
||||
resolver: yupResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Form Submission Logic
|
||||
const onSubmit = (data) => {
|
||||
setApiErrorMessage("");
|
||||
|
||||
// เรียกใช้ Mutation (Token + Fetch User)
|
||||
loginMutation.mutate({
|
||||
// ส่ง 'username' และ 'password' ตรงตาม API
|
||||
username: data.username,
|
||||
password: data.password
|
||||
}, {
|
||||
onError: (error) => {
|
||||
setApiErrorMessage(error.message || "การล็อกอินล้มเหลว กรุณาตรวจสอบข้อมูล");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const loading = loginMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="card w-full shadow-xl bg-base-100">
|
||||
<div className="grid md:grid-cols-2 grid-cols-1">
|
||||
|
||||
<div className="hidden md:block">
|
||||
<LandingIntro />
|
||||
</div>
|
||||
|
||||
<div className='py-12 px-10'>
|
||||
<h2 className='text-2xl font-semibold mb-6 text-center text-base-content'>เข้าสู่ระบบ DDO Console</h2>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
{/* API Error Alert */}
|
||||
{apiErrorMessage && (
|
||||
<div className="alert alert-error text-sm my-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>{apiErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
{/* Username Input (เชื่อม RHF) */}
|
||||
<InputText
|
||||
type="text"
|
||||
containerStyle="mt-4"
|
||||
labelTitle="ชื่อผู้ใช้งาน"
|
||||
{...register('username')}
|
||||
error={errors.username}
|
||||
/>
|
||||
|
||||
{/* Password Input (เชื่อม RHF) */}
|
||||
<InputText
|
||||
type="password"
|
||||
containerStyle="mt-4"
|
||||
labelTitle="รหัสผ่าน"
|
||||
{...register('password')}
|
||||
error={errors.password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='text-right'>
|
||||
<Link to="/forgot-password">
|
||||
<span className="text-sm inline-block hover:text-primary hover:underline hover:cursor-pointer transition duration-200">
|
||||
ลืมรหัสผ่าน?
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ปุ่ม Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
className={"btn mt-6 w-full btn-primary" + (loading ? " loading" : "")}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'กำลังเข้าสู่ระบบ...' : 'เข้าสู่ระบบ'}
|
||||
</button>
|
||||
|
||||
<div className='text-center mt-4 text-base-content/80'>
|
||||
ยังไม่มีบัญชีใช่ไหม?
|
||||
<Link to="/register">
|
||||
<span className="inline-block text-primary hover:underline hover:cursor-pointer transition duration-200 ml-1">
|
||||
ลงทะเบียน
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
web/src/components/SidebarSubmenu.jsx
Normal file
87
web/src/components/SidebarSubmenu.jsx
Normal file
@ -0,0 +1,87 @@
|
||||
// src/components/SidebarSubmenu.jsx (ปรับปรุงสำหรับ RBAC)
|
||||
|
||||
import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// Component นี้คาดหวังว่าฟังก์ชัน canAccess จะถูกส่งมาจาก MainLayout หรือสร้างขึ้นใหม่
|
||||
|
||||
function SidebarSubmenu({ submenu, name, icon, closeMobileSidebar }) {
|
||||
const location = useLocation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const role = useSelector(state => state.auth.role); // ดึง Role จาก Redux
|
||||
|
||||
// ฟังก์ชันตรวจสอบสิทธิ์ (ควรมาจาก MainLayout แต่เราจะใช้ Logic พื้นฐาน)
|
||||
const canAccess = (requiredRole) => {
|
||||
if (role === 'admin') return true;
|
||||
if (!requiredRole) return true;
|
||||
const allowedRoles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
|
||||
return allowedRoles.includes(role);
|
||||
};
|
||||
|
||||
// เปิด Submenu หาก Path ปัจจุบันอยู่ใน Submenu นั้น
|
||||
useEffect(() => {
|
||||
// ตรวจสอบว่ามีเมนูย่อยใดที่ผู้ใช้เข้าถึงได้และเป็น Path ปัจจุบันหรือไม่
|
||||
const isActive = submenu.some(
|
||||
m => canAccess(m.requiredRole) && m.path === location.pathname
|
||||
);
|
||||
if (isActive) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [location.pathname, submenu, role]); // เพิ่ม role ใน dependency array
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{/** Route header */}
|
||||
<a
|
||||
className='w-full block cursor-pointer flex items-center justify-between text-base'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{icon}
|
||||
<span className="ml-3 whitespace-nowrap">{name}</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={'w-5 h-5 mt-1 float-right delay-400 duration-500 transition-all ' +
|
||||
(isExpanded ? 'rotate-180' : '')}
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/** Submenu list */}
|
||||
<div className={` w-full ` + (isExpanded ? "" : "hidden")}>
|
||||
<ul className={`menu menu-compact`}>
|
||||
{submenu.map((m, k) => {
|
||||
// กรองเมนูย่อยตามสิทธิ์ (RBAC)
|
||||
if (!canAccess(m.requiredRole)) return null;
|
||||
|
||||
return (
|
||||
<li key={k}>
|
||||
{/* ใช้ NavLink เพื่อแสดงสถานะ Active และใช้ onClick เพื่อปิด Sidebar (สำหรับ Mobile) */}
|
||||
<NavLink
|
||||
to={m.path}
|
||||
onClick={closeMobileSidebar}
|
||||
className={({ isActive }) =>
|
||||
`text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}`
|
||||
}
|
||||
>
|
||||
{m.icon} {m.name}
|
||||
|
||||
{/* แถบสี Primary แสดงสถานะ Active */}
|
||||
{location.pathname === m.path ? (
|
||||
<span
|
||||
className="absolute mt-1 mb-1 inset-y-0 left-0 w-1 rounded-tr-md rounded-br-md bg-primary "
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarSubmenu;
|
||||
12
web/src/components/Subtitle.jsx
Normal file
12
web/src/components/Subtitle.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
function Subtitle({ styleClass, children }) {
|
||||
return (
|
||||
// ใช้ text-2xl เพื่อให้ดูโดดเด่นขึ้นสำหรับหัวข้อหน้า
|
||||
<div className={`text-xl md:text-2xl font-semibold text-base-content ${styleClass || ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Subtitle;
|
||||
34
web/src/components/TemplatePointers.jsx
Normal file
34
web/src/components/TemplatePointers.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
function TemplatePointers(){
|
||||
return(
|
||||
<>
|
||||
<h1 className="text-2xl mt-8 font-bold text-base-content">DDO Console Features</h1>
|
||||
|
||||
{/* 1. ส่วน A: Dashboard & Monitoring */}
|
||||
<p className="py-2 mt-4">
|
||||
✓ <span className="font-semibold text-primary">ความน่าเชื่อถือของข้อมูล (HA Data Layer)</span>: ใช้ CockroachDB Cluster (3 Node HA) เพื่อรับประกันความต่อเนื่องของบริการฐานข้อมูล
|
||||
</p>
|
||||
|
||||
{/* 2. ส่วน B: Control & Management (MLOps Flow) */}
|
||||
<p className="py-2 ">
|
||||
✓ <span className="font-semibold text-primary">Asynchronous AI Processing</span>: ประมวลผลงานหนักผ่าน Celery เพื่อให้ Frontend ตอบสนองทันที และจัดการคิวงานได้
|
||||
</p>
|
||||
|
||||
{/* 3. ส่วน C: Data Governance & Discovery (เน้น Medical/AI) */}
|
||||
<p className="py-2">
|
||||
✓ <span className="font-semibold text-primary">AI Model Serving Layer</span>: บริการ Model Inference พร้อมจัดการไฟล์ภาพขนาดใหญ่ด้วย MinIO (S3)
|
||||
</p>
|
||||
|
||||
{/* 4. ส่วน Security (การยืนยันตัวตนและการเข้าถึง) */}
|
||||
<p className="py-2 ">
|
||||
✓ <span className="font-semibold text-primary">Security & Gateway</span>: การจัดการผู้ใช้และสิทธิ์ (JWT/RBAC) ผ่าน Django DRF ซึ่งทำหน้าที่เป็น Lightweight Gateway หลัก
|
||||
</p>
|
||||
|
||||
{/* 5. ส่วนสถาปัตยกรรมกระจายศูนย์ */}
|
||||
<p className="py-2 mb-4">
|
||||
✓ <span className="font-semibold text-primary">สถาปัตยกรรมกระจายศูนย์ (Distributed)</span>: สร้างบนเทคโนโลยีหลัก เช่น MONAI, Django DRF, CockroachDB HA, Redis Cache และ MinIO Object Storage
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplatePointers
|
||||
33
web/src/components/TitleCard.jsx
Normal file
33
web/src/components/TitleCard.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import Subtitle from './Subtitle';
|
||||
|
||||
function TitleCard({ title, children, topMargin, TopSideButtons }) {
|
||||
return (
|
||||
<div className={`card w-full p-6 bg-base-100 shadow-xl ${topMargin || "mt-6"}`}>
|
||||
|
||||
{/* Title และ Top Side Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Subtitle styleClass={TopSideButtons ? "flex-grow" : ""}>
|
||||
{title}
|
||||
</Subtitle>
|
||||
|
||||
{/* Top side button, show only if present */}
|
||||
{TopSideButtons && (
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
{TopSideButtons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider (เส้นแบ่ง) */}
|
||||
<div className="divider mt-2 mb-4"></div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className='h-full w-full'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TitleCard;
|
||||
108
web/src/config/sidebarRoutes.jsx
Normal file
108
web/src/config/sidebarRoutes.jsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { FaTachometerAlt, FaCog, FaDatabase, FaCogs, FaProjectDiagram, FaFlask,
|
||||
FaClipboardList, FaHeartbeat } from 'react-icons/fa';
|
||||
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
icon: <FaTachometerAlt
|
||||
className="w-5 h-5 flex-shrink-0" />,
|
||||
name: 'แดชบอร์ด/ภาพรวม',
|
||||
// ไม่ต้องระบุ requiredRole (viewer/admin เข้าถึงได้เสมอ)
|
||||
},
|
||||
|
||||
// ----------------================--
|
||||
// กลุ่ม: Data & MLOps
|
||||
// ----------------------------------
|
||||
{
|
||||
path: '',
|
||||
icon: <FaDatabase
|
||||
className="w-5 h-5 flex-shrink-0" />,
|
||||
name: 'ข้อมูล & AI/MLOps', // ปรับชื่อเล็กน้อย
|
||||
// Roles: ใครก็ตามที่จัดการข้อมูล/โมเดล ควรเข้าถึงได้
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
submenu: [
|
||||
// เพิ่ม Endpoint สำหรับเรียกใช้ AI Inference Proxy
|
||||
{
|
||||
path:
|
||||
'/dashboard/inference-run',
|
||||
name: 'AI Inference (Run)', // ชื่อสำหรับเรียกใช้ AI จริง
|
||||
requiredRole: ['viewer', 'admin', 'manager'], // ผู้ใช้ทั่วไปก็ควรเรียกได้
|
||||
},
|
||||
{
|
||||
path:
|
||||
'/dashboard/model-registry',
|
||||
name: 'Model Registry & Control', // รวม CRUD และ Control ไว้ในหน้าเดียว
|
||||
// Roles: จำกัดเฉพาะผู้ที่มีสิทธิ์จัดการโมเดล (Admin/Manager)
|
||||
requiredRole: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
path:
|
||||
'/dashboard/datasets',
|
||||
name: 'Dataset Catalog (CKAN)',
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
},
|
||||
{
|
||||
path:
|
||||
'/dashboard/lineage',
|
||||
name: 'Dataset Lineage',
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
},
|
||||
// Note: ลบ /dashboard/predict ออก เนื่องจาก /inference-run ควรจะครอบคลุม
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// กลุ่ม: Pipelines & Logs
|
||||
// ----------------------------------
|
||||
{
|
||||
path: '',
|
||||
icon: <FaProjectDiagram
|
||||
className="w-5 h-5 flex-shrink-0" />,
|
||||
name: 'Pipelines & Log',
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
submenu: [
|
||||
{
|
||||
path:
|
||||
'/dashboard/pipeline/trigger',
|
||||
name: 'Pipeline Control',
|
||||
// Roles: จำกัดเฉพาะผู้ที่สั่งรันได้ (Admin/Manager)
|
||||
requiredRole: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
path:
|
||||
'/dashboard/pipeline/logs',
|
||||
name: 'Pipeline Logs',
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
},
|
||||
{
|
||||
path:
|
||||
'/dashboard/pipeline/status',
|
||||
name: 'Pipeline Run Status',
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// กลุ่ม: การดูแลระบบ
|
||||
// ----------------------------------
|
||||
{
|
||||
path: '/dashboard/health',
|
||||
icon: <FaHeartbeat
|
||||
className="w-5 h-5 flex-shrink-0" />,
|
||||
name: 'สถานะระบบ (Health)',
|
||||
requiredRole: ['viewer', 'admin', 'manager'],
|
||||
},
|
||||
{
|
||||
path: '/dashboard/settings',
|
||||
icon: <FaCog
|
||||
className="w-5 h-5 flex-shrink-0" />,
|
||||
name: 'ตั้งค่าระบบ',
|
||||
requiredRole: ['admin'], // สำหรับ Superuser/Admin เท่านั้น
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
export default routes;
|
||||
81
web/src/features/auth/authSlice.js
Normal file
81
web/src/features/auth/authSlice.js
Normal file
@ -0,0 +1,81 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Helper Function: กำหนดบทบาท (Role) จาก Django User Object
|
||||
// ----------------------------------------------------
|
||||
const determineRole = (user) => {
|
||||
if (!user || !user.id) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
// 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง (ถ้ามี)
|
||||
if (user.role) {
|
||||
return user.role.toLowerCase(); // เช่น 'ADMIN' -> 'admin'
|
||||
}
|
||||
|
||||
// 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback
|
||||
if (user.is_superuser) {
|
||||
return 'admin';
|
||||
}
|
||||
if (user.is_staff) {
|
||||
return 'manager';
|
||||
}
|
||||
// ผู้ใช้ทั่วไป
|
||||
return 'viewer';
|
||||
};
|
||||
|
||||
// ----------------------------------------------------
|
||||
// ฟังก์ชันช่วยในการอ่านค่าจาก Local Storage เพื่อกำหนดสถานะเริ่มต้น
|
||||
// ----------------------------------------------------
|
||||
const getInitialAuthState = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = JSON.parse(localStorage.getItem('user'));
|
||||
|
||||
// ดึง Role
|
||||
const role = determineRole(user);
|
||||
|
||||
return {
|
||||
isAuthenticated: !!token,
|
||||
user: user || null,
|
||||
role: role,
|
||||
};
|
||||
};
|
||||
|
||||
const initialState = getInitialAuthState();
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Reducer สำหรับการล็อกอินสำเร็จ
|
||||
loginSuccess: (state, action) => {
|
||||
state.isAuthenticated = true;
|
||||
state.user = action.payload.user;
|
||||
state.role = action.payload.role;
|
||||
},
|
||||
|
||||
// Reducer สำหรับการออกจากระบบ
|
||||
logout: (state) => {
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.role = 'guest';
|
||||
// ลบข้อมูลจาก localStorage
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
},
|
||||
|
||||
// Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์)
|
||||
updateUser: (state, action) => {
|
||||
state.user = action.payload.user;
|
||||
// คำนวณ Role ใหม่จากข้อมูลที่อัปเดต
|
||||
const newRole = determineRole(action.payload.user);
|
||||
state.role = newRole;
|
||||
localStorage.setItem('user', JSON.stringify(action.payload.user));
|
||||
// ไม่ต้องยุ่งกับ token/role storage ในนี้ถ้ามี loginSuccess/logout จัดการแล้ว
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const { loginSuccess, logout, updateUser } = authSlice.actions;
|
||||
|
||||
export default authSlice.reducer;
|
||||
16
web/src/layouts/AuthLayout.jsx
Normal file
16
web/src/layouts/AuthLayout.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import {Outlet} from "react-router-dom";
|
||||
|
||||
export default function AuthLayout(){
|
||||
return(
|
||||
<>
|
||||
{/* 1. Div หลัก: จัดให้อยู่ตรงกลางจอ (ใช้ flex h-screen) */}
|
||||
<div className="flex h-screen items-center justify-center bg-base-200 p-4">
|
||||
|
||||
{/* 2. Div ภาชนะสำหรับเนื้อหา: กำหนดขนาดสูงสุด */}
|
||||
<div className="w-full max-w-5xl">
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
176
web/src/layouts/MainLayout.jsx
Normal file
176
web/src/layouts/MainLayout.jsx
Normal file
@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { Link, Outlet, useNavigate, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { logout } from '../features/auth/authSlice';
|
||||
import { FaSignOutAlt, FaUserCircle } from 'react-icons/fa';
|
||||
import Bars3Icon from '@heroicons/react/24/outline/Bars3Icon';
|
||||
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
||||
|
||||
import routes from '../config/sidebarRoutes';
|
||||
import SidebarSubmenu from '../components/SidebarSubmenu';
|
||||
|
||||
|
||||
export default function MainLayout() {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation(); // Hook สำหรับตรวจสอบ Path ปัจจุบัน
|
||||
|
||||
// ดึงข้อมูลผู้ใช้และ Role จาก Redux Store
|
||||
const user = useSelector(state => state.auth.user);
|
||||
const role = useSelector(state => state.auth.role);
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout());
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
// Logic: ฟังก์ชันตรวจสอบสิทธิ์ (RBAC)
|
||||
const canAccess = (requiredRole) => {
|
||||
if (role === 'admin') return true;
|
||||
if (!requiredRole) return true;
|
||||
|
||||
// ตรวจสอบ Role ของผู้ใช้กับ Role ที่จำเป็น
|
||||
const allowedRoles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
|
||||
return allowedRoles.includes(role);
|
||||
};
|
||||
|
||||
// ฟังก์ชันปิด Sidebar ใน Mobile
|
||||
const closeMobileSidebar = () => {
|
||||
document.getElementById('my-drawer-2').checked = false;
|
||||
}
|
||||
|
||||
// ฟังก์ชันสำหรับดึงชื่อเมนูตาม Path ปัจจุบัน (สำหรับ Header)
|
||||
const getPageTitle = (pathname) => {
|
||||
let title = "DDO Console";
|
||||
routes.forEach((route) => {
|
||||
if (route.path && route.path === pathname) {
|
||||
title = route.name;
|
||||
} else if (route.submenu) {
|
||||
route.submenu.forEach((submenuRoute) => {
|
||||
if (submenuRoute.path === pathname) {
|
||||
title = submenuRoute.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return title;
|
||||
};
|
||||
|
||||
const currentTitle = getPageTitle(location.pathname);
|
||||
|
||||
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
{/* 1. Drawer Toggle */}
|
||||
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
||||
|
||||
{/* 2. Main Content Area */}
|
||||
<div className="drawer-content flex flex-col bg-base-100">
|
||||
{/* Header/Navbar */}
|
||||
<div className="navbar bg-base-100 shadow-md sticky top-0 z-10">
|
||||
<div className="flex-none lg:hidden">
|
||||
{/* Mobile Toggle Button (ใช้ Heroicon) */}
|
||||
<label htmlFor="my-drawer-2" className="btn btn-square btn-ghost">
|
||||
<Bars3Icon className="h-5 w-5" />
|
||||
</label>
|
||||
</div>
|
||||
{/* แสดงชื่อหน้าปัจจุบัน */}
|
||||
<div className="flex-1 px-2 mx-2 text-xl font-bold text-primary">
|
||||
{currentTitle}
|
||||
</div>
|
||||
|
||||
{/* User Profile/Logout Dropdown */}
|
||||
<div className="flex-none">
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost hover:bg-base-300 rounded-lg">
|
||||
<FaUserCircle className="h-5 w-5 mr-2" />
|
||||
{user ? user.username : 'ผู้ใช้งาน'}
|
||||
<span className="badge badge-sm badge-primary ml-2">{role}</span>
|
||||
</div>
|
||||
<ul tabIndex={0} className="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-[1]">
|
||||
<li>
|
||||
<Link to="/dashboard/profile" className="hover:bg-base-200">
|
||||
<FaUserCircle /> โปรไฟล์
|
||||
</Link>
|
||||
</li>
|
||||
<div className="divider my-0"></div>
|
||||
<li>
|
||||
<button onClick={handleLogout} className="text-error hover:bg-error/10 hover:text-error">
|
||||
<FaSignOutAlt /> ออกจากระบบ
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Content (Outlet) */}
|
||||
<main className="p-4 md:p-8 flex-grow">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer (Optional) */}
|
||||
<footer className="footer footer-center p-4 bg-base-100 text-base-content border-t border-base-content/10">
|
||||
<aside>
|
||||
<p>Copyright © {new Date().getFullYear()} - Data Decision Operations Console</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* 3. Sidebar (ปรับโครงสร้างใหม่) */}
|
||||
<div className="drawer-side z-20 border-r border-base-content/10">
|
||||
<label htmlFor="my-drawer-2" aria-label="close sidebar" className="drawer-overlay"></label>
|
||||
<ul className="menu pt-2 w-64 min-h-full bg-base-100 text-base-content">
|
||||
|
||||
{/* ปุ่มปิด Sidebar สำหรับ Mobile */}
|
||||
<button
|
||||
className="btn btn-ghost btn-circle z-50 top-0 right-0 mt-4 mr-2 absolute lg:hidden"
|
||||
onClick={closeMobileSidebar}
|
||||
>
|
||||
<XMarkIcon className="h-5 inline-block w-5" />
|
||||
</button>
|
||||
|
||||
{/* ส่วนโลโก้ DDO Console */}
|
||||
<li className="mb-4 font-semibold text-xl">
|
||||
<Link to={'/dashboard'} className="text-primary">
|
||||
<img className="mask mask-squircle w-10" src="/logo192.png" alt="DDO Console Logo"/>
|
||||
DDO Console
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* การวนลูปเมนูจาก sidebarRoutes.jsx */}
|
||||
<li className="menu-title">เมนูหลัก</li>
|
||||
{routes.map((route, k) => {
|
||||
// 1. ตรวจสอบสิทธิ์เมนูหลัก
|
||||
if (!canAccess(route.requiredRole)) return null;
|
||||
|
||||
return (
|
||||
<li key={k} >
|
||||
{route.submenu ?
|
||||
// 2. ถ้ามี Submenu ใช้ SidebarSubmenu Component
|
||||
<SidebarSubmenu
|
||||
{...route}
|
||||
closeMobileSidebar={closeMobileSidebar} // ส่งฟังก์ชันปิดไปให้ Component ย่อย
|
||||
/> :
|
||||
(
|
||||
// 3. ถ้าไม่มี Submenu ใช้ NavLink ธรรมดา
|
||||
<NavLink
|
||||
end
|
||||
to={route.path}
|
||||
onClick={closeMobileSidebar}
|
||||
className={({ isActive }) =>
|
||||
`text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}`
|
||||
}
|
||||
>
|
||||
{route.icon} {route.name}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,8 +4,19 @@ import './index.css'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './app/store';
|
||||
// TanStack Query
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
41
web/src/pages/BlankPage.jsx
Normal file
41
web/src/pages/BlankPage.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import TitleCard from '../components/TitleCard';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
function BlankPage({ title, message }) {
|
||||
const location = useLocation();
|
||||
|
||||
// ตั้งค่า Default Message ตามประเภทของหน้า
|
||||
const defaultTitle = title || "Feature Under Development";
|
||||
const defaultMessage = message ||
|
||||
`เส้นทางปัจจุบัน: ${location.pathname} ยังไม่มีเนื้อหาที่พร้อมใช้งาน กรุณากลับไปที่หน้าหลักหรือเมนูอื่น`;
|
||||
|
||||
return (
|
||||
// ใช้ TitleCard ห่อหุ้มเนื้อหาเพื่อให้มีโครงสร้างเหมือน Page อื่นๆ
|
||||
<TitleCard title={defaultTitle} topMargin="mt-0">
|
||||
<div className="text-center p-16 bg-base-200 rounded-lg">
|
||||
|
||||
{/* Icon แจ้งเตือน */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-16 h-16 mx-auto text-primary mb-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
|
||||
<h1 className="text-2xl font-bold text-base-content/80 mt-4">
|
||||
{defaultTitle}
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mt-2 max-w-lg mx-auto whitespace-normal break-words">
|
||||
{defaultMessage}
|
||||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<a href="/dashboard" className="btn btn-primary">
|
||||
กลับสู่ Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</TitleCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlankPage;
|
||||
25
web/src/pages/Dashboard.jsx
Normal file
25
web/src/pages/Dashboard.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import TitleCard from '../components/TitleCard';
|
||||
|
||||
function Dashboard() {
|
||||
const TopButtons = (
|
||||
<button className="btn btn-primary btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-2"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
สร้างรายงาน
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* ใช้งาน TitleCard พร้อมปุ่มด้านบน */}
|
||||
<TitleCard title="ภาพรวมประสิทธิภาพระบบ" TopSideButtons={TopButtons} topMargin="mt-0">
|
||||
<p className="text-gray-600">
|
||||
แสดงสถิติสำคัญและ Model ทั้งหมด
|
||||
</p>
|
||||
{/* เพิ่มส่วนของการแสดง Metric สำคัญ เช่น Stats Component */}
|
||||
</TitleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
23
web/src/routes/AuthRoutes.jsx
Normal file
23
web/src/routes/AuthRoutes.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Route, Routes, Navigate } from "react-router-dom";
|
||||
import AuthLayout from "../layouts/AuthLayout.jsx";
|
||||
import LoginForm from "../components/LoginForm.jsx";
|
||||
|
||||
|
||||
export default function AuthRoutes() {
|
||||
return(
|
||||
<Routes>
|
||||
{/* 1. Root Path ('/') นำทางไปยัง /login ทันที */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
|
||||
{/* 2. AuthLayout สำหรับหน้า Login, Register, Forgot Password */}
|
||||
<Route element={<AuthLayout/>}>
|
||||
<Route path="/login" element={<LoginForm/>}/>
|
||||
<Route path="/register" element={<div>หน้าลงทะเบียน</div>}/>
|
||||
<Route path="/forgot-password" element={<div>หน้าลืมรหัสผ่าน</div>}/>
|
||||
</Route>
|
||||
|
||||
{/* 3. Fallback สำหรับเส้นทางที่ไม่รู้จักในส่วน Public */}
|
||||
<Route path="*" element={<div>404 Not Found</div>}/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
30
web/src/routes/PrivateRoutes.jsx
Normal file
30
web/src/routes/PrivateRoutes.jsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import ProtectedRoute from "./ProtectedRoute.jsx";
|
||||
import MainLayout from "../layouts/MainLayout.jsx";
|
||||
|
||||
// Import เส้นทางย่อยทั้งหมดที่กำหนดไว้ภายนอก
|
||||
import pageRoutes from './pageRoutes.jsx';
|
||||
|
||||
|
||||
export default function PrivateRoutes() {
|
||||
return (
|
||||
// 1. Routes หลักที่ครอบคลุมทั้งหมดภายใต้ /dashboard/*
|
||||
<Routes>
|
||||
{/* 2. Layer ความปลอดภัย: MainLayout ถูกครอบด้วย ProtectedRoute */}
|
||||
<Route element={<ProtectedRoute element={<MainLayout />} />}>
|
||||
|
||||
{/* 3. Layer เส้นทางย่อย: วนซ้ำ pageRoutes เพื่อสร้าง <Route>s */}
|
||||
{pageRoutes.map((route, index) => (
|
||||
// สร้าง Route สำหรับแต่ละรายการใน pageRoutes
|
||||
<Route
|
||||
key={index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
17
web/src/routes/ProtectedRoute.jsx
Normal file
17
web/src/routes/ProtectedRoute.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export default function ProtectedRoute({ element: Element }) {
|
||||
// ดึงสถานะการล็อกอินจาก Redux Store
|
||||
const isAuthenticated = useSelector(state => state.auth.isAuthenticated);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// ถ้ายังไม่ได้ล็อกอิน ให้นำทางกลับไปที่หน้า /login
|
||||
// replace: true ป้องกันการย้อนกลับไปหน้า Dashboard ใน History
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// ถ้าล็อกอินแล้ว อนุญาตให้แสดง Element (Layout/Page)
|
||||
return Element;
|
||||
}
|
||||
18
web/src/routes/pageRoutes.jsx
Normal file
18
web/src/routes/pageRoutes.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import Dashboard from '../pages/Dashboard';
|
||||
import BlankPage from '../pages/BlankPage';
|
||||
|
||||
// Array ของเส้นทางย่อยภายใต้ /dashboard/
|
||||
const pageRoutes = [
|
||||
// --- Dashboard ---
|
||||
{
|
||||
path: '', // ตรงกับ /dashboard
|
||||
element: <Dashboard />,
|
||||
},
|
||||
// Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
|
||||
{
|
||||
path: '*',
|
||||
element: <BlankPage title="404 Not Found" />,
|
||||
}
|
||||
];
|
||||
|
||||
export default pageRoutes;
|
||||
9
web/src/schemas/authSchema.js
Normal file
9
web/src/schemas/authSchema.js
Normal file
@ -0,0 +1,9 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Schema สำหรับตรวจสอบข้อมูล Login (ใช้ Yup)
|
||||
// ----------------------------------------------------------------------
|
||||
export const loginSchema = yup.object().shape({
|
||||
username: yup.string().required('กรุณากรอกชื่อผู้ใช้งาน'),
|
||||
password: yup.string().required('กรุณากรอกรหัสผ่าน'),
|
||||
});
|
||||
139
web/src/services/authApi.js
Normal file
139
web/src/services/authApi.js
Normal file
@ -0,0 +1,139 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { loginSuccess } from '../features/auth/authSlice';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios'; // ใช้ Axios ธรรมดาสำหรับการเรียกที่ยังไม่มี Token
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Helper Functions
|
||||
// ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* แปลง Object ให้เป็นฟอร์มข้อมูล (x-www-form-urlencoded)
|
||||
*/
|
||||
const toFormUrlEncoded = (data) => {
|
||||
return Object.keys(data)
|
||||
.map(key => encodeURIComponent(key) + '=' + encodeURIComponent(data[key]))
|
||||
.join('&');
|
||||
};
|
||||
|
||||
/**
|
||||
* กำหนดบทบาท (Role) จาก Django User Object ที่สมบูรณ์
|
||||
* @param {object} user - User Object จาก /users/me/
|
||||
*/
|
||||
const determineRole = (user) => {
|
||||
if (!user || !user.id) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
// 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง (ถ้ามี)
|
||||
if (user.role) {
|
||||
return user.role.toLowerCase(); // เช่น 'ADMIN' -> 'admin'
|
||||
}
|
||||
|
||||
// 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback/เกณฑ์มาตรฐาน
|
||||
if (user.is_superuser) {
|
||||
return 'admin';
|
||||
}
|
||||
if (user.is_staff) {
|
||||
// ถ้าเป็น is_staff แต่ไม่ใช่ superuser
|
||||
return 'manager';
|
||||
}
|
||||
|
||||
// ผู้ใช้ทั่วไป
|
||||
return 'viewer';
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* ฟังก์ชันหลักในการล็อกอิน: ขั้นตอนที่ 1 (รับ Token) และ ขั้นตอนที่ 2 (รับ User Object)
|
||||
*/
|
||||
const loginUser = async (credentials) => {
|
||||
// credentials จะเป็น { username, password }
|
||||
const formData = toFormUrlEncoded(credentials);
|
||||
|
||||
let access, refresh;
|
||||
|
||||
// ---------------------------------------------
|
||||
// ขั้นตอนที่ 1: รับ Access/Refresh Token (POST /jwt/create/)
|
||||
// ---------------------------------------------
|
||||
try {
|
||||
const tokenResponse = await axios.post(`${API_BASE_URL}/api/v1/auth/jwt/create/`, formData, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
access = tokenResponse.data.access;
|
||||
refresh = tokenResponse.data.refresh;
|
||||
|
||||
} catch (error) {
|
||||
// จัดการ Error จากการล็อกอิน
|
||||
const errorMessage = error.response?.data?.detail || "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// ขั้นตอนที่ 2: ใช้ Access Token เพื่อดึง User Object (GET /users/me/)
|
||||
// ---------------------------------------------
|
||||
let user;
|
||||
try {
|
||||
const userResponse = await axios.get(`${API_BASE_URL}/api/v1/auth/users/me/`, {
|
||||
headers: {
|
||||
// ใช้ JWT เพื่อยืนยันตัวตน
|
||||
'Authorization': `Bearer ${access}`,
|
||||
},
|
||||
});
|
||||
|
||||
user = userResponse.data;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data after token creation:", error);
|
||||
throw new Error("ล็อกอินสำเร็จ แต่ไม่สามารถดึงข้อมูลผู้ใช้ได้ (กรุณาติดต่อผู้ดูแลระบบ)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// ขั้นตอนที่ 3: คำนวณ Role และส่งกลับข้อมูล
|
||||
// ---------------------------------------------
|
||||
const userRole = determineRole(user);
|
||||
|
||||
return {
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
user: user,
|
||||
role: userRole,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Custom Hook สำหรับจัดการการล็อกอิน
|
||||
// ----------------------------------------------------
|
||||
export const useLoginMutation = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: loginUser,
|
||||
onSuccess: (data) => {
|
||||
|
||||
// 1. จัดการ Token และ User Data
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('role', data.role); // บันทึก Role ที่ถูกต้อง
|
||||
|
||||
// 2. อัปเดต Redux State
|
||||
dispatch(loginSuccess({ user: data.user, role: data.role }));
|
||||
|
||||
// 3. นำทางผู้ใช้
|
||||
navigate('/dashboard', { replace: true });
|
||||
queryClient.invalidateQueries({ queryKey: ['userData'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
// error.message ถูกโยนมาจาก loginUser function
|
||||
console.error('Login API Error:', error.message);
|
||||
throw new Error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
58
web/src/services/axiosClient.js
Normal file
58
web/src/services/axiosClient.js
Normal file
@ -0,0 +1,58 @@
|
||||
import axios from 'axios';
|
||||
import { getStore } from '../app/store';
|
||||
import { logout } from '../features/auth/authSlice';
|
||||
|
||||
// 1. สร้าง Axios Instance โดยใช้ Base URL จาก .env
|
||||
|
||||
const axiosClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
// กำหนด Timeout เพื่อป้องกันการค้าง
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
|
||||
// 2. เพิ่ม Interceptor เพื่อแนบ Token ใน Header
|
||||
axiosClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// ดึง Token จาก Local Storage
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// ถ้ามี Token ให้แนบไปใน Header 'Authorization'
|
||||
if (token) {
|
||||
// รูปแบบจากเป็น 'Bearer'
|
||||
// ตามที่กำหนดใน Django DRF/Djoser settings (JWTAuthentication)
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// 3. (Optional) Interceptor สำหรับ Response (จัดการ Token Expired)
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const status = error.response ? error.response.status : null;
|
||||
|
||||
// ถ้าได้รับ 401 หรือ 403 (Token หมดอายุ/ไม่มีสิทธิ์)
|
||||
if (status === 401 || status === 403) {
|
||||
// Logic สำหรับการจัดการ Token หมดอายุ (เช่น Redirect ไปหน้า Login)
|
||||
console.error("Authorization Failed: Token expired or insufficient roles.");
|
||||
|
||||
// บังคับ Logout
|
||||
const storeInstance = getStore(); // เรียกใช้ store instance
|
||||
if (storeInstance) {
|
||||
storeInstance.dispatch(logout()); // เรียก Redux Action
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
export default axiosClient;
|
||||
Loading…
x
Reference in New Issue
Block a user