diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0b8763d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5f3a278 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/monorepo-starter-template.iml b/.idea/monorepo-starter-template.iml new file mode 100644 index 0000000..67954c8 --- /dev/null +++ b/.idea/monorepo-starter-template.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py index e80d26e..0e23c57 100644 --- a/backend/accounts/admin.py +++ b/backend/accounts/admin.py @@ -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) \ No newline at end of file diff --git a/backend/accounts/migrations/0002_customuser_role.py b/backend/accounts/migrations/0002_customuser_role.py new file mode 100644 index 0000000..eea7bda --- /dev/null +++ b/backend/accounts/migrations/0002_customuser_role.py @@ -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), + ), + ] diff --git a/backend/accounts/models.py b/backend/accounts/models.py index be1b122..a4262ea 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -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 diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py index b99488a..28a85d0 100644 --- a/backend/accounts/serializers.py +++ b/backend/accounts/serializers.py @@ -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') diff --git a/backend/core/settings.py b/backend/core/settings.py index 3ea7d36..f6c38ae 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -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' \ No newline at end of file +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', + ], + # ตั้งค่าอื่น ๆ (ถ้าจำเป็น) +} \ No newline at end of file diff --git a/backend/core/spectacular_hooks.py b/backend/core/spectacular_hooks.py new file mode 100644 index 0000000..5a992e4 --- /dev/null +++ b/backend/core/spectacular_hooks.py @@ -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 diff --git a/backend/core/urls.py b/backend/core/urls.py index a26824d..c3be368 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -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) diff --git a/backend/model_registry/__init__.py b/backend/model_registry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model_registry/admin.py b/backend/model_registry/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/model_registry/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/model_registry/apps.py b/backend/model_registry/apps.py new file mode 100644 index 0000000..4ce609e --- /dev/null +++ b/backend/model_registry/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModelRegistryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'model_registry' diff --git a/backend/model_registry/migrations/0001_initial.py b/backend/model_registry/migrations/0001_initial.py new file mode 100644 index 0000000..b4f1eca --- /dev/null +++ b/backend/model_registry/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/backend/model_registry/migrations/__init__.py b/backend/model_registry/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model_registry/models.py b/backend/model_registry/models.py new file mode 100644 index 0000000..a61987f --- /dev/null +++ b/backend/model_registry/models.py @@ -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) ได้ในภายหลัง \ No newline at end of file diff --git a/backend/model_registry/repositories/__init__.py b/backend/model_registry/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model_registry/repositories/ai_model_repository.py b/backend/model_registry/repositories/ai_model_repository.py new file mode 100644 index 0000000..eb30a9e --- /dev/null +++ b/backend/model_registry/repositories/ai_model_repository.py @@ -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 \ No newline at end of file diff --git a/backend/model_registry/serializers/__init__.py b/backend/model_registry/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model_registry/serializers/ai_model_serializer.py b/backend/model_registry/serializers/ai_model_serializer.py new file mode 100644 index 0000000..7a9f1b7 --- /dev/null +++ b/backend/model_registry/serializers/ai_model_serializer.py @@ -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 \ No newline at end of file diff --git a/backend/model_registry/services/__init__.py b/backend/model_registry/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model_registry/services/ai_model_service.py b/backend/model_registry/services/ai_model_service.py new file mode 100644 index 0000000..6630a08 --- /dev/null +++ b/backend/model_registry/services/ai_model_service.py @@ -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 \ No newline at end of file diff --git a/backend/model_registry/tests.py b/backend/model_registry/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/model_registry/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/model_registry/views.py b/backend/model_registry/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/model_registry/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/model_registry/views/__init__.py b/backend/model_registry/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/model_registry/views/ai_model_viewset.py b/backend/model_registry/views/ai_model_viewset.py new file mode 100644 index 0000000..42bc7fd --- /dev/null +++ b/backend/model_registry/views/ai_model_viewset.py @@ -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) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 84e2f83..ff1c0a9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,6 @@ django-redis # สำหรับเชื่อมต่อ Django redis # ไคลเอนต์ Python สำหรับ Redis celery # ตัว Worker boto3 -python-dotenv \ No newline at end of file +python-dotenv +drf-spectacular +drf-spectacular-sidecar \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index 50b923e..5af736d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" + } } } } diff --git a/web/package.json b/web/package.json index c6d48a9..d86172d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/favicon-32x32.svg b/web/public/favicon-32x32.svg new file mode 100644 index 0000000..70627f0 --- /dev/null +++ b/web/public/favicon-32x32.svg @@ -0,0 +1,16 @@ + + + +Created with Fabric.js 2.4.6 + + + + + + + + + DDO + + + \ No newline at end of file diff --git a/web/public/intro.png b/web/public/intro.png new file mode 100644 index 0000000..001ec91 Binary files /dev/null and b/web/public/intro.png differ diff --git a/web/public/logo192.png b/web/public/logo192.png new file mode 100644 index 0000000..721bf3f Binary files /dev/null and b/web/public/logo192.png differ diff --git a/web/src/App.jsx b/web/src/App.jsx index 3e4fa0f..eda475f 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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 ( - <> -

ทดสอบนำเข้าฟอนต์ในโปรเจกต์ Daisy UI

- - + // ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx + + + {/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */} + }/> + + {/* Auth Routes: สำหรับ /login, /register และเป็น Fallback หลัก */} + }/> + + ) } -export default App +export default App \ No newline at end of file diff --git a/web/src/app/store.js b/web/src/app/store.js new file mode 100644 index 0000000..d7f53cf --- /dev/null +++ b/web/src/app/store.js @@ -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; \ No newline at end of file diff --git a/web/src/components/ErrorText.jsx b/web/src/components/ErrorText.jsx new file mode 100644 index 0000000..a477498 --- /dev/null +++ b/web/src/components/ErrorText.jsx @@ -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 && ( +

+ {children} +

+ )} + + ); +} + +export default ErrorText; diff --git a/web/src/components/InputText.jsx b/web/src/components/InputText.jsx new file mode 100644 index 0000000..32ec38c --- /dev/null +++ b/web/src/components/InputText.jsx @@ -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( +
+ + + {/* แสดงข้อความ Error ที่ส่งมาจาก RHF errors object */} + {error &&

{error.message}

} +
+ ); + } +); + +export default InputText; \ No newline at end of file diff --git a/web/src/components/LandingIntro.jsx b/web/src/components/LandingIntro.jsx new file mode 100644 index 0000000..a309ed4 --- /dev/null +++ b/web/src/components/LandingIntro.jsx @@ -0,0 +1,31 @@ +import TemplatePointers from "./TemplatePointers.jsx" + +function LandingIntro(){ + + return( +
+
+
+ +

+ {/* ไฟล์ logo192.png ใน /public */} + App Logo + DDO Console +

+ + {/* ไฟล์ intro.png ใน /public */} +
+ Admin Template +
+ + + +
+ +
+
+ ) + +} + +export default LandingIntro diff --git a/web/src/components/LoginForm.jsx b/web/src/components/LoginForm.jsx new file mode 100644 index 0000000..6b843bd --- /dev/null +++ b/web/src/components/LoginForm.jsx @@ -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 ( +
+
+ +
+ +
+ +
+

เข้าสู่ระบบ DDO Console

+ +
+ + {/* API Error Alert */} + {apiErrorMessage && ( +
+ + {apiErrorMessage} +
+ )} + +
+ {/* Username Input (เชื่อม RHF) */} + + + {/* Password Input (เชื่อม RHF) */} + +
+ +
+ + + ลืมรหัสผ่าน? + + +
+ + {/* ปุ่ม Submit */} + + +
+ ยังไม่มีบัญชีใช่ไหม? + + + ลงทะเบียน + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/SidebarSubmenu.jsx b/web/src/components/SidebarSubmenu.jsx new file mode 100644 index 0000000..9b78ccb --- /dev/null +++ b/web/src/components/SidebarSubmenu.jsx @@ -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 ( +
+ {/** Route header */} + setIsExpanded(!isExpanded)} + > +
+ {icon} + {name} +
+ +
+ + {/** Submenu list */} +
+ +
+
+ ); +} + +export default SidebarSubmenu; \ No newline at end of file diff --git a/web/src/components/Subtitle.jsx b/web/src/components/Subtitle.jsx new file mode 100644 index 0000000..c2db806 --- /dev/null +++ b/web/src/components/Subtitle.jsx @@ -0,0 +1,12 @@ +import React from 'react'; + +function Subtitle({ styleClass, children }) { + return ( + // ใช้ text-2xl เพื่อให้ดูโดดเด่นขึ้นสำหรับหัวข้อหน้า +
+ {children} +
+ ); +} + +export default Subtitle; diff --git a/web/src/components/TemplatePointers.jsx b/web/src/components/TemplatePointers.jsx new file mode 100644 index 0000000..3b2e94f --- /dev/null +++ b/web/src/components/TemplatePointers.jsx @@ -0,0 +1,34 @@ +function TemplatePointers(){ + return( + <> +

DDO Console Features

+ + {/* 1. ส่วน A: Dashboard & Monitoring */} +

+ ✓ ความน่าเชื่อถือของข้อมูล (HA Data Layer): ใช้ CockroachDB Cluster (3 Node HA) เพื่อรับประกันความต่อเนื่องของบริการฐานข้อมูล +

+ + {/* 2. ส่วน B: Control & Management (MLOps Flow) */} +

+ ✓ Asynchronous AI Processing: ประมวลผลงานหนักผ่าน Celery เพื่อให้ Frontend ตอบสนองทันที และจัดการคิวงานได้ +

+ + {/* 3. ส่วน C: Data Governance & Discovery (เน้น Medical/AI) */} +

+ ✓ AI Model Serving Layer: บริการ Model Inference พร้อมจัดการไฟล์ภาพขนาดใหญ่ด้วย MinIO (S3) +

+ + {/* 4. ส่วน Security (การยืนยันตัวตนและการเข้าถึง) */} +

+ ✓ Security & Gateway: การจัดการผู้ใช้และสิทธิ์ (JWT/RBAC) ผ่าน Django DRF ซึ่งทำหน้าที่เป็น Lightweight Gateway หลัก +

+ + {/* 5. ส่วนสถาปัตยกรรมกระจายศูนย์ */} +

+ ✓ สถาปัตยกรรมกระจายศูนย์ (Distributed): สร้างบนเทคโนโลยีหลัก เช่น MONAI, Django DRF, CockroachDB HA, Redis Cache และ MinIO Object Storage +

+ + ) +} + +export default TemplatePointers \ No newline at end of file diff --git a/web/src/components/TitleCard.jsx b/web/src/components/TitleCard.jsx new file mode 100644 index 0000000..ecfd5bc --- /dev/null +++ b/web/src/components/TitleCard.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import Subtitle from './Subtitle'; + +function TitleCard({ title, children, topMargin, TopSideButtons }) { + return ( +
+ + {/* Title และ Top Side Buttons */} +
+ + {title} + + + {/* Top side button, show only if present */} + {TopSideButtons && ( +
+ {TopSideButtons} +
+ )} +
+ + {/* Divider (เส้นแบ่ง) */} +
+ + {/* Card Body */} +
+ {children} +
+
+ ); +} + +export default TitleCard; diff --git a/web/src/config/sidebarRoutes.jsx b/web/src/config/sidebarRoutes.jsx new file mode 100644 index 0000000..a70ff52 --- /dev/null +++ b/web/src/config/sidebarRoutes.jsx @@ -0,0 +1,108 @@ +import { FaTachometerAlt, FaCog, FaDatabase, FaCogs, FaProjectDiagram, FaFlask, + FaClipboardList, FaHeartbeat } from 'react-icons/fa'; + + +const routes = [ + { + path: '/dashboard', + icon: , + name: 'แดชบอร์ด/ภาพรวม', + // ไม่ต้องระบุ requiredRole (viewer/admin เข้าถึงได้เสมอ) + }, + + // ----------------================-- + // กลุ่ม: Data & MLOps + // ---------------------------------- + { + path: '', + icon: , + 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: , + 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: , + name: 'สถานะระบบ (Health)', + requiredRole: ['viewer', 'admin', 'manager'], + }, + { + path: '/dashboard/settings', + icon: , + name: 'ตั้งค่าระบบ', + requiredRole: ['admin'], // สำหรับ Superuser/Admin เท่านั้น + }, + +]; + + +export default routes; \ No newline at end of file diff --git a/web/src/features/auth/authSlice.js b/web/src/features/auth/authSlice.js new file mode 100644 index 0000000..b49d7a8 --- /dev/null +++ b/web/src/features/auth/authSlice.js @@ -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; \ No newline at end of file diff --git a/web/src/layouts/AuthLayout.jsx b/web/src/layouts/AuthLayout.jsx new file mode 100644 index 0000000..8d7faac --- /dev/null +++ b/web/src/layouts/AuthLayout.jsx @@ -0,0 +1,16 @@ +import {Outlet} from "react-router-dom"; + +export default function AuthLayout(){ + return( + <> + {/* 1. Div หลัก: จัดให้อยู่ตรงกลางจอ (ใช้ flex h-screen) */} +
+ + {/* 2. Div ภาชนะสำหรับเนื้อหา: กำหนดขนาดสูงสุด */} +
+ +
+
+ + ) +} \ No newline at end of file diff --git a/web/src/layouts/MainLayout.jsx b/web/src/layouts/MainLayout.jsx new file mode 100644 index 0000000..b80a1fa --- /dev/null +++ b/web/src/layouts/MainLayout.jsx @@ -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 ( +
+ {/* 1. Drawer Toggle */} + + + {/* 2. Main Content Area */} +
+ {/* Header/Navbar */} +
+
+ {/* Mobile Toggle Button (ใช้ Heroicon) */} + +
+ {/* แสดงชื่อหน้าปัจจุบัน */} +
+ {currentTitle} +
+ + {/* User Profile/Logout Dropdown */} +
+
+
+ + {user ? user.username : 'ผู้ใช้งาน'} + {role} +
+
    +
  • + + โปรไฟล์ + +
  • +
    +
  • + +
  • +
+
+
+
+ + {/* Page Content (Outlet) */} +
+ +
+ + {/* Footer (Optional) */} +
+ +
+
+ + {/* 3. Sidebar (ปรับโครงสร้างใหม่) */} +
+ +
    + + {/* ปุ่มปิด Sidebar สำหรับ Mobile */} + + + {/* ส่วนโลโก้ DDO Console */} +
  • + + DDO Console Logo + DDO Console + +
  • + + {/* การวนลูปเมนูจาก sidebarRoutes.jsx */} +
  • เมนูหลัก
  • + {routes.map((route, k) => { + // 1. ตรวจสอบสิทธิ์เมนูหลัก + if (!canAccess(route.requiredRole)) return null; + + return ( +
  • + {route.submenu ? + // 2. ถ้ามี Submenu ใช้ SidebarSubmenu Component + : + ( + // 3. ถ้าไม่มี Submenu ใช้ NavLink ธรรมดา + + `text-base ${isActive ? 'bg-base-200 text-primary font-semibold' : 'hover:bg-base-400'}` + } + > + {route.icon} {route.name} + + ) + } +
  • + ); + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/main.jsx b/web/src/main.jsx index ba17548..9ab579f 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -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( - + + + + + , ) diff --git a/web/src/pages/BlankPage.jsx b/web/src/pages/BlankPage.jsx new file mode 100644 index 0000000..90a2a75 --- /dev/null +++ b/web/src/pages/BlankPage.jsx @@ -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 อื่นๆ + +
+ + {/* Icon แจ้งเตือน */} + + + + +

+ {defaultTitle} +

+ +

+ {defaultMessage} +

+ + +
+
+ ); +} + +export default BlankPage; diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx new file mode 100644 index 0000000..b67052c --- /dev/null +++ b/web/src/pages/Dashboard.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import TitleCard from '../components/TitleCard'; + +function Dashboard() { + const TopButtons = ( + + ); + + return ( +
+ {/* ใช้งาน TitleCard พร้อมปุ่มด้านบน */} + +

+ แสดงสถิติสำคัญและ Model ทั้งหมด +

+ {/* เพิ่มส่วนของการแสดง Metric สำคัญ เช่น Stats Component */} +
+
+ ); +} + +export default Dashboard; diff --git a/web/src/routes/AuthRoutes.jsx b/web/src/routes/AuthRoutes.jsx new file mode 100644 index 0000000..0714003 --- /dev/null +++ b/web/src/routes/AuthRoutes.jsx @@ -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( + + {/* 1. Root Path ('/') นำทางไปยัง /login ทันที */} + } /> + + {/* 2. AuthLayout สำหรับหน้า Login, Register, Forgot Password */} + }> + }/> + หน้าลงทะเบียน}/> + หน้าลืมรหัสผ่าน}/> + + + {/* 3. Fallback สำหรับเส้นทางที่ไม่รู้จักในส่วน Public */} + 404 Not Found}/> + + ); +} \ No newline at end of file diff --git a/web/src/routes/PrivateRoutes.jsx b/web/src/routes/PrivateRoutes.jsx new file mode 100644 index 0000000..60c1dfc --- /dev/null +++ b/web/src/routes/PrivateRoutes.jsx @@ -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/* + + {/* 2. Layer ความปลอดภัย: MainLayout ถูกครอบด้วย ProtectedRoute */} + } />}> + + {/* 3. Layer เส้นทางย่อย: วนซ้ำ pageRoutes เพื่อสร้าง s */} + {pageRoutes.map((route, index) => ( + // สร้าง Route สำหรับแต่ละรายการใน pageRoutes + + ))} + + + + ); +} \ No newline at end of file diff --git a/web/src/routes/ProtectedRoute.jsx b/web/src/routes/ProtectedRoute.jsx new file mode 100644 index 0000000..b2debaf --- /dev/null +++ b/web/src/routes/ProtectedRoute.jsx @@ -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 ; + } + + // ถ้าล็อกอินแล้ว อนุญาตให้แสดง Element (Layout/Page) + return Element; +} diff --git a/web/src/routes/pageRoutes.jsx b/web/src/routes/pageRoutes.jsx new file mode 100644 index 0000000..3febe42 --- /dev/null +++ b/web/src/routes/pageRoutes.jsx @@ -0,0 +1,18 @@ +import Dashboard from '../pages/Dashboard'; +import BlankPage from '../pages/BlankPage'; + +// Array ของเส้นทางย่อยภายใต้ /dashboard/ +const pageRoutes = [ + // --- Dashboard --- + { + path: '', // ตรงกับ /dashboard + element: , + }, + // Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ + { + path: '*', + element: , + } +]; + +export default pageRoutes; diff --git a/web/src/schemas/authSchema.js b/web/src/schemas/authSchema.js new file mode 100644 index 0000000..d663bc8 --- /dev/null +++ b/web/src/schemas/authSchema.js @@ -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('กรุณากรอกรหัสผ่าน'), +}); \ No newline at end of file diff --git a/web/src/services/authApi.js b/web/src/services/authApi.js new file mode 100644 index 0000000..ef97530 --- /dev/null +++ b/web/src/services/authApi.js @@ -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); + }, + }); +}; \ No newline at end of file diff --git a/web/src/services/axiosClient.js b/web/src/services/axiosClient.js new file mode 100644 index 0000000..bff0a1c --- /dev/null +++ b/web/src/services/axiosClient.js @@ -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; \ No newline at end of file