Initial commit
46
.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# =======================================================
|
||||||
|
# 1. PYTHON / DJANGO BACKEND
|
||||||
|
# =======================================================
|
||||||
|
# Virtual Environments (ไม่ว่าจะชื่อ venv, env, หรือ .venv)
|
||||||
|
*venv/
|
||||||
|
*.env
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Python Cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyd
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Django & Database Files
|
||||||
|
/backend/*.sqlite3
|
||||||
|
/backend/staticfiles/
|
||||||
|
/backend/media/
|
||||||
|
/backend/local_settings.py
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# 2. NODE.JS / REACT FRONTEND (Web & Mobile)
|
||||||
|
# =======================================================
|
||||||
|
/web/node_modules/
|
||||||
|
/mobile/node_modules/
|
||||||
|
/web/dist/ # Build output for web (Vite)
|
||||||
|
/mobile/.expo/ # Expo cache files
|
||||||
|
/mobile/web-build/ # Expo web build output
|
||||||
|
/mobile/android/ # Native build folder
|
||||||
|
/mobile/ios/ # Native build folder
|
||||||
|
|
||||||
|
# IDE Files (IntelliJ / VS Code)
|
||||||
|
.idea/ # IntelliJ/WebStorm specific folder
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# 3. DOCKER / INFRASTRUCTURE
|
||||||
|
# =======================================================
|
||||||
|
# ห้าม Commit Volumes และ Secrets
|
||||||
|
# Docker Volumes ถูกสร้างขึ้นในโฟลเดอร์นี้
|
||||||
|
/infra/volumes/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Secrets (ถ้าใช้ไฟล์ .env ในอนาคต)
|
||||||
|
.env
|
||||||
71
README.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# 🏗️ Monorepo Web App Starter Template Version 1.0
|
||||||
|
**Version 1.0.0 — Internal Use Only**
|
||||||
|
|
||||||
|
Template นี้ถูกออกแบบมาเพื่อเป็นพื้นฐาน (Boilerplate) สำหรับทุกโปรเจกต์ในองค์กร
|
||||||
|
ช่วยลดเวลา Setup, เพิ่มมาตรฐาน และรองรับการขยายระบบในระยะยาว
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 เป้าหมายของ Template นี้
|
||||||
|
|
||||||
|
โปรเจกต์นี้เป็น **Monorepo Web App Starter Template** ที่มีฟีเจอร์พื้นฐานครบพร้อมใช้งาน ได้แก่:
|
||||||
|
|
||||||
|
✔ Authentication + JWT + Refresh Token
|
||||||
|
✔ Remember-Me Token Logic
|
||||||
|
✔ Celery Task Queue + Redis Broker
|
||||||
|
✔ API Gateway (Django DRF)
|
||||||
|
✔ React (Vite) + TanStack Query + Redux Toolkit
|
||||||
|
✔ Docker Compose (Minio + Celery + Flower + Redis + CockroachDB)
|
||||||
|
|
||||||
|
สามารถ Clone เพื่อสร้างโปรเจกต์ใหม่ได้ทันทีผ่าน Gitea *Make this Template*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 เหตุผลที่องค์กรควรใช้ Template นี้
|
||||||
|
|
||||||
|
### 1️⃣ ลดเวลาในการ Setup (Time to Market)
|
||||||
|
ไม่ต้องติดตั้งซ้ำทุกครั้ง เช่น:
|
||||||
|
|
||||||
|
- Django + JWT + Djoser
|
||||||
|
- Token Refresh + Remember-Me Logic
|
||||||
|
- Celery Worker + Redis Queue
|
||||||
|
- Vite + React + TanStack Query
|
||||||
|
- Docker Compose รองรับ Backend/Frontend/Database
|
||||||
|
|
||||||
|
โคลนแล้วเริ่มพัฒนาได้ทันที!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ มาตรฐานเดียวกันทั้งองค์กร (Standardization)
|
||||||
|
Template นี้รวม Best Practices เช่น:
|
||||||
|
|
||||||
|
- DRF Custom Permission, Auth Middleware
|
||||||
|
- React Service Layer + Axios Interceptors
|
||||||
|
- Redux Slice สำหรับ Auth
|
||||||
|
- Database Structure มาตรฐาน
|
||||||
|
- Docker Directory Structure
|
||||||
|
|
||||||
|
ทำให้ทุกทีมโค้ดไปในทิศทางเดียวกัน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ ผ่านการทดสอบมาแล้ว (Quality & Stability)
|
||||||
|
Template นี้ถูกสร้างจากโปรเจกต์จริง
|
||||||
|
แก้ปัญหาเช่น:
|
||||||
|
|
||||||
|
- Connection Refused (Celery)
|
||||||
|
- Token Refresh ไม่ทำงาน
|
||||||
|
- CORS/CSRF ปัญหาใน Dev
|
||||||
|
- Docker Volume Lost
|
||||||
|
|
||||||
|
จึงมั่นใจได้ว่าใช้แล้วเสถียร
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ ง่ายในการบำรุงรักษา (Maintenance Friendly)
|
||||||
|
เมื่อมีการอัปเดต security หรือ dependency เวอร์ชันใหม่:
|
||||||
|
|
||||||
|
- ทีมสามารถ Pull จาก Template
|
||||||
|
- หรือ Sync ผ่าน Git Subtree/Gitea Mirror
|
||||||
|
|
||||||
|
ช่วยลด Tech Debt ระยะยาว
|
||||||
25
backend/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 1. BASE IMAGE
|
||||||
|
FROM python:3.11-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1 # เพื่อให้ Log แสดงผลทันที
|
||||||
|
|
||||||
|
# 2. WORK DIRECTORY
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 3. DEPENDENCIES: Copy และติดตั้ง (ใช้ประโยชน์จาก Docker Layer Caching)
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 4. ENTRYPOINT SCRIPT: Copy และกำหนดสิทธิ์รัน (ใช้สำหรับ Startup Automation)
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# 5. CODE: คัดลอกโค้ดโปรเจกต์ที่เหลือทั้งหมดมาไว้ใน /app
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
|
# 6. EXPOSE:
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 7. ENTRYPOINT/CMD: กำหนด Entrypoint หลัก
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
20
backend/Dockerfile.celery
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["celery", "-A", "core", "worker", "-l", "info"]
|
||||||
0
backend/accounts/__init__.py
Normal file
34
backend/accounts/admin.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# users/admin.py
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin # เปลี่ยนชื่อเป็น BaseUserAdmin เพื่อป้องกันการซ้ำซ้อน
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# สร้าง Custom User Admin เพื่อจัดการฟิลด์เพิ่มเติม
|
||||||
|
class CustomUserAdmin(BaseUserAdmin):
|
||||||
|
# 1. กำหนดฟิลด์ที่จะแสดงในหน้าลิสต์ผู้ใช้
|
||||||
|
list_display = (
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
'role'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
6
backend/accounts/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'accounts'
|
||||||
81
backend/accounts/emails.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
from djoser import email
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
|
||||||
|
class CustomPasswordResetEmail(email.PasswordResetEmail):
|
||||||
|
|
||||||
|
template_name = None
|
||||||
|
|
||||||
|
def render(self, context=None):
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
url = context['url']
|
||||||
|
user = context['user']
|
||||||
|
|
||||||
|
self.html_content = self.create_email_html(url, user)
|
||||||
|
self.plain_content = self.create_email_plain(url, user)
|
||||||
|
|
||||||
|
def get_context_data(self):
|
||||||
|
return super().get_context_data()
|
||||||
|
|
||||||
|
def get_subject(self):
|
||||||
|
"""
|
||||||
|
subject ถูกสร้างโดย Djoser ใน context['subject']
|
||||||
|
"""
|
||||||
|
context = self.get_context_data()
|
||||||
|
return context.get("subject", "Password Reset")
|
||||||
|
|
||||||
|
def send(self, to, *args, **kwargs):
|
||||||
|
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=self.get_subject(),
|
||||||
|
body=self.plain_content,
|
||||||
|
from_email=self.from_email,
|
||||||
|
to=to
|
||||||
|
)
|
||||||
|
if self.html_content:
|
||||||
|
msg.attach_alternative(self.html_content, "text/html")
|
||||||
|
|
||||||
|
# djcelery_email จะ Intercep เมธอด send() ของ EmailMultiAlternatives โดยอัตโนมัติ
|
||||||
|
return msg.send()
|
||||||
|
|
||||||
|
def create_email_html(self, url, user):
|
||||||
|
# ดึง context data อีกครั้งเพื่อเข้าถึง protocol และ domain
|
||||||
|
context = self.get_context_data()
|
||||||
|
full_reset_url = f"{context['protocol']}://{context['domain']}{url}" # สร้าง Full URL
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<p>สวัสดีคุณ <b>{user.username}</b>,</p>
|
||||||
|
<p>คุณได้ร้องขอการรีเซ็ตรหัสผ่าน โปรดคลิกลิงก์ด้านล่างเพื่อตั้งรหัสผ่านใหม่:</p>
|
||||||
|
|
||||||
|
<p><a href="{full_reset_url}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">รีเซ็ตรหัสผ่าน (คลิกที่นี่)</a></p>
|
||||||
|
|
||||||
|
<p>หากลิงก์ไม่สามารถคลิกได้ กรุณาคัดลอกลิงก์นี้ไปวางในเบราว์เซอร์: <b>{full_reset_url}</b></p>
|
||||||
|
<p>หากคุณไม่ได้ร้องขอ โปรดเพิกเฉยต่ออีเมลนี้</p>
|
||||||
|
<p>ขอบคุณครับ,<br/>ทีม DDO Console</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_email_plain(self, url, user):
|
||||||
|
# ดึง context data อีกครั้งเพื่อเข้าถึง protocol และ domain
|
||||||
|
context = self.get_context_data()
|
||||||
|
full_reset_url = f"{context['protocol']}://{context['domain']}{url}" # ⬅️ สร้าง Full URL
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
สวัสดี {user.username},
|
||||||
|
|
||||||
|
คุณได้ร้องขอการรีเซ็ตรหัสผ่าน กรุณาใช้ลิงก์ด้านล่างเพื่อตั้งรหัสผ่านใหม่:
|
||||||
|
|
||||||
|
{full_reset_url}
|
||||||
|
|
||||||
|
หากคุณไม่ได้ร้องขอ โปรดเพิกเฉยต่ออีเมลนี้
|
||||||
|
|
||||||
|
ขอบคุณครับ,
|
||||||
|
ทีม DDO Console
|
||||||
|
"""
|
||||||
45
backend/accounts/migrations/0001_initial.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-10-29 22:54
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('phone_number', models.CharField(blank=True, max_length=20, null=True, unique=True)),
|
||||||
|
('email', models.EmailField(max_length=254, unique=True)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/accounts/migrations/__init__.py
Normal file
19
backend/accounts/models.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
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)
|
||||||
|
email = models.EmailField(unique=True)
|
||||||
|
|
||||||
|
# เพิ่มฟิลด์ Role สำหรับ RBAC ที่กำหนดเอง
|
||||||
|
ROLE_CHOICES = [
|
||||||
|
('ADMIN', 'Administrator'),
|
||||||
|
('OPERATOR', 'Operator'),
|
||||||
|
('VIEWER', 'Viewer'),
|
||||||
|
]
|
||||||
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='VIEWER')
|
||||||
|
|
||||||
|
# ไม่ต้องเปลี่ยนส่วนอื่นๆ ถ้าสืบทอดจาก AbstractUser
|
||||||
|
pass
|
||||||
59
backend/accounts/serializers.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from djoser.serializers import UserCreateSerializer as BaseUserCreateSerializer
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class UserCreateSerializer(BaseUserCreateSerializer):
|
||||||
|
# Serializer สำหรับการลงทะเบียน (Djoser จะใช้ตัวนี้)
|
||||||
|
class Meta(BaseUserCreateSerializer.Meta):
|
||||||
|
model = CustomUser
|
||||||
|
fields = ('id', 'username', 'email', 'phone_number', 'password') # เพิ่ม phone_number
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
# Serializer สำหรับการดึงข้อมูล (ใช้แสดงข้อมูลผู้ใช้ปัจจุบัน)
|
||||||
|
class Meta:
|
||||||
|
model = CustomUser
|
||||||
|
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')
|
||||||
|
|
||||||
|
# Serializer สำหรับ Login JWT ที่รับค่า remember_me
|
||||||
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
# print("CustomTokenObtainPairSerializer called")
|
||||||
|
data = super().validate(attrs)
|
||||||
|
|
||||||
|
# รับ remember_me จาก request (รองรับ true/false ทั้ง bool และ string)
|
||||||
|
remember_raw = self.context['request'].data.get('remember_me', False)
|
||||||
|
|
||||||
|
remember_me = (
|
||||||
|
remember_raw is True or
|
||||||
|
str(remember_raw).lower() == "true" or
|
||||||
|
remember_raw == "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh = self.get_token(self.user)
|
||||||
|
|
||||||
|
# ฝัง remember_me ลงใน payload
|
||||||
|
refresh['remember_me'] = remember_me
|
||||||
|
|
||||||
|
# ถ้า remember_me=True → อายุ Refresh Token เป็น 30 วัน
|
||||||
|
if remember_me:
|
||||||
|
refresh.set_exp(
|
||||||
|
from_time=refresh.current_time,
|
||||||
|
lifetime=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME_REMEMBER_ME']
|
||||||
|
)
|
||||||
|
|
||||||
|
data['refresh'] = str(refresh)
|
||||||
|
data['access'] = str(refresh.access_token)
|
||||||
|
return data
|
||||||
3
backend/accounts/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
5
backend/accounts/views.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import CustomTokenObtainPairSerializer
|
||||||
|
|
||||||
|
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||||
|
serializer_class = CustomTokenObtainPairSerializer
|
||||||
0
backend/api/__init__.py
Normal file
3
backend/api/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/api/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
||||||
36
backend/api/migrations/0001_initial.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-14 23:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
#('model_registry', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InferenceAuditLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='เวลาเรียกใช้')),
|
||||||
|
('endpoint_url', models.CharField(max_length=500, verbose_name='FastAPI Endpoint')),
|
||||||
|
('http_status', models.IntegerField(verbose_name='HTTP Status Code')),
|
||||||
|
('latency_ms', models.FloatField(verbose_name='Latency (ms)')),
|
||||||
|
('is_success', models.BooleanField(default=False, verbose_name='สำเร็จหรือไม่')),
|
||||||
|
('response_summary', models.TextField(blank=True, null=True, verbose_name='ผลลัพธ์สรุป/ข้อความ error')),
|
||||||
|
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ผู้ใช้งาน')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Inference Audit Log',
|
||||||
|
'verbose_name_plural': 'Inference Audit Logs',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/api/migrations/__init__.py
Normal file
26
backend/api/models.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
#from model_registry.models import AiModel
|
||||||
|
|
||||||
|
class InferenceAuditLog(models.Model):
|
||||||
|
"""บันทึกทุกคำสั่งรัน Inference ที่เข้ามาใน Gateway"""
|
||||||
|
|
||||||
|
# ข้อมูลผู้ใช้/โมเดล
|
||||||
|
# ใช้ ForeignKey ไปยัง CustomUser และ AiModel
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, verbose_name="ผู้ใช้งาน")
|
||||||
|
#model = models.ForeignKey(AiModel, on_delete=models.SET_NULL, null=True, verbose_name="Model ที่ถูกเรียก")
|
||||||
|
|
||||||
|
# ข้อมูลการประมวลผล
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True, verbose_name="เวลาเรียกใช้")
|
||||||
|
endpoint_url = models.CharField(max_length=500, verbose_name="FastAPI Endpoint")
|
||||||
|
http_status = models.IntegerField(verbose_name="HTTP Status Code")
|
||||||
|
latency_ms = models.FloatField(verbose_name="Latency (ms)")
|
||||||
|
is_success = models.BooleanField(default=False, verbose_name="สำเร็จหรือไม่")
|
||||||
|
|
||||||
|
# ผลลัพธ์
|
||||||
|
response_summary = models.TextField(blank=True, null=True, verbose_name="ผลลัพธ์สรุป/ข้อความ error")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
verbose_name = "Inference Audit Log"
|
||||||
|
verbose_name_plural = "Inference Audit Logs"
|
||||||
0
backend/api/serializers/__init__.py
Normal file
17
backend/api/serializers/audit_serializer.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from api.models import InferenceAuditLog
|
||||||
|
|
||||||
|
class InferenceAuditLogSerializer(serializers.ModelSerializer):
|
||||||
|
# แสดงข้อมูล Model และ User ที่เกี่ยวข้อง
|
||||||
|
#model_name = serializers.CharField(source='model.name', read_only=True)
|
||||||
|
username = serializers.CharField(source='user.username', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InferenceAuditLog
|
||||||
|
fields = (
|
||||||
|
'id', 'user', 'username',
|
||||||
|
#'model', 'model_name',
|
||||||
|
'timestamp', 'http_status', 'latency_ms',
|
||||||
|
'is_success', 'response_summary'
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
0
backend/api/services/__init__.py
Normal file
102
backend/api/services/health_service.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from django.db import connection, Error as DjangoDBError
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.conf import settings
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# สร้าง Exception ขึ้นมาใหม่แทนการใช้ของ model_registry
|
||||||
|
class HealthCheckError(Exception):
|
||||||
|
"""Custom exception raised for health check errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HealthService:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _check_database(self):
|
||||||
|
# Logic ตรวจสอบ CockroachDB
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
# ใช้ round() ปกติ
|
||||||
|
latency = round((time.time() - start_time) * 1000, 2)
|
||||||
|
return "UP", f"Query executed successfully. Latency: {latency}ms"
|
||||||
|
except DjangoDBError as e:
|
||||||
|
return "DOWN", f"DB Connection Error: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return "DOWN", f"Unknown DB Error: {e}"
|
||||||
|
|
||||||
|
def _check_cache(self):
|
||||||
|
# Logic ตรวจสอบ Redis
|
||||||
|
start_time = time.time()
|
||||||
|
test_key = 'health_test_key'
|
||||||
|
try:
|
||||||
|
cache.set(test_key, 'ok', timeout=1)
|
||||||
|
result = cache.get(test_key)
|
||||||
|
latency = round((time.time() - start_time) * 1000, 2)
|
||||||
|
if result == 'ok':
|
||||||
|
return "UP", f"Read/Write successful. Latency: {latency}ms"
|
||||||
|
return "DOWN", "Failed to retrieve test key."
|
||||||
|
except Exception as e:
|
||||||
|
return "DOWN", f"Redis Error: {e}"
|
||||||
|
|
||||||
|
def _check_minio(self):
|
||||||
|
"""Logic ตรวจสอบ Object Storage (MinIO) โดยใช้ boto3"""
|
||||||
|
try:
|
||||||
|
# 1. สร้าง S3 Client ด้วย boto3
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=os.getenv("MINIO_ENDPOINT", "http://localhost:9000"),
|
||||||
|
aws_access_key_id=os.getenv("MINIO_ACCESS_KEY", "minio_admin"),
|
||||||
|
aws_secret_access_key=os.getenv("MINIO_SECRET_KEY", "minio_p@ssw0rd!"),
|
||||||
|
# ใช้ Config เพื่อจัดการ timeout/signature version
|
||||||
|
config=Config(signature_version="s3v4", connect_timeout=5, read_timeout=10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ควรเปลี่ยนชื่อ MODEL_BUCKET เป็นชื่อที่สื่อถึงการใช้งานอื่น (แผนอนาคต)
|
||||||
|
bucket_name = os.getenv("MODEL_BUCKET", "models")
|
||||||
|
|
||||||
|
# 2. ทดสอบการเข้าถึง Bucket
|
||||||
|
s3_client.head_bucket(Bucket=bucket_name)
|
||||||
|
|
||||||
|
return "UP", f"Bucket '{bucket_name}' accessible via boto3."
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
error_code = e.response['Error']['Code']
|
||||||
|
if error_code == '404':
|
||||||
|
return "DOWN", f"Bucket '{bucket_name}' not found."
|
||||||
|
elif error_code == '403':
|
||||||
|
return "DOWN", f"MinIO S3 Access Denied. Check Key/Secret."
|
||||||
|
return "DOWN", f"MinIO S3 Error (Code {error_code}): {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return "DOWN", f"MinIO Connection Error: {e}"
|
||||||
|
|
||||||
|
def get_system_health(self):
|
||||||
|
"""เมธอดหลักที่รวมผลลัพธ์ทั้งหมด"""
|
||||||
|
results = {}
|
||||||
|
overall_status = "Healthy"
|
||||||
|
|
||||||
|
# รัน Check ทั้งหมด
|
||||||
|
db_status, db_details = self._check_database()
|
||||||
|
results['database'] = {"name": "CockroachDB", "status": db_status, "details": db_details}
|
||||||
|
if db_status != 'UP': overall_status = "Degraded"
|
||||||
|
|
||||||
|
cache_status, cache_details = self._check_cache()
|
||||||
|
results['cache'] = {"name": "Redis Cache", "status": cache_status, "details": cache_details}
|
||||||
|
if cache_status != 'UP': overall_status = "Degraded"
|
||||||
|
|
||||||
|
minio_status, minio_details = self._check_minio()
|
||||||
|
results['storage'] = {"name": "MinIO S3", "status": minio_status, "details": minio_details}
|
||||||
|
if minio_status != 'UP': overall_status = "Degraded"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": overall_status,
|
||||||
|
"services": results,
|
||||||
|
"last_checked": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
3
backend/api/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/api/views.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
backend/api/views/__init__.py
Normal file
62
backend/api/views/audit_viewset.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from rest_framework import viewsets, permissions, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from django.db.models import Avg, Count, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from api.models import InferenceAuditLog
|
||||||
|
from api.serializers.audit_serializer import InferenceAuditLogSerializer
|
||||||
|
from permissions.permission_classes import IsAdminOrManager # ใช้สิทธิ์เดียวกันกับ Model Registry
|
||||||
|
|
||||||
|
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม
|
||||||
|
"""
|
||||||
|
queryset = InferenceAuditLog.objects.all()
|
||||||
|
serializer_class = InferenceAuditLogSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated] # อนุญาตให้เข้าถึงเมื่อล็อกอินแล้ว
|
||||||
|
|
||||||
|
# ใช้ 'id' เป็นฟิลด์ค้นหา
|
||||||
|
lookup_field = 'id'
|
||||||
|
|
||||||
|
# ใช้ 'id' เป็นชื่อพารามิเตอร์ใน URL
|
||||||
|
lookup_url_kwarg = 'id'
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
# บังคับให้ Lookup Key (pk) เป็น String
|
||||||
|
kwargs[self.lookup_url_kwarg] = str(kwargs[self.lookup_url_kwarg])
|
||||||
|
|
||||||
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# คืน Log ล่าสุด 10 รายการ (สำหรับ Recent Events ใน Dashboard)
|
||||||
|
return self.queryset.select_related('user')[:10]
|
||||||
|
|
||||||
|
# -----------------------------------------------
|
||||||
|
# Custom Action: ดึงสถิติรวมสำหรับ Dashboard
|
||||||
|
# Endpoint: GET /api/v1/audit/inference-summary/
|
||||||
|
# -----------------------------------------------
|
||||||
|
@action(detail=False, methods=['get'], url_path='inference-summary')
|
||||||
|
def get_summary(self, request):
|
||||||
|
one_day_ago = timezone.now() - timedelta(hours=24)
|
||||||
|
|
||||||
|
# 1. คำนวณสถิติรวม (Global Metrics)
|
||||||
|
metrics = self.queryset.filter(timestamp__gte=one_day_ago).aggregate(
|
||||||
|
total_runs=Count('id'),
|
||||||
|
success_count=Count('id', filter=Q(is_success=True)),
|
||||||
|
avg_latency_ms=Avg('latency_ms')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. คำนวณ Success Rate
|
||||||
|
total = metrics.get('total_runs', 0)
|
||||||
|
success = metrics.get('success_count', 0)
|
||||||
|
success_rate = (success / total) * 100 if total > 0 else 0
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"time_window": "24 hours",
|
||||||
|
"total_runs": total,
|
||||||
|
"success_rate": round(success_rate, 2),
|
||||||
|
"avg_latency_ms": round(metrics.get('avg_latency_ms', 0) or 0, 2),
|
||||||
|
"last_logs": InferenceAuditLogSerializer(self.get_queryset(), many=True).data
|
||||||
|
})
|
||||||
36
backend/api/views/health_check_view.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status, permissions
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Import Service Layer
|
||||||
|
from api.services.health_service import HealthService
|
||||||
|
|
||||||
|
# Dependency Injection: สร้าง Instance ของ Service
|
||||||
|
health_service = HealthService()
|
||||||
|
|
||||||
|
class SystemHealthCheck(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/v1/health/
|
||||||
|
Controller สำหรับดึงสถานะ Health Check ของระบบ
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
# เรียกใช้ Service Layer
|
||||||
|
response_data = health_service.get_system_health()
|
||||||
|
|
||||||
|
# กำหนด HTTP Status ตามสถานะรวม
|
||||||
|
http_status = status.HTTP_200_OK
|
||||||
|
if response_data['status'] != "Healthy":
|
||||||
|
http_status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
return Response(response_data, status=http_status)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# จัดการข้อผิดพลาดที่ไม่คาดคิดในระดับ View
|
||||||
|
return Response(
|
||||||
|
{"status": "Error", "detail": f"Internal Server Error during health check: {e}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
0
backend/core/__init__.py
Normal file
16
backend/core/asgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for core project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
16
backend/core/celery.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import os
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
# กำหนดค่า Django settings module ให้ Celery
|
||||||
|
# 'core.settings' คือ path ของ settings.py ของโปรเจกต์ Django
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
# สร้าง Celery application instance
|
||||||
|
app = Celery('core') # ชื่อตรงนี้ต้องตรงกับ -A core และ CELERY_APP: core
|
||||||
|
|
||||||
|
# โหลด configuration จากไฟล์ settings.py ของ Django
|
||||||
|
# โดย Celery จะใช้ prefix CELERY_ (เช่น CELERY_BROKER_URL)
|
||||||
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
|
||||||
|
# ค้นหา tasks ทั้งหมดใน INSTALLED_APPS ของ Django โดยอัตโนมัติ
|
||||||
|
app.autodiscover_tasks()
|
||||||
354
backend/core/settings.py
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Django settings for core project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv() # โหลดตัวแปรจาก .env ใน Local Dev
|
||||||
|
except ImportError:
|
||||||
|
pass # ไม่ทำอะไรถ้า Module ไม่พบ (หมายความว่ารันอยู่ใน Docker)
|
||||||
|
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "cockroach-1")
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = 'django-insecure-a1c7+vs57v77ywrtrdrgg58utw5p1a4t2tq9!=w09y@nq-ig0q'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
DJANGO_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
]
|
||||||
|
|
||||||
|
THIRD_PARTY_APPS = [
|
||||||
|
'django_bolt',
|
||||||
|
'rest_framework',
|
||||||
|
'corsheaders',
|
||||||
|
'drf_spectacular',
|
||||||
|
'drf_spectacular_sidecar',
|
||||||
|
'djcelery_email',
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCAL_APPS = [
|
||||||
|
'api',
|
||||||
|
'accounts',
|
||||||
|
'user_profile',
|
||||||
|
'permissions',
|
||||||
|
#'model_registry'
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware', # สำคัญมากสำหรับ Frontend
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ตั้งค่า CORS: อนุญาตให้ Frontend เข้าถึง API ได้ (ใน Dev Mode)
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://localhost:5173", # Vite/React Default Port
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
]
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = True # ควรเป็น False ใน Production
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'core.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django_cockroachdb',
|
||||||
|
'NAME': os.environ.get('DB_NAME', 'my_db'),
|
||||||
|
'HOST': os.environ.get('DB_HOST', 'cockroach-1'),
|
||||||
|
'PORT': os.environ.get('DB_PORT', '26257'),
|
||||||
|
'USER': os.environ.get('DB_USER', 'root'),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
|
||||||
|
|
||||||
|
# อยู่ระดับเดียวกับ 'ENGINE' และ 'OPTIONS'
|
||||||
|
'support_check_by_version': False,
|
||||||
|
|
||||||
|
'OPTIONS': {
|
||||||
|
# ใช้ 'options' เพื่อส่งค่า config ไปยัง CockroachDB/PostgreSQL
|
||||||
|
'options': '-c default_transaction_read_only=off'
|
||||||
|
},
|
||||||
|
'ATOMIC_REQUESTS': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# 1. กำหนด Custom User Model
|
||||||
|
AUTH_USER_MODEL = 'accounts.CustomUser' # ต้องชี้ไปที่ Model ใน App accounts/models.py
|
||||||
|
|
||||||
|
# 2. ตั้งค่า REST Framework
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
# ใช้ JWT เป็นวิธีการยืนยันตัวตนหลัก
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
'DEFAULT_RENDERER_CLASSES': (
|
||||||
|
# ใช้ DRF Renderer ที่รับประกันว่าตัวเลขขนาดใหญ่จะถูกแปลงเป็น String
|
||||||
|
# (นี่คือวิธีแก้ปัญหา BigInt Truncation ที่ถูกต้องที่สุด)
|
||||||
|
'rest_framework.renderers.JSONRenderer',
|
||||||
|
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||||
|
),
|
||||||
|
# เพิ่มการตั้งค่าเพื่อรองรับตัวเลขขนาดใหญ่ใน JSON
|
||||||
|
'COERCE_DECIMAL_TO_STRING': False, # ตั้งค่านี้ไว้เผื่อ
|
||||||
|
# ตัวเลขขนาดใหญ่ (เช่น BigIntegerField) ต้องถูก Serialize เป็น String
|
||||||
|
'DATETIME_FORMAT': "%Y-%m-%d %H:%M:%S",
|
||||||
|
|
||||||
|
'DEFAULT_THROTTLE_CLASSES': [
|
||||||
|
'rest_framework.throttling.AnonRateThrottle',
|
||||||
|
'rest_framework.throttling.UserRateThrottle',
|
||||||
|
],
|
||||||
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
|
'anon': '100/day',
|
||||||
|
'user': '50/minute',
|
||||||
|
'inference_burst': '20/minute', # Rate Limit สำหรับงานหนัก
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
# ชี้ไปที่ Custom Serializer ที่อยู่ใน accounts.serializers
|
||||||
|
'TOKEN_OBTAIN_PAIR_SERIALIZER': 'accounts.serializers.CustomTokenObtainPairSerializer',
|
||||||
|
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'REFRESH_TOKEN_LIFETIME_REMEMBER_ME': timedelta(days=30),
|
||||||
|
|
||||||
|
# การตั้งค่าอื่น ๆ ของ SIMPLE_JWT ในอนาคต
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. ตั้งค่า DJOSER (เพื่อจัดการ Auth Endpoints)
|
||||||
|
DOMAIN = "localhost:5173"
|
||||||
|
SITE_NAME = 'localhost:5173' # หรือชื่อ Domain จริง
|
||||||
|
DJOSER = {
|
||||||
|
# ใช้งาน JWT โดยตรง
|
||||||
|
'USER_ID_FIELD': 'id', # ใช้ ID ของ User Model
|
||||||
|
'EMAIL': {
|
||||||
|
# ชี้ไปยัง Custom Email Class ที่เราสร้าง
|
||||||
|
'password_reset': 'accounts.emails.CustomPasswordResetEmail',
|
||||||
|
},
|
||||||
|
'PASSWORD_RESET_CONFIRM_URL': '/password/reset/confirm/{uid}/{token}', # URL ที่ Djoser จะใช้สร้างลิงก์ในอีเมล (ชี้ไปยัง Frontend Route)
|
||||||
|
'USERNAME_RESET_CONFIRM_URL': '/username/reset/confirm/{uid}/{token}',
|
||||||
|
'ACTIVATION_URL': '/activate/{uid}/{token}', # หากต้องการยืนยันอีเมล
|
||||||
|
'SERIALIZERS': {
|
||||||
|
'user_create': 'accounts.serializers.UserCreateSerializer', # จะสร้างในขั้นตอนถัดไป
|
||||||
|
'user': 'accounts.serializers.UserSerializer', # ใช้ UserSerializer เดิม
|
||||||
|
'current_user': 'accounts.serializers.UserSerializer',
|
||||||
|
},
|
||||||
|
'TOKEN_MODEL': None, # ไม่ใช้ TokenAuth แบบเก่า
|
||||||
|
'PERMISSIONS': {
|
||||||
|
'user_create': ['rest_framework.permissions.AllowAny'],
|
||||||
|
},
|
||||||
|
"DOMAIN": "localhost:5173",
|
||||||
|
"SITE_NAME": "localhost:5173",
|
||||||
|
"PROTOCOL": "http",
|
||||||
|
}
|
||||||
|
|
||||||
|
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
|
||||||
|
REDIS_PORT = os.getenv("REDIS_PORT", "6379")
|
||||||
|
|
||||||
|
# 1. ตั้งค่า Redis Cache
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
|
# ใช้ตัวแปร REDIS_HOST ที่ดึงมาจาก .env (localhost) หรือ Docker (redis)
|
||||||
|
"LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}/1",
|
||||||
|
"OPTIONS": {
|
||||||
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
|
"IGNORE_EXCEPTIONS": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. บังคับให้ Django ใช้ Cache (Redis) ในการเก็บ Session
|
||||||
|
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||||
|
SESSION_CACHE_ALIAS = "default"
|
||||||
|
|
||||||
|
# 3. ใช้ Redis สำหรับเก็บ Token Cache (สำหรับ DRF/JWT ถ้าจำเป็น)
|
||||||
|
# CACHE_MIDDLEWARE_SECONDS = 60 * 5 # 5 นาที
|
||||||
|
# CACHE_MIDDLEWARE_KEY_PREFIX = 'auth_cache'
|
||||||
|
|
||||||
|
# CELERY CONFIGURATION
|
||||||
|
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://redis:6379/0')
|
||||||
|
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://redis:6379/0')
|
||||||
|
CELERY_ACCEPT_CONTENT = ['json']
|
||||||
|
CELERY_TASK_SERIALIZER = 'json'
|
||||||
|
CELERY_RESULT_SERIALIZER = 'json'
|
||||||
|
CELERY_TIMEZONE = 'Asia/Bangkok'
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# SPECTACULAR CONFIGURATION
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
SPECTACULAR_SETTINGS = {
|
||||||
|
# ชื่อโครงการ
|
||||||
|
'TITLE': 'Unified Inbox Service API',
|
||||||
|
|
||||||
|
# คำอธิบายสั้นๆ ของโครงการ
|
||||||
|
'DESCRIPTION': 'API Gateway for Unified Inbox Service (Django DRF).',
|
||||||
|
|
||||||
|
# เวอร์ชัน 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).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '2. Application Service',
|
||||||
|
'description': 'Endpoints for Application Service.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'POSTPROCESSING_HOOKS': [
|
||||||
|
'core.spectacular_hooks.rename_djoser_tags',
|
||||||
|
],
|
||||||
|
# ตั้งค่าอื่น ๆ (ถ้าจำเป็น)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# CELERY EMAIL CONFIGURATION (Transactional Emails)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 1. EMAIL_BACKEND หลัก ใช้ Celery Email Backend
|
||||||
|
# Django จะส่งอีเมลทั้งหมดไปเข้าคิว Celery
|
||||||
|
EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend'
|
||||||
|
|
||||||
|
# 2. CELERY_EMAIL_BACKEND: กำหนด SMTP ที่ Celery Worker จะใช้
|
||||||
|
# Celery Worker จะใช้ตัวนี้ในการส่งอีเมลออกจริง ๆ
|
||||||
|
CELERY_EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
|
||||||
|
# Mailjet SMTP Configuration (ใช้ Env Vars)
|
||||||
|
#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
EMAIL_HOST = os.getenv('MAILJET_SMTP_HOST')
|
||||||
|
EMAIL_PORT = os.getenv('MAILJET_SMTP_PORT')
|
||||||
|
EMAIL_USE_TLS = os.getenv('MAILJET_SMTP_TLS', 'True') == 'True'
|
||||||
|
EMAIL_HOST_USER = os.getenv('MAILJET_API_KEY') # API Key เป็น Username
|
||||||
|
EMAIL_HOST_PASSWORD = os.getenv('MAILJET_SECRET_KEY') # Secret Key เป็น Password
|
||||||
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') # อีเมลผู้ส่ง
|
||||||
|
SERVER_EMAIL = DEFAULT_FROM_EMAIL # อีเมลสำหรับแจ้งเตือน Server
|
||||||
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
|
||||||
64
backend/core/urls.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for cremation_backend project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
# Import View ในแอพ /api
|
||||||
|
from api.views.health_check_view import SystemHealthCheck
|
||||||
|
from api.views.audit_viewset import AuditLogViewSet
|
||||||
|
|
||||||
|
from accounts.views import CustomTokenObtainPairView
|
||||||
|
|
||||||
|
# 1. กำหนดตัวแปร router ก่อนใช้งาน
|
||||||
|
router = DefaultRouter()
|
||||||
|
|
||||||
|
# 2. ลงทะเบียน API ViewSets (Project-Level Routing)
|
||||||
|
# URL: /api/v1/audit/ (AuditLogViewSet)
|
||||||
|
router.register(
|
||||||
|
r'audit',
|
||||||
|
AuditLogViewSet,
|
||||||
|
basename='auditlog',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
|
# 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/jwt/create/", CustomTokenObtainPairView.as_view(), name="jwt-create"),
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Health Check Endpoint URL: /api/v1/health/
|
||||||
|
path('api/v1/health/', SystemHealthCheck.as_view(), name='system-health'),
|
||||||
|
|
||||||
|
# 3. รวม Router API
|
||||||
|
path('api/v1/', include(router.urls)),
|
||||||
|
]
|
||||||
16
backend/core/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for core project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
34
backend/create_db.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import psycopg
|
||||||
|
import os
|
||||||
|
|
||||||
|
# --- ตั้งค่าการเชื่อมต่อ (สำคัญ: ต้องตรงกับค่าใน .env) ---
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "localhost") # ใช้ localhost
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", 26257)
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "my_db")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "root")
|
||||||
|
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
|
||||||
|
|
||||||
|
print(f"Attempting to connect to CockroachDB at {DB_HOST}:{DB_PORT} to create database '{DB_NAME}'...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ต้องเชื่อมต่อไปยัง Database มาตรฐาน (defaultdb) ก่อน เพื่อให้มีสิทธิ์สร้าง Database ใหม่
|
||||||
|
conn = psycopg.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
dbname="defaultdb", # ใช้ defaultdb เพื่อสร้าง my_db
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD
|
||||||
|
)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# คำสั่งสร้าง Database
|
||||||
|
cur.execute(f"CREATE DATABASE IF NOT EXISTS {DB_NAME};")
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print(f"Database '{DB_NAME}' created or already exists successfully.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to connect to or create database: {e}")
|
||||||
|
print("Ensure Docker Compose is running and environment variables (DB_HOST, DB_PORT) are set correctly.")
|
||||||
51
backend/docker-entrypoint.sh
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Waiting for CockroachDB cluster to be ready..."
|
||||||
|
|
||||||
|
# ใช้ Python แทน client cockroach
|
||||||
|
until python - <<END
|
||||||
|
import psycopg
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
conn = psycopg.connect(
|
||||||
|
host=os.environ.get("DB_HOST","cockroach-1"),
|
||||||
|
port=os.environ.get("DB_PORT",26257),
|
||||||
|
dbname="defaultdb",
|
||||||
|
user=os.environ.get("DB_USER","root"),
|
||||||
|
password=os.environ.get("DB_PASSWORD","")
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
except:
|
||||||
|
exit(1)
|
||||||
|
END
|
||||||
|
do
|
||||||
|
echo "CockroachDB is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "CockroachDB is ready."
|
||||||
|
|
||||||
|
# Create DB
|
||||||
|
python - <<END
|
||||||
|
import psycopg
|
||||||
|
import os
|
||||||
|
conn = psycopg.connect(
|
||||||
|
host=os.environ.get("DB_HOST","cockroach-1"),
|
||||||
|
port=os.environ.get("DB_PORT",26257),
|
||||||
|
dbname="defaultdb",
|
||||||
|
user=os.environ.get("DB_USER","root"),
|
||||||
|
password=os.environ.get("DB_PASSWORD","")
|
||||||
|
)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"CREATE DATABASE IF NOT EXISTS {os.environ.get('DB_NAME','my_db')};")
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
END
|
||||||
|
|
||||||
|
# Migrate & Superuser
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py shell -c "from django.contrib.auth import get_user_model; User=get_user_model(); User.objects.filter(username='admin').exists() or User.objects.create_superuser('admin','admin@softwarecraft.tech','Str0ngp@ssword123-')"
|
||||||
|
|
||||||
|
echo "Starting Django server..."
|
||||||
|
exec python manage.py runserver 0.0.0.0:8000
|
||||||
22
backend/manage.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
0
backend/permissions/__init__.py
Normal file
3
backend/permissions/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/permissions/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'permissions'
|
||||||
0
backend/permissions/migrations/__init__.py
Normal file
3
backend/permissions/models.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
56
backend/permissions/permission_classes.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from rest_framework import permissions
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# Base Class เพื่อใช้ Logic การตรวจสอบสิทธิ์ร่วมกัน
|
||||||
|
# ----------------------------------------------------
|
||||||
|
class BaseRolePermission(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Base class สำหรับตรวจสอบสิทธิ์ตาม is_superuser และ is_staff
|
||||||
|
"""
|
||||||
|
def is_admin(self, user):
|
||||||
|
return user.is_authenticated and user.is_superuser
|
||||||
|
|
||||||
|
def is_manager(self, user):
|
||||||
|
# ผู้จัดการคือ is_staff แต่ไม่ใช่ superuser
|
||||||
|
return user.is_authenticated and user.is_staff and not user.is_superuser
|
||||||
|
|
||||||
|
# หรือเพิ่มฟังก์ชัน is_model_owner(user) ถ้าจำเป็น
|
||||||
|
# ถ้าใช้ฟิลด์ 'role' ที่คุณเพิ่มใน Serializer ควรใช้:
|
||||||
|
# return user.is_authenticated and getattr(user, 'role', '').upper() == 'ADMIN'
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 1. Permission: อนุญาตเฉพาะ Admin เท่านั้น
|
||||||
|
# ----------------------------------------------------
|
||||||
|
class IsAdmin(BaseRolePermission):
|
||||||
|
message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (Admin Required)."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Admin คือ is_superuser
|
||||||
|
return self.is_admin(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 2. Permission: อนุญาตเฉพาะ Admin หรือ Manager เท่านั้น
|
||||||
|
# ----------------------------------------------------
|
||||||
|
class IsAdminOrManager(BaseRolePermission):
|
||||||
|
message = "คุณต้องมีสิทธิ์ Admin หรือ Manager."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Admin หรือ Manager (is_staff)
|
||||||
|
return self.is_admin(request.user) or self.is_manager(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 3. Permission: อนุญาตเฉพาะผู้ใช้ที่ผ่านการยืนยันตัวตนแล้ว (IsAuthenticated)
|
||||||
|
# และเป็น Viewer ขึ้นไป
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# ไม่จำเป็นต้องสร้างคลาสนี้ถ้าใช้ DRF's IsAuthenticated โดยตรง
|
||||||
|
# แต่ถ้าต้องการเพิ่ม Logic อื่นๆ ในอนาคต ควรใช้คลาสนี้
|
||||||
|
class IsAuthenticatedViewer(permissions.BasePermission):
|
||||||
|
message = "คุณต้องเข้าสู่ระบบก่อน"
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return request.user.is_authenticated
|
||||||
3
backend/permissions/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/permissions/views.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
19
backend/requirements.txt
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
Django
|
||||||
|
djangorestframework
|
||||||
|
django-cors-headers
|
||||||
|
django-bolt
|
||||||
|
django-cockroachdb
|
||||||
|
psycopg[binary]
|
||||||
|
typing_extensions
|
||||||
|
djoser # สำหรับ endpoints: /users/ (Register), /token/login (Login), /password/reset
|
||||||
|
djangorestframework-simplejwt # สำหรับสร้าง JWT (JSON Web Tokens)
|
||||||
|
django-redis # สำหรับเชื่อมต่อ Django กับ Redis
|
||||||
|
redis # ไคลเอนต์ Python สำหรับ Redis
|
||||||
|
celery # ตัว Worker
|
||||||
|
boto3
|
||||||
|
python-dotenv
|
||||||
|
drf-spectacular
|
||||||
|
drf-spectacular-sidecar
|
||||||
|
django-celery-email
|
||||||
|
python-dotenv
|
||||||
|
flower
|
||||||
0
backend/user_profile/__init__.py
Normal file
3
backend/user_profile/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/user_profile/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'user_profile'
|
||||||
0
backend/user_profile/migrations/__init__.py
Normal file
3
backend/user_profile/models.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
backend/user_profile/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/user_profile/views.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
157
infra/docker-compose.yml
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# infra/docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Node 1: Primary Node (Bootstrap)
|
||||||
|
cockroach-1:
|
||||||
|
image: cockroachdb/cockroach:latest
|
||||||
|
container_name: cockroach-1
|
||||||
|
command: start --insecure --host=cockroach-1 --listen-addr=cockroach-1:26257 --advertise-addr=cockroach-1:26257 --join=cockroach-1:26257,cockroach-2:26257,cockroach-3:26257
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- cockroach1:/cockroach/data
|
||||||
|
ports:
|
||||||
|
- "26257:26257" # Default SQL Port
|
||||||
|
- "8080:8080" # Web UI Port (สำหรับดูสถานะ Cluster)
|
||||||
|
|
||||||
|
# Node 2: Joining Node
|
||||||
|
cockroach-2:
|
||||||
|
image: cockroachdb/cockroach:latest
|
||||||
|
container_name: cockroach-2
|
||||||
|
command: start --insecure --host=cockroach-2 --listen-addr=cockroach-2:26257 --advertise-addr=cockroach-2:26257 --join=cockroach-1:26257,cockroach-2:26257,cockroach-3:26257
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- cockroach2:/cockroach/data
|
||||||
|
|
||||||
|
# Node 3: Joining Node
|
||||||
|
cockroach-3:
|
||||||
|
image: cockroachdb/cockroach:latest
|
||||||
|
container_name: cockroach-3
|
||||||
|
command: start --insecure --host=cockroach-3 --listen-addr=cockroach-3:26257 --advertise-addr=cockroach-3:26257 --join=cockroach-1:26257,cockroach-2:26257,cockroach-3:26257
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- cockroach3:/cockroach/data
|
||||||
|
|
||||||
|
# 4. Init Cluster (ตั้งค่าครั้งแรก)
|
||||||
|
init-cluster:
|
||||||
|
image: cockroachdb/cockroach:latest
|
||||||
|
container_name: init-cluster
|
||||||
|
command: init --insecure --host=cockroach-1:26257
|
||||||
|
depends_on:
|
||||||
|
- cockroach-1
|
||||||
|
- cockroach-2
|
||||||
|
- cockroach-3
|
||||||
|
# ตั้งค่าให้ Container ตายหลังจากรัน init เสร็จ (ไม่รันซ้ำ)
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# Redis Service
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# Celery Flower Monitoring Service
|
||||||
|
flower:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile.celery
|
||||||
|
container_name: celery_flower
|
||||||
|
ports:
|
||||||
|
- "5555:5555"
|
||||||
|
environment:
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
CELERY_APP: core
|
||||||
|
volumes:
|
||||||
|
- ../backend:/app
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- celery_worker
|
||||||
|
command: celery -A core flower --port=5555
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# Celery Worker Service
|
||||||
|
celery_worker:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile.celery
|
||||||
|
container_name: celery_worker
|
||||||
|
volumes:
|
||||||
|
- ../backend:/app
|
||||||
|
command: celery -A core worker -l info # รัน worker process
|
||||||
|
depends_on:
|
||||||
|
- redis # Worker ต้องรอให้ Redis พร้อม
|
||||||
|
- cockroach-1 # Worker อาจจะต้องเข้าถึง DB ด้วย
|
||||||
|
# Environment Variables สำหรับการส่งอีเมล
|
||||||
|
environment:
|
||||||
|
MAILJET_SMTP_HOST: ${MAILJET_SMTP_HOST}
|
||||||
|
MAILJET_SMTP_PORT: ${MAILJET_SMTP_PORT}
|
||||||
|
MAILJET_API_KEY: ${MAILJET_API_KEY}
|
||||||
|
MAILJET_SECRET_KEY: ${MAILJET_SECRET_KEY}
|
||||||
|
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL}
|
||||||
|
# กำหนด HOST/PORT DB/Redis ซ้ำอีกครั้ง (เป็น Best Practice)
|
||||||
|
REDIS_HOST: redis
|
||||||
|
DB_HOST: cockroach-1
|
||||||
|
|
||||||
|
# Backend/API (DRF)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ../backend:/app
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
# เปลี่ยนให้พึ่งพา Node ฐานข้อมูลโดยตรง (เพราะ entrypoint script จะจัดการการรอ)
|
||||||
|
depends_on:
|
||||||
|
- cockroach-1
|
||||||
|
- cockroach-2
|
||||||
|
- cockroach-3
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
DB_HOST: cockroach-1
|
||||||
|
DB_PORT: 26257
|
||||||
|
DB_NAME: my_db
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: ''
|
||||||
|
|
||||||
|
# AI Model Serving Service (MONAI Inference)
|
||||||
|
ai_model_server:
|
||||||
|
build:
|
||||||
|
context: ../ # อ้างอิงจาก Root Monorepo
|
||||||
|
dockerfile: infra/docker/Dockerfile.ai
|
||||||
|
container_name: ai_model_server
|
||||||
|
volumes:
|
||||||
|
- ../ai-medical:/app/ai-medical # Map โฟลเดอร์โค้ด AI
|
||||||
|
# - /path/to/gpu/device:/dev/nvidia0 # Uncomment ถ้าใช้ GPU
|
||||||
|
ports:
|
||||||
|
- "8001:8001" # Port สำหรับ API Model Serving
|
||||||
|
depends_on:
|
||||||
|
- backend # ให้มั่นใจว่า Backend พร้อมใช้งานก่อน
|
||||||
|
environment:
|
||||||
|
# กำหนดตัวแปรสภาพแวดล้อมที่ AI Service ต้องใช้
|
||||||
|
MODEL_STORAGE_URL: http://minio:9000/models/
|
||||||
|
MODEL_FILE_NAME: monai_model_v1.pth
|
||||||
|
|
||||||
|
# MinIO Service (S3-Compatible Object Storage)
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
container_name: minio
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API Port
|
||||||
|
- "9001:9001" # Console/Web UI Port
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minio_admin
|
||||||
|
MINIO_ROOT_PASSWORD: minio_p@ssw0rd!
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cockroach1:
|
||||||
|
cockroach2:
|
||||||
|
cockroach3:
|
||||||
|
minio_data:
|
||||||
41
mobile/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
20
mobile/App.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text>Open up App.js to start working on your app!</Text>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
29
mobile/app.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "mobile",
|
||||||
|
"slug": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
mobile/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
mobile/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
mobile/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
8
mobile/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||||
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||||
|
// the environment is set up appropriately
|
||||||
|
registerRootComponent(App);
|
||||||
9308
mobile/package-lock.json
generated
Normal file
20
mobile/package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~54.0.20",
|
||||||
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-web": "^0.21.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
24
web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
109
web/README.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 🚀 MONAI MLOps Console Frontend (DDO Console)
|
||||||
|
|
||||||
|
Frontend Console สำหรับการจัดการ **AI Model Registry**, **MLOps Pipeline**, และการเรียกใช้งาน **AI Inference Service**
|
||||||
|
รองรับงาน **Medical Imaging** โดยเชื่อมต่อกับ Backend หลักที่เป็น **Django DRF** (Lightweight Gateway/Security)
|
||||||
|
และระบบ AI inference บน **FastAPI/MONAI**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 สถาปัตยกรรมและเทคโนโลยีหลัก
|
||||||
|
|
||||||
|
| องค์ประกอบ | เทคโนโลยี | บทบาท / เหตุผล |
|
||||||
|
| :--- |:-----------------------------------------------| :--- |
|
||||||
|
| **Framework** | 🧩 **React (Vite)** | ความเร็วสูง, Hot Reload, โครงสร้าง Component-Based |
|
||||||
|
| **Styling** | 🎨 **Tailwind CSS + DaisyUI** | Utility-first CSS พร้อมชุด Component ที่สวยงาม |
|
||||||
|
| **Server State / Data Fetching** | ⚙️ **TanStack Query (React Query)** | จัดการ Cache, Loading, Error, และ Data Synchronization |
|
||||||
|
| **Form Handling** | 🧠 **React Hook Form + Yup** | Validation ที่มีประสิทธิภาพ, ลดการ re-render |
|
||||||
|
| **Security Flow** | 🔐 **JWT + Axios Interceptor + Refresh Token** | ใช้ `axiosClient` จัดการ Token, Refresh Token, และ Force Logout |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 โครงสร้างโปรเจกต์ (Clean Architecture)
|
||||||
|
|
||||||
|
โปรเจกต์นี้ใช้แนวทาง **Clean Architecture + Modularization**
|
||||||
|
เพื่อให้ดูแลและขยายระบบได้ง่ายในระยะยาว
|
||||||
|
|
||||||
|
| โฟลเดอร์ | หน้าที่ |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `src/routes/` | กำหนด Routing เช่น `AuthRoutes`, `PrivateRoutes`, และ `ProtectedRoute` |
|
||||||
|
| `src/services/` | เก็บ Hooks สำหรับเรียก API (`useModelList`, `useRunInferenceMutation`) และ `axiosClient` |
|
||||||
|
| `src/schemas/` | เก็บ **Yup Schemas** สำหรับตรวจสอบข้อมูลจากฟอร์ม |
|
||||||
|
| `src/features/` | เก็บ Redux Slice เช่น `authSlice` สำหรับจัดการ state ระดับ global |
|
||||||
|
| `src/pages/` | เก็บ Component หลักของแต่ละหน้า เช่น Dashboard, Model Registry |
|
||||||
|
| `src/components/` | Component ที่ใช้ซ้ำได้ เช่น Modal, Table, Input (RHF Adapter) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
ระบบมีการควบคุมสิทธิ์ทั้งฝั่ง Backend และ Frontend:
|
||||||
|
|
||||||
|
- **Backend (Django DRF):**
|
||||||
|
ใช้ Custom Permission Class (`IsAdminOrManager`) เพื่อจำกัดการเข้าถึง API
|
||||||
|
- **Frontend (React):**
|
||||||
|
ใช้ Role จาก JWT เพื่อกรองเมนูใน `sidebarRoutes.jsx`
|
||||||
|
และควบคุมการเข้าถึงหน้าใน `MainLayout.jsx`
|
||||||
|
|
||||||
|
| Role | สิทธิ์ |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `admin` | เข้าถึงทุกเมนู และสามารถจัดการผู้ใช้งานได้ |
|
||||||
|
| `manager` | เข้าถึงหน้า Model Registry และ Pipeline Management |
|
||||||
|
| `viewer` | อ่านข้อมูลเท่านั้น |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ การติดตั้งและเริ่มต้นใช้งาน
|
||||||
|
|
||||||
|
### 🔧 Prerequisites
|
||||||
|
|
||||||
|
- Node.js `v18+`
|
||||||
|
- npm หรือ yarn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🧰 1. ติดตั้ง Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚙️ 2. ตั้งค่า Environment Variables
|
||||||
|
|
||||||
|
สร้างไฟล์ .env.local ใน Root Directory
|
||||||
|
และกำหนด Base URL ของ Django DRF API Gateway:
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
หรือ URL จริงของ API Gateway
|
||||||
|
|
||||||
|
### 🧠 3. รัน Development Server
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
🌐 แอปจะรันที่ http://localhost:5173
|
||||||
|
|
||||||
|
และจะ Redirect ไปที่หน้า Login ทันที เพื่อบังคับยืนยันตัวตนก่อนเข้า Console
|
||||||
|
|
||||||
|
## 🧩 Best Practices & Design Principles
|
||||||
|
|
||||||
|
ใช้ Atomic Design Pattern สำหรับการออกแบบ Component
|
||||||
|
|
||||||
|
ใช้ TanStack Query DevTools เพื่อ Debug API Cache ได้ง่าย
|
||||||
|
|
||||||
|
รองรับการเพิ่ม Theme (ผ่าน DaisyUI Theme Config)
|
||||||
|
|
||||||
|
แยก Business Logic ออกจาก UI เพื่อให้ทดสอบได้ง่าย
|
||||||
|
|
||||||
|
## 📘 License
|
||||||
|
|
||||||
|
Distributed under the MIT License.
|
||||||
|
See LICENSE for more information.
|
||||||
|
|
||||||
|
## 👨💻 Authors
|
||||||
|
|
||||||
|
FlookSP — DDD Project
|
||||||
|
|
||||||
|
“Building Trustworthy AI Infrastructure for Medical Data Operations.”
|
||||||
29
web/eslint.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="corporate">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3984
web/package-lock.json
generated
Normal file
42
web/package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"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",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"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",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"yup": "^1.7.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
After Width: | Height: | Size: 185 KiB |
BIN
web/public/logo192.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
1
web/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
23
web/src/App.jsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import AuthRoutes from "./routes/AuthRoutes.jsx";
|
||||||
|
import PrivateRoutes from "./routes/PrivateRoutes.jsx";
|
||||||
|
import ToastNotification from './components/ToastNotification.jsx';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
// ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastNotification />
|
||||||
|
<Routes>
|
||||||
|
{/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
|
||||||
|
<Route path="/dashboard/*" element={<PrivateRoutes/>}/>
|
||||||
|
|
||||||
|
{/* Auth Routes: สำหรับ /login, /register และเป็น Fallback หลัก */}
|
||||||
|
<Route path="/*" element={<AuthRoutes/>}/>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
25
web/src/app/store.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import authReducer from '../features/auth/authSlice';
|
||||||
|
import toastReducer from '../features/toast/toastSlice';
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
// กำหนด Reducer หลัก
|
||||||
|
auth: authReducer,
|
||||||
|
toast: toastReducer,
|
||||||
|
// [เพิ่ม 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;
|
||||||
1
web/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
50
web/src/components/ConfirmModal.jsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaTrash, FaTimesCircle } from 'react-icons/fa'; // Icons
|
||||||
|
|
||||||
|
export default function ConfirmModal({ message, onConfirm, onCancel, isOpen, isDeleting }) {
|
||||||
|
|
||||||
|
// ใช้ open={isOpen} ใน <dialog> เพื่อควบคุมการแสดงผล
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Div ครอบคลุมจอทั้งหมด (Backdrop)
|
||||||
|
<div className="fixed inset-0 bg-gray-300 bg-opacity-30 z-[110] flex justify-center items-center p-4">
|
||||||
|
<dialog className="modal modal-open" open={isOpen}>
|
||||||
|
<div className="modal-box max-w-md bg-white p-6 shadow-2xl rounded-lg">
|
||||||
|
|
||||||
|
{/* Header/Title */}
|
||||||
|
<h3 className="font-bold text-xl text-error flex items-center">
|
||||||
|
<FaTimesCircle className="h-6 w-6 mr-2" />
|
||||||
|
ยืนยันการลบข้อมูล
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Message Body */}
|
||||||
|
<p className="py-4 text-gray-700 whitespace-normal break-words">{message}</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="modal-action">
|
||||||
|
{/* ปุ่มยกเลิก */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ปุ่มยืนยันการลบ */}
|
||||||
|
<button
|
||||||
|
// ใช้ isDeleting เพื่อแสดง Loading Spinner
|
||||||
|
className={"btn btn-error " + (isDeleting ? 'loading' : '')}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'กำลังดำเนินการ...' : 'ยืนยันการลบ'}
|
||||||
|
<FaTrash className="w-4 h-4 ml-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
34
web/src/components/FileUploadAndSubmit.jsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// ใน component นี้ เราจะจัดการแค่ UI/Input/Button และเรียก onSubmit() ที่มาจาก parent
|
||||||
|
export default function FileUploadAndSubmit({ file, setFile, isPending, onSubmit, selectedModelId }) {
|
||||||
|
|
||||||
|
// กำหนดว่าปุ่มควรถูก Disable หรือไม่
|
||||||
|
const isDisabled = isPending || !selectedModelId || !file;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">อัปโหลดไฟล์ DICOM / NIfTI</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="file-input file-input-bordered w-full"
|
||||||
|
accept=".nii,.nii.gz,.zip,.dcm"
|
||||||
|
onChange={(e) => setFile(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`btn btn-primary w-full ${isPending ? 'loading' : ''}`}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{isPending ? 'กำลังประมวลผล...' : 'เริ่มประมวลผล'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
web/src/components/Health/InfraStatusCard.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TitleCard from '../TitleCard';
|
||||||
|
import { FaDatabase, FaServer, FaFlask } from 'react-icons/fa';
|
||||||
|
import ServiceStatus from '../ServiceStatus';
|
||||||
|
|
||||||
|
const infrastructureServicesMap = [
|
||||||
|
{ key: 'database', name: 'CockroachDB', icon: FaDatabase },
|
||||||
|
{ key: 'cache', name: 'Redis Cache', icon: FaServer },
|
||||||
|
{ key: 'storage', name: 'MinIO S3', icon: FaServer },
|
||||||
|
{ key: 'ai_service', name: 'MONAI FastAPI (Instance)', icon: FaFlask },
|
||||||
|
];
|
||||||
|
|
||||||
|
const InfraStatusCard = ({ serviceData }) => {
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-200 shadow-md">
|
||||||
|
<h3 className="card-title p-4 pb-0 text-base-content">Infrastructure Status</h3>
|
||||||
|
<div className="card-body p-0 divide-y divide-base-300">
|
||||||
|
{infrastructureServicesMap.map((service) => {
|
||||||
|
const svc = serviceData?.[service.key];
|
||||||
|
if (!svc) return null;
|
||||||
|
return (
|
||||||
|
<ServiceStatus
|
||||||
|
key={service.key}
|
||||||
|
name={service.name}
|
||||||
|
status={svc.status}
|
||||||
|
details={svc.details}
|
||||||
|
icon={service.icon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfraStatusCard;
|
||||||
34
web/src/components/Health/ModelEndpointList.jsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaFlask, FaCheckCircle, FaTimesCircle } from 'react-icons/fa';
|
||||||
|
import ServiceStatus from '../ServiceStatus';
|
||||||
|
|
||||||
|
const ModelEndpointsStatus = ({ modelData }) => {
|
||||||
|
const models = modelData.models || [];
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
return <p className="text-center text-gray-500 p-4">ไม่พบ Model ที่มีสถานะ ACTIVE</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-3 flex items-center space-x-2 text-base-content">
|
||||||
|
<FaFlask className="text-primary" />
|
||||||
|
<span>สถานะ Model Endpoints ({models.length} รายการ)</span>
|
||||||
|
</h3>
|
||||||
|
<div className="bg-base-100 shadow-inner rounded-lg border border-base-300 divide-y divide-base-200">
|
||||||
|
{models.map((model) => (
|
||||||
|
<ServiceStatus
|
||||||
|
key={model.id}
|
||||||
|
name={`${model.name} (v${model.model_version})`}
|
||||||
|
status={model.status}
|
||||||
|
// แสดง Latency หรือ Detail ของ Check
|
||||||
|
details={model.details}
|
||||||
|
icon={model.status === 'UP' ? FaCheckCircle : FaTimesCircle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelEndpointsStatus;
|
||||||
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
@ -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
|
||||||
139
web/src/components/LoginForm.jsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
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'; // ตรวจสอบ Path ให้ถูกต้อง
|
||||||
|
|
||||||
|
import { loginSchema } from '../schemas/authSchema'; // ตรวจสอบ Path ให้ถูกต้อง
|
||||||
|
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const loginMutation = useLoginMutation();
|
||||||
|
const [apiErrorMessage, setApiErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm({
|
||||||
|
resolver: yupResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data) => {
|
||||||
|
setApiErrorMessage("");
|
||||||
|
|
||||||
|
// เรียกใช้ Mutation (Token + Fetch User)
|
||||||
|
loginMutation.mutate({
|
||||||
|
username: data.username,
|
||||||
|
password: data.password
|
||||||
|
}, {
|
||||||
|
onError: (error) => {
|
||||||
|
setApiErrorMessage(error.message || "การล็อกอินล้มเหลว กรุณาตรวจสอบข้อมูล");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = loginMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Card หลัก: ปรับ bg-white, shadow, border-radius ตามสไตล์ Contabo
|
||||||
|
<div className="card w-full shadow-lg rounded-xl bg-white min-h-full">
|
||||||
|
<div className="grid md:grid-cols-2 grid-cols-1">
|
||||||
|
|
||||||
|
{/* คอลัมน์ซ้าย: Intro/Features */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<LandingIntro />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* คอลัมน์ขวา: Form Wrapper (จัดกึ่งกลางเนื้อหา) */}
|
||||||
|
<div className='py-12 px-10 flex flex-col justify-center items-center'>
|
||||||
|
|
||||||
|
{/* Div จำกัดความกว้างของฟอร์มจริง ๆ */}
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
|
||||||
|
{/* Text: ปรับเป็นภาษาไทย */}
|
||||||
|
<h2 className='text-3xl font-bold mb-8 text-center text-gray-800'>ยินดีต้อนรับกลับมา</h2>
|
||||||
|
<p className='text-sm mb-6 text-center text-gray-600 leading-relaxed'>
|
||||||
|
ที่นี่คุณสามารถเข้าสู่ระบบ DDO Console ได้ โปรดทราบว่าข้อมูลประจำตัวนี้อาจแตกต่างจากข้อมูลประจำตัวสำหรับระบบอื่น ๆ
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
|
||||||
|
{/* API Error Alert */}
|
||||||
|
{apiErrorMessage && (
|
||||||
|
<div className="alert alert-error text-sm my-4 rounded-md">
|
||||||
|
<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: เปลี่ยนเป็น 'ชื่อผู้ใช้' */}
|
||||||
|
<InputText
|
||||||
|
type="text"
|
||||||
|
containerStyle="mt-4"
|
||||||
|
labelTitle="ชื่อผู้ใช้"
|
||||||
|
placeholder="ชื่อผู้ใช้ของคุณ"
|
||||||
|
{...register('username')} // ฟิลด์ใน RHF ยังคงใช้ 'username'
|
||||||
|
error={errors.username}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Input: เปลี่ยนเป็น 'รหัสผ่าน' */}
|
||||||
|
<InputText
|
||||||
|
type="password"
|
||||||
|
containerStyle="mt-4"
|
||||||
|
labelTitle="รหัสผ่าน"
|
||||||
|
placeholder="********"
|
||||||
|
{...register('password')}
|
||||||
|
error={errors.password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkbox และ Link ลืมรหัสผ่าน */}
|
||||||
|
<div className='flex justify-between items-center text-sm mt-6'>
|
||||||
|
<label className="flex items-center space-x-2 text-gray-600">
|
||||||
|
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" {...register('remember_me')}/>
|
||||||
|
<span>จดจำฉัน</span>
|
||||||
|
</label>
|
||||||
|
<Link to="/forgot-password">
|
||||||
|
<span className="inline-block text-blue-600 hover:underline hover:cursor-pointer transition duration-200">
|
||||||
|
ลืมรหัสผ่าน?
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={"btn mt-8 w-full btn-primary rounded-md shadow-lg transition duration-300 ease-in-out" + (loading ? " loading" : "")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'กำลังเข้าสู่ระบบ...' : 'เข้าสู่ระบบ'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* "ยังไม่มีบัญชีใช่ไหม?" */}
|
||||||
|
<div className='text-center mt-6 text-gray-600'>
|
||||||
|
ยังไม่มีบัญชีใช่ไหม?
|
||||||
|
<Link to="/register">
|
||||||
|
<span className="inline-block text-blue-600 hover:underline hover:cursor-pointer transition duration-200 ml-1">
|
||||||
|
ลงทะเบียน
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div> {/* สิ้นสุด Div จำกัดความกว้าง */}
|
||||||
|
|
||||||
|
</div> {/* สิ้นสุดคอลัมน์ขวา */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
web/src/components/ModalForm.jsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import InputText from './InputText';
|
||||||
|
import { modelSchema } from '../schemas/modelSchema';
|
||||||
|
import SelectInput from './SelectInput';
|
||||||
|
|
||||||
|
export default function ModalForm({ isOpen, onClose, mode, OnSubmit, model }) {
|
||||||
|
|
||||||
|
// สถานะ Status Choices จาก AiModel (กำหนดเองใน Frontend)
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'ACTIVE', name: 'ACTIVE (พร้อมใช้งาน)' },
|
||||||
|
{ value: 'INACTIVE', name: 'INACTIVE (ไม่ได้ใช้งาน)' },
|
||||||
|
{ value: 'TESTING', name: 'TESTING (กำลังทดสอบ)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. RHF Setup
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting }
|
||||||
|
} = useForm({
|
||||||
|
resolver: yupResolver(modelSchema), // ใช้ Schema สำหรับ Model Registry
|
||||||
|
// กำหนดค่าเริ่มต้นตาม mode
|
||||||
|
defaultValues: {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
model_version: 'v1.0.0',
|
||||||
|
developer: '',
|
||||||
|
base_url: '',
|
||||||
|
inference_path: '',
|
||||||
|
status: 'INACTIVE',
|
||||||
|
auth_required: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Logic โหลดข้อมูล Model เข้า Form เมื่อเปิดโหมด Edit
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'edit' && model) {
|
||||||
|
// ใช้ reset() ของ RHF เพื่อโหลดข้อมูลลงในฟอร์ม
|
||||||
|
reset({
|
||||||
|
id: model.id || '', // ID เป็น ReadOnly
|
||||||
|
name: model.name || '',
|
||||||
|
model_version: model.model_version || 'v1.0.0',
|
||||||
|
developer: model.developer || '',
|
||||||
|
base_url: model.base_url || '',
|
||||||
|
inference_path: model.inference_path || '',
|
||||||
|
status: model.status || 'INACTIVE',
|
||||||
|
auth_required: model.auth_required || false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Reset ฟอร์มเป็นค่าเริ่มต้นเมื่อเปิดโหมด Add
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [mode, model, reset]);
|
||||||
|
|
||||||
|
|
||||||
|
// 3. Logic การ Submit
|
||||||
|
const onSubmitHandler = (data) => {
|
||||||
|
// เพิ่ม ID เข้าไปในข้อมูลถ้าเป็นโหมด Edit
|
||||||
|
if (mode === 'edit') {
|
||||||
|
data.id = model.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// เรียกใช้ OnSubmit ที่มาจาก Page Component (เรียก Mutation)
|
||||||
|
OnSubmit(data);
|
||||||
|
|
||||||
|
onClose(); // ปิด Modal หลังจาก Submit
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalTitle = mode === 'add' ? 'ลงทะเบียน AI Model ใหม่' : `แก้ไข Model: ${model?.name}`;
|
||||||
|
const submitText = mode === 'add' ? 'ลงทะเบียน' : 'บันทึกการแก้ไข';
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog id="model_form_modal" className="modal" open={isOpen}>
|
||||||
|
<div className="modal-box w-11/12 max-w-4xl"> {/* เพิ่มขนาด Modal */}
|
||||||
|
<h3 className="font-bold text-xl py-4">{modalTitle}</h3>
|
||||||
|
|
||||||
|
{/* ปุ่มปิด Modal */}
|
||||||
|
<button type="button" className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={onClose}>✕</button>
|
||||||
|
|
||||||
|
{/* 4. Form หลัก (ใช้ handleSubmit ของ RHF) */}
|
||||||
|
<form onSubmit={handleSubmit(onSubmitHandler)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
{/* กลุ่มที่ 1: ชื่อและสถานะ */}
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<InputText
|
||||||
|
labelTitle="ชื่อโมเดล (เช่น Spleen Segmentation)"
|
||||||
|
placeholder="Model Name"
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<InputText
|
||||||
|
labelTitle="เวอร์ชัน"
|
||||||
|
placeholder="v1.0.0"
|
||||||
|
type="text"
|
||||||
|
{...register('model_version')}
|
||||||
|
error={errors.model_version}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
{/* กลุ่มที่ 2: Base URL / Inference Path */}
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="w-full md:w-3/5">
|
||||||
|
<InputText
|
||||||
|
labelTitle="Base URL (Internal Service Address)"
|
||||||
|
placeholder="http://ai_model_server:8001"
|
||||||
|
type="url"
|
||||||
|
{...register('base_url')}
|
||||||
|
error={errors.base_url}
|
||||||
|
labelStyle='text-warning'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-2/5">
|
||||||
|
<InputText
|
||||||
|
labelTitle="Inference Path"
|
||||||
|
placeholder="inference/spleen/"
|
||||||
|
type="text"
|
||||||
|
{...register('inference_path')}
|
||||||
|
error={errors.inference_path}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
{/* กลุ่มที่ 3: สถานะและการควบคุม */}
|
||||||
|
{/* ---------------------------------- */}
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<SelectInput
|
||||||
|
labelTitle="สถานะบริการ"
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
{...register('status')} // RHF register
|
||||||
|
error={errors.status} // RHF error
|
||||||
|
/>
|
||||||
|
{errors.status && <p className="text-error text-xs mt-1">{errors.status.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-1/2 flex items-end">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label cursor-pointer space-x-2">
|
||||||
|
<span className="label-text">ต้องการ Internal Auth Key?</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
{...register('auth_required')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<InputText
|
||||||
|
labelTitle="ผู้พัฒนา/ทีม"
|
||||||
|
placeholder="Core AI Team"
|
||||||
|
type="text"
|
||||||
|
{...register('developer')}
|
||||||
|
error={errors.developer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. ปุ่ม Submit และ Cancel */}
|
||||||
|
<div className="modal-action mt-6">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={isSubmitting}>ยกเลิก</button>
|
||||||
|
<button type="submit" className={"btn btn-success" + (isSubmitting ? " loading" : "")} disabled={isSubmitting}>
|
||||||
|
{submitText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
web/src/components/ModelRegistry/ModelTable.jsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
//import { useTestConnection } from '../../services/modelRegistryApi'; // Hook สำหรับ Test Connection
|
||||||
|
import { FaCheckCircle, FaExclamationTriangle, FaTrash, FaEdit } from 'react-icons/fa';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { addToast } from '../../features/toast/toastSlice';
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// Helper Function: แสดง Badge สถานะ
|
||||||
|
// ----------------------------------------------------
|
||||||
|
const getStatusBadge = (status) => {
|
||||||
|
const baseClass = "badge badge-sm";
|
||||||
|
switch (status) {
|
||||||
|
case 'ACTIVE': return <div className={`${baseClass} badge-success font-medium`}>ACTIVE</div>;
|
||||||
|
case 'TESTING': return <div className={`${baseClass} badge-warning font-medium`}>TESTING</div>;
|
||||||
|
case 'INACTIVE': return <div className={`${baseClass} badge-info font-medium`}>INACTIVE</div>;
|
||||||
|
default: return <div className={`${baseClass} badge-neutral font-medium`}>N/A</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) {
|
||||||
|
// ประกาศ useDispatch Hook
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Hook สำหรับทดสอบการเชื่อมต่อ (Test Connection)
|
||||||
|
// ใช้ Hook ตรงนี้ เพราะเป็น Logic ที่เกี่ยวข้องกับ Action ในตารางโดยตรง
|
||||||
|
//const testConnectionMutation = useTestConnection();
|
||||||
|
|
||||||
|
// 2. Logic การแสดงผลตาราง
|
||||||
|
return (
|
||||||
|
<table className="table w-full table-zebra table-pin-rows">
|
||||||
|
{/* Table Head */}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className='w-1/6'>Model/Version</th>
|
||||||
|
<th className='w-1/4'>Base URL</th>
|
||||||
|
<th>Developer</th>
|
||||||
|
<th className='w-1/12'>Status</th>
|
||||||
|
<th className='w-1/6'>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<tbody>
|
||||||
|
{models.map((model) => (
|
||||||
|
<tr className="hover:bg-base-300" key={model.id}>
|
||||||
|
<td className='font-semibold'>
|
||||||
|
{model.name}
|
||||||
|
<span className="badge badge-xs badge-ghost ml-2">{model.model_version}</span>
|
||||||
|
</td>
|
||||||
|
<td className='text-xs max-w-xs overflow-hidden truncate tooltip' data-tip={model.full_endpoint}>
|
||||||
|
{model.full_endpoint}
|
||||||
|
</td>
|
||||||
|
<td>{model.developer || 'N/A'}</td>
|
||||||
|
<td>{getStatusBadge(model.status)}</td>
|
||||||
|
|
||||||
|
{/* Actions (CRUD/Control) */}
|
||||||
|
<td className='flex space-x-1'>
|
||||||
|
{/* Edit/Update Button (Protected by RBAC in Backend) */}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-secondary btn-xs tooltip"
|
||||||
|
data-tip="Edit Model Metadata"
|
||||||
|
onClick={() => handleOpenEdit(model)}
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Test Connection Button */}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-info btn-xs tooltip"
|
||||||
|
data-tip="Test Connection"
|
||||||
|
onClick={()=>{}}
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete Button (Protected by RBAC in Backend) */}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-error btn-xs tooltip"
|
||||||
|
data-tip="Delete Model"
|
||||||
|
onClick={() => handleDelete(String(model.id))}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelTable;
|
||||||
26
web/src/components/ModelRegistry/ModelTopBar.jsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaPlus, FaSearch } from 'react-icons/fa';
|
||||||
|
|
||||||
|
function ModelTopBar({ onOpenAdd, onSearch }) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center space-x-3'>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search models..."
|
||||||
|
className="input input-bordered input-sm w-32 md:w-auto"
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-square btn-sm"><FaSearch /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Button */}
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={onOpenAdd}>
|
||||||
|
<FaPlus className="w-4 h-4" /> Register New Model
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelTopBar;
|
||||||
28
web/src/components/ModelSelector.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ModelSelector({ activeModels, isLoading, selectedModelId, setSelectedModelId, isPending }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">เลือก Model</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={selectedModelId}
|
||||||
|
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||||
|
disabled={isLoading || isPending}
|
||||||
|
>
|
||||||
|
<option value="">-- เลือก Model --</option>
|
||||||
|
{isLoading ? (
|
||||||
|
<option disabled>กำลังโหลด...</option>
|
||||||
|
) : (
|
||||||
|
activeModels?.map(model => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.name} (v{model.model_version})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
web/src/components/PasswordReset/ResetInfoCard.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {FaEnvelope, FaQuestionCircle, FaClock, FaExclamationTriangle, FaPhoneAlt} from 'react-icons/fa';
|
||||||
|
|
||||||
|
export default function ResetInfoCard() {
|
||||||
|
return (
|
||||||
|
// ใช้ w-full h-full เพื่อให้ Component ขยายเต็มพื้นที่ Container
|
||||||
|
<div className="w-full h-full p-12 bg-base-200 flex flex-col justify-between">
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center text-xl font-bold text-primary mb-4 border-b border-base-200 pb-3">
|
||||||
|
<FaQuestionCircle className="w-6 h-6 mr-3 text-warning" />
|
||||||
|
คำแนะนำในการรีเซ็ตรหัสผ่าน
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1. ขั้นตอนการรีเซ็ต */}
|
||||||
|
<h3 className="text-lg font-semibold text-base-content mb-2 flex items-center">
|
||||||
|
<FaClock className="w-4 h-4 mr-2 text-info" />
|
||||||
|
กระบวนการ
|
||||||
|
</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-base-content/70 ml-4">
|
||||||
|
<li>กรุณากรอกอีเมลที่ใช้ลงทะเบียน</li>
|
||||||
|
<li>ระบบจะส่งลิงก์รีเซ็ตไปให้คุณ</li>
|
||||||
|
<li>ลิงก์จะหมดอายุภายใน <b>24 ชั่วโมง</b></li>
|
||||||
|
<li>หากไม่พบอีเมล ให้ตรวจสอบใน <b>/Spam Folder</b></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="divider my-4"></div>
|
||||||
|
|
||||||
|
{/* 2. ช่องทางติดต่อช่วยเหลือ */}
|
||||||
|
<h3 className="text-lg font-semibold text-base-content mb-2 flex items-center">
|
||||||
|
<FaExclamationTriangle className="w-4 h-4 mr-2 text-error" />
|
||||||
|
หากไม่ได้รับอีเมล
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-base-content/70 ml-2">
|
||||||
|
<li className='flex items-center'>
|
||||||
|
<FaEnvelope className="w-4 h-4 mr-2 text-info" />
|
||||||
|
<span className="font-semibold">ติดต่อ:</span> support@ddo.tech
|
||||||
|
</li>
|
||||||
|
<li className='flex items-center'>
|
||||||
|
<FaPhoneAlt className="w-4 h-4 mr-2 text-info" />
|
||||||
|
<span className="font-semibold">โทร:</span> 02-XXX-XXXX
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
web/src/components/PasswordReset/ResetPasswordForm.jsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
|
||||||
|
import InputText from '../InputText';
|
||||||
|
import ErrorText from '../ErrorText';
|
||||||
|
import { resetConfirmSchema } from '../../schemas/authSchema';
|
||||||
|
|
||||||
|
export default function ResetPasswordForm({ uid, token, confirmMutation }) {
|
||||||
|
|
||||||
|
// 1. Hook Form Setup
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||||
|
resolver: yupResolver(resetConfirmSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Submission Handler
|
||||||
|
const onSubmit = (data) => {
|
||||||
|
// ส่ง uid, token, new_password, และ re_new_password ไปให้ API
|
||||||
|
confirmMutation.mutate({
|
||||||
|
uid: uid,
|
||||||
|
token: token,
|
||||||
|
new_password: data.new_password,
|
||||||
|
re_new_password: data.re_new_password
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = confirmMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-base-content text-center">ตั้งรหัสผ่านใหม่</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-6 text-center">กรุณากรอกรหัสผ่านใหม่เพื่อเข้าสู่ระบบ</p>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
type="password"
|
||||||
|
labelTitle="รหัสผ่านใหม่"
|
||||||
|
placeholder="รหัสผ่านใหม่"
|
||||||
|
{...register('new_password')}
|
||||||
|
error={errors.new_password}
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
type="password"
|
||||||
|
labelTitle="ยืนยันรหัสผ่านใหม่"
|
||||||
|
placeholder="ยืนยันรหัสผ่าน"
|
||||||
|
{...register('re_new_password')}
|
||||||
|
error={errors.re_new_password}
|
||||||
|
containerStyle="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{confirmMutation.isError && (
|
||||||
|
<ErrorText styleClass="my-4">{confirmMutation.error.message}</ErrorText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={"btn btn-primary w-full mt-6" + (loading ? " loading" : "")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'กำลังตั้งค่า...' : 'ตั้งค่ารหัสผ่านใหม่'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
web/src/components/Profile/PasswordChangeForm.jsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { passwordSchema } from '../../schemas/authSchema';
|
||||||
|
import InputText from '../InputText';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import axiosClient from '../../services/axiosClient';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { addToast } from '../../features/toast/toastSlice';
|
||||||
|
import ErrorText from '../ErrorText';
|
||||||
|
|
||||||
|
|
||||||
|
// Hook เฉพาะสำหรับการเปลี่ยนรหัสผ่าน (POST /users/set_password/)
|
||||||
|
const useChangePasswordMutation = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ current_password, new_password, re_new_password }) => {
|
||||||
|
// Djoser Endpoint
|
||||||
|
const response = await axiosClient.post(`/api/v1/auth/users/set_password/`, {
|
||||||
|
current_password: current_password,
|
||||||
|
new_password: new_password,
|
||||||
|
re_new_password: re_new_password, // Djoser ต้องการ re_new_password field
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
dispatch(addToast({ message: 'เปลี่ยนรหัสผ่านสำเร็จแล้ว!', type: 'success' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['userProfile'] }); // Invalidate เพื่อความปลอดภัย
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const msg = error.response?.data?.current_password?.[0] || 'รหัสผ่านปัจจุบันไม่ถูกต้อง';
|
||||||
|
dispatch(addToast({ message: `เปลี่ยนรหัสผ่านล้มเหลว: ${msg}`, type: 'error' }));
|
||||||
|
throw new Error(msg); // Throw เพื่อให้ RHF จับ error ได้
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default function PasswordChangeForm() {
|
||||||
|
const changeMutation = useChangePasswordMutation();
|
||||||
|
const isChanging = changeMutation.isPending;
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||||
|
resolver: yupResolver(passwordSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data) => {
|
||||||
|
changeMutation.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset(); // Clear form on success
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
|
||||||
|
<InputText labelTitle="รหัสผ่านปัจจุบัน" type="password" {...register('current_password')} error={errors.current_password} />
|
||||||
|
<InputText labelTitle="รหัสผ่านใหม่" type="password" {...register('new_password')} error={errors.new_password} />
|
||||||
|
<InputText labelTitle="ยืนยันรหัสผ่านใหม่" type="password" {...register('re_new_password')} error={errors.re_new_password} />
|
||||||
|
|
||||||
|
{changeMutation.isError && (
|
||||||
|
<ErrorText styleClass="mt-4">
|
||||||
|
{errors.current_password?.message || changeMutation.error.message || 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน'}
|
||||||
|
</ErrorText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={"btn btn-warning mt-6" + (isChanging ? " loading" : "")}
|
||||||
|
disabled={isChanging}
|
||||||
|
>
|
||||||
|
{isChanging ? 'กำลังดำเนินการ...' : 'เปลี่ยนรหัสผ่าน'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
web/src/components/Profile/ProfileEditForm.jsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { profileSchema } from '../../schemas/authSchema';
|
||||||
|
import InputText from '../InputText';
|
||||||
|
import { useUpdateProfileMutation } from '../../services/authApi';
|
||||||
|
|
||||||
|
export default function ProfileEditForm({ user }) {
|
||||||
|
const updateMutation = useUpdateProfileMutation();
|
||||||
|
const isUpdating = updateMutation.isPending;
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||||
|
resolver: yupResolver(profileSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// โหลดข้อมูลผู้ใช้เข้า Form เมื่อ Component โหลด
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
reset({
|
||||||
|
first_name: user.first_name || '',
|
||||||
|
last_name: user.last_name || '',
|
||||||
|
phone_number: user.phone_number || '',
|
||||||
|
email: user.email || '',
|
||||||
|
// ไม่โหลด username เพราะอาจมีปัญหาในการอัปเดต
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, reset]);
|
||||||
|
|
||||||
|
const onSubmit = (data) => {
|
||||||
|
// Djoser /users/me/ Endpoint รองรับ PATCH เพื่อส่งเฉพาะ Field ที่เปลี่ยน
|
||||||
|
updateMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
|
||||||
|
<InputText labelTitle="อีเมล (จำเป็น)" {...register('email')} error={errors.email} />
|
||||||
|
<InputText labelTitle="ชื่อจริง" {...register('first_name')} error={errors.first_name} />
|
||||||
|
<InputText labelTitle="นามสกุล" {...register('last_name')} error={errors.last_name} />
|
||||||
|
<InputText labelTitle="เบอร์โทรศัพท์" {...register('phone_number')} error={errors.phone_number} />
|
||||||
|
|
||||||
|
<p className="text-sm text-warning mt-4">Username: {user.username} (ไม่สามารถแก้ไขได้)</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={"btn btn-primary mt-6" + (isUpdating ? " loading" : "")}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
{isUpdating ? 'กำลังบันทึก...' : 'บันทึกการเปลี่ยนแปลง'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/components/Registration/RegisterInfoCard.jsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaEnvelope, FaQuestionCircle, FaPhoneAlt } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export default function RegisterInfoCard() {
|
||||||
|
return (
|
||||||
|
// ใช้ w-full h-full เพื่อให้ Component ขยายเต็มพื้นที่ Container
|
||||||
|
<div className="w-full h-full p-0 flex flex-col">
|
||||||
|
<div className="flex items-center text-xl font-bold text-primary mb-4 border-b border-base-200 pb-3">
|
||||||
|
<FaQuestionCircle className="w-6 h-6 mr-3 text-warning" />
|
||||||
|
คำแนะนำและช่วยเหลือ
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1. แนวทางการกรอกข้อมูล */}
|
||||||
|
<h3 className="text-lg font-semibold text-base-content mb-2">1. แนวทางการกรอกข้อมูล</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-sm text-base-content/70 ml-2">
|
||||||
|
<li><span className="font-semibold">ชื่อผู้ใช้งาน:</span> ใช้ตัวอักษร/ตัวเลขเท่านั้น (ขั้นต่ำ 4 ตัว)</li>
|
||||||
|
<li><span className="font-semibold">รหัสผ่าน:</span> ต้องยาวอย่างน้อย 8 ตัวอักษร</li>
|
||||||
|
<li><span className="font-semibold">เบอร์โทรศัพท์:</span> เป็นตัวเลข 10 หลัก (ไม่บังคับ)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="divider my-4"></div>
|
||||||
|
|
||||||
|
{/* 2. ช่องทางติดต่อช่วยเหลือ */}
|
||||||
|
<h3 className="text-lg font-semibold text-base-content mb-2">2. ช่องทางติดต่อช่วยเหลือ</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-base-content/70 ml-2">
|
||||||
|
<li className='flex items-center'>
|
||||||
|
<FaEnvelope className="w-4 h-4 mr-2 text-info" />
|
||||||
|
<span className="font-semibold">Email:</span> support@ddo.tech
|
||||||
|
</li>
|
||||||
|
<li className='flex items-center'>
|
||||||
|
<FaPhoneAlt className="w-4 h-4 mr-2 text-info" />
|
||||||
|
<span className="font-semibold">โทร:</span> 02-XXX-XXXX
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
web/src/components/ResultDisplay.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ResultDisplay({ result }) {
|
||||||
|
// หากไม่มีผลลัพธ์ (result เป็น null) ก็ไม่ต้องแสดงผลอะไร
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 alert alert-success shadow-lg">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold">Inference Success!</h3>
|
||||||
|
<p className="text-sm">ได้รับผลลัพธ์จากเซิร์ฟเวอร์แล้ว (JSON Payload)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* แสดงผลลัพธ์ JSON ในรูปแบบที่อ่านง่าย */}
|
||||||
|
<pre className="text-sm mt-2 bg-base-100 p-2 rounded max-h-60 overflow-auto border border-base-300
|
||||||
|
text-base-content whitespace-pre-wrap break-words">
|
||||||
|
{/* text-base-content: เพื่อให้สีตัวอักษรเป็นสีพื้นฐานของ Theme (ไม่ใช่สีขาว) */}
|
||||||
|
{/* whitespace-pre-wrap: บังคับให้ข้อความขึ้นบรรทัดใหม่แม้จะอยู่ใน <pre> */}
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
web/src/components/SelectInput.jsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
// 1. สร้าง Functional Component ปกติ (ไม่ใช้ forwardRef โดยตรง)
|
||||||
|
function SelectInputBase({ labelTitle, options, containerStyle, error, ...rest }, ref) {
|
||||||
|
return (
|
||||||
|
<div className={`form-control w-full ${containerStyle || ""}`}>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text text-base-content">{labelTitle}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
{...rest}
|
||||||
|
className={`select select-bordered w-full ${error ? "select-error" : ""}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
--- กรุณาเลือก ---
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{error && <p className="text-error text-xs mt-1">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. กำหนด propTypes ปกติ (จะไม่มี warning อีก)
|
||||||
|
SelectInputBase.propTypes = {
|
||||||
|
labelTitle: PropTypes.string.isRequired,
|
||||||
|
options: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
).isRequired,
|
||||||
|
containerStyle: PropTypes.string,
|
||||||
|
error: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. ใช้ forwardRef กับตัวหลัก
|
||||||
|
const SelectInput = forwardRef(SelectInputBase);
|
||||||
|
|
||||||
|
export default SelectInput;
|
||||||
47
web/src/components/ServiceStatus.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaCheckCircle, FaTimesCircle, FaDatabase, FaServer, FaFlask } from 'react-icons/fa';
|
||||||
|
import PropTypes from 'prop-types'; // สำหรับการตรวจสอบ Props (Best Practice)
|
||||||
|
|
||||||
|
// Helper Function: Mapping Icon
|
||||||
|
const IconMap = {
|
||||||
|
FaDatabase,
|
||||||
|
FaServer,
|
||||||
|
FaFlask,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServiceStatus({ name, status, details, icon: Icon }) {
|
||||||
|
const isUp = status === 'UP' || status === 'Healthy';
|
||||||
|
const colorClass = isUp ? 'text-success' : 'text-error';
|
||||||
|
const badgeClass = isUp ? 'badge-success' : 'badge-error';
|
||||||
|
|
||||||
|
// Icon สำหรับแสดงสถานะ (ใช้ Check/Times Circle เป็น Fallback)
|
||||||
|
const StatusIcon = Icon || (isUp ? FaCheckCircle : FaTimesCircle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-base-300 transition duration-150 last:border-b-0">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* Icon ของ Service */}
|
||||||
|
<StatusIcon className={`w-5 h-5 ${colorClass}`} />
|
||||||
|
{/* ชื่อ Service */}
|
||||||
|
<span className="font-semibold text-base-content">{name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-x-3 text-right">
|
||||||
|
{/* Badge แสดงสถานะ */}
|
||||||
|
<span className={`badge ${badgeClass} badge-outline text-xs hidden sm:inline`}>{status}</span>
|
||||||
|
{/* รายละเอียด (Truncate เพื่อป้องกัน overflow) */}
|
||||||
|
<span className="text-xs text-gray-500 max-w-[200px] inline-block truncate" title={details}>{details}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// กำหนด PropTypes เพื่อเพิ่มความแข็งแกร่งในการพัฒนา
|
||||||
|
ServiceStatus.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
status: PropTypes.string.isRequired,
|
||||||
|
details: PropTypes.string.isRequired,
|
||||||
|
icon: PropTypes.elementType,
|
||||||
|
};
|
||||||