Initial commit

This commit is contained in:
gitea 2025-11-18 21:53:33 +00:00
commit 32e87cfdb5
135 changed files with 18464 additions and 0 deletions

46
.gitignore vendored Normal file
View 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
View 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
View 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
View 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"]

View File

34
backend/accounts/admin.py Normal file
View 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
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

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

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

View 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),
),
]

View File

View 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

View 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

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

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

3
backend/api/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
backend/api/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View 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'],
},
),
]

View File

26
backend/api/models.py Normal file
View 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"

View File

View 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

View File

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

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
backend/api/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

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

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

16
backend/core/asgi.py Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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.")

View 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
View 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()

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PermissionsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'permissions'

View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View 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

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

19
backend/requirements.txt Normal file
View 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

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UserProfileConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'user_profile'

View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

157
infra/docker-compose.yml Normal file
View 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
View 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
View 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
View 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"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
mobile/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
mobile/index.js Normal file
View 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

File diff suppressed because it is too large Load Diff

20
mobile/package.json Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

42
web/package.json Normal file
View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
web/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

1
web/public/vite.svg Normal file
View 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
View 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
View 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
View 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

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

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

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

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

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

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

View 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

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

View File

@ -0,0 +1,189 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import InputText from './InputText';
import { modelSchema } from '../schemas/modelSchema';
import SelectInput from './SelectInput';
export default function ModalForm({ isOpen, onClose, mode, OnSubmit, model }) {
// Status Choices AiModel ( Frontend)
const STATUS_OPTIONS = [
{ value: 'ACTIVE', name: 'ACTIVE (พร้อมใช้งาน)' },
{ value: 'INACTIVE', name: 'INACTIVE (ไม่ได้ใช้งาน)' },
{ value: 'TESTING', name: 'TESTING (กำลังทดสอบ)' },
];
// 1. RHF Setup
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting }
} = useForm({
resolver: yupResolver(modelSchema), // Schema Model Registry
// mode
defaultValues: {
id: '',
name: '',
model_version: 'v1.0.0',
developer: '',
base_url: '',
inference_path: '',
status: 'INACTIVE',
auth_required: false,
}
});
// 2. Logic Model Form Edit
useEffect(() => {
if (mode === 'edit' && model) {
// reset() RHF
reset({
id: model.id || '', // ID ReadOnly
name: model.name || '',
model_version: model.model_version || 'v1.0.0',
developer: model.developer || '',
base_url: model.base_url || '',
inference_path: model.inference_path || '',
status: model.status || 'INACTIVE',
auth_required: model.auth_required || false,
});
} else {
// Reset Add
reset();
}
}, [mode, model, reset]);
// 3. Logic Submit
const onSubmitHandler = (data) => {
// ID Edit
if (mode === 'edit') {
data.id = model.id;
}
// OnSubmit Page Component ( Mutation)
OnSubmit(data);
onClose(); // Modal Submit
};
const modalTitle = mode === 'add' ? 'ลงทะเบียน AI Model ใหม่' : `แก้ไข Model: ${model?.name}`;
const submitText = mode === 'add' ? 'ลงทะเบียน' : 'บันทึกการแก้ไข';
return (
<dialog id="model_form_modal" className="modal" open={isOpen}>
<div className="modal-box w-11/12 max-w-4xl"> {/* เพิ่มขนาด Modal */}
<h3 className="font-bold text-xl py-4">{modalTitle}</h3>
{/* ปุ่มปิด Modal */}
<button type="button" className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={onClose}></button>
{/* 4. Form หลัก (ใช้ handleSubmit ของ RHF) */}
<form onSubmit={handleSubmit(onSubmitHandler)}>
<div className="space-y-4">
{/* ---------------------------------- */}
{/* กลุ่มที่ 1: ชื่อและสถานะ */}
{/* ---------------------------------- */}
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="w-full">
<InputText
labelTitle="ชื่อโมเดล (เช่น Spleen Segmentation)"
placeholder="Model Name"
type="text"
{...register('name')}
error={errors.name}
/>
</div>
<div className="w-full">
<InputText
labelTitle="เวอร์ชัน"
placeholder="v1.0.0"
type="text"
{...register('model_version')}
error={errors.model_version}
/>
</div>
</div>
{/* ---------------------------------- */}
{/* กลุ่มที่ 2: Base URL / Inference Path */}
{/* ---------------------------------- */}
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="w-full md:w-3/5">
<InputText
labelTitle="Base URL (Internal Service Address)"
placeholder="http://ai_model_server:8001"
type="url"
{...register('base_url')}
error={errors.base_url}
labelStyle='text-warning'
/>
</div>
<div className="w-full md:w-2/5">
<InputText
labelTitle="Inference Path"
placeholder="inference/spleen/"
type="text"
{...register('inference_path')}
error={errors.inference_path}
/>
</div>
</div>
{/* ---------------------------------- */}
{/* กลุ่มที่ 3: สถานะและการควบคุม */}
{/* ---------------------------------- */}
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="w-full md:w-1/2">
<SelectInput
labelTitle="สถานะบริการ"
options={STATUS_OPTIONS}
{...register('status')} // RHF register
error={errors.status} // RHF error
/>
{errors.status && <p className="text-error text-xs mt-1">{errors.status.message}</p>}
</div>
<div className="w-full md:w-1/2 flex items-end">
<div className="form-control">
<label className="label cursor-pointer space-x-2">
<span className="label-text">องการ Internal Auth Key?</span>
<input
type="checkbox"
className="checkbox checkbox-primary"
{...register('auth_required')}
/>
</label>
</div>
</div>
</div>
<div className="w-full">
<InputText
labelTitle="ผู้พัฒนา/ทีม"
placeholder="Core AI Team"
type="text"
{...register('developer')}
error={errors.developer}
/>
</div>
</div>
{/* 5. ปุ่ม Submit และ Cancel */}
<div className="modal-action mt-6">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={isSubmitting}>ยกเล</button>
<button type="submit" className={"btn btn-success" + (isSubmitting ? " loading" : "")} disabled={isSubmitting}>
{submitText}
</button>
</div>
</form>
</div>
</dialog>
);
}

View File

@ -0,0 +1,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;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { FaPlus, FaSearch } from 'react-icons/fa';
function ModelTopBar({ onOpenAdd, onSearch }) {
return (
<div className='flex items-center space-x-3'>
{/* Search Bar */}
<div className="input-group">
<input
type="text"
placeholder="Search models..."
className="input input-bordered input-sm w-32 md:w-auto"
onChange={(e) => onSearch(e.target.value)}
/>
<button className="btn btn-square btn-sm"><FaSearch /></button>
</div>
{/* Add Button */}
<button className="btn btn-primary btn-sm" onClick={onOpenAdd}>
<FaPlus className="w-4 h-4" /> Register New Model
</button>
</div>
);
}
export default ModelTopBar;

View File

@ -0,0 +1,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>
);
}

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

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

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

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

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

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

View File

@ -0,0 +1,48 @@
import React, { forwardRef } from "react";
import PropTypes from "prop-types";
// 1. Functional Component ( forwardRef )
function SelectInputBase({ labelTitle, options, containerStyle, error, ...rest }, ref) {
return (
<div className={`form-control w-full ${containerStyle || ""}`}>
<label className="label">
<span className="label-text text-base-content">{labelTitle}</span>
</label>
<select
ref={ref}
{...rest}
className={`select select-bordered w-full ${error ? "select-error" : ""}`}
>
<option value="" disabled>
--- กรณาเลอก ---
</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.name}
</option>
))}
</select>
{error && <p className="text-error text-xs mt-1">{error.message}</p>}
</div>
);
}
// 2. propTypes ( warning )
SelectInputBase.propTypes = {
labelTitle: PropTypes.string.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
containerStyle: PropTypes.string,
error: PropTypes.object,
};
// 3. forwardRef
const SelectInput = forwardRef(SelectInputBase);
export default SelectInput;

View File

@ -0,0 +1,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,
};

Some files were not shown because too many files have changed in this diff Show More