Compare commits

..

6 Commits

Author SHA1 Message Date
jigoong
76398c3de6 feat(apiservice): add edit client/key functionality in API Management page
- PATCH /admin/api-keys/clients/{id} — update client name and is_active
- PATCH /admin/api-keys/{id} — update key name and permissions
- Edit Client modal with name field and active/inactive toggle
- Edit Key modal with name field and permissions JSON textarea (pre-filled)
- Fix JS syntax error: use data-* attributes instead of inline JSON in onclick
2026-06-09 00:41:36 +07:00
jigoong
3a5f9e9001 feat: replace SQLAdmin with Keycloak-protected API management page
- Disable SQLAdmin basic auth (comment out mount_admin, statics, redirect)
- Add /api-management page (Keycloak admin role required)
- Add admin_api_keys.py: REST endpoints for list/create clients and keys
- Add api_management.html: manage API clients, keys, permissions with copy-once key display
- Update index.html: API Management link -> /api-management
- Update auth middleware: add /api-management and /admin/users to PROTECTED_PATHS
- Add CHANGES-2026-06-04.md dev notes
2026-06-04 18:22:22 +07:00
jigoong
e4d32b86cb feat: add VOC data endpoint (POST /api/v1/voc-data)
- Add VocDataIn schema (date, topic, sub_topic, level, depart_id, dep_name)
- Add RawVocData SQLAlchemy model (rawdata.raw_voc_data, BIGSERIAL PK)
- Add POST /api/v1/voc-data endpoint with voc.data:write permission
- Dual-write to local PostgreSQL + Supabase
- Table auto-created on startup via Base.metadata.create_all()
2026-06-04 18:22:14 +07:00
jigoong
ee473aca8f fix: finance upload filepath bug and add extra_hosts for keycloak auth flow
- fix NameError: filepath undefined in trigger_airflow call (use filepath_stored)
- add extra_hosts ai.sriphat.com:192.168.100.8 for container DNS resolution
  (required for KEYCLOAK_SERVER_URL=http://ai.sriphat.com/keycloak/ to work
   inside Docker — host nginx on .8:80 routes /keycloak/ to Keycloak container)
2026-05-27 01:28:57 +07:00
jigoong
a587be08bd feat: MinIO integration — bucket finance, API service upload, Nginx routing
- 01-infra/nginx-configs: add MinIO /minio/ and /minio-console/ location blocks
  (port 9000 S3 API, port 9001 Console UI, path stripping via rewrite)
- 03-apiservice: integrate MinIO minio-python SDK for file upload
  - requirements.txt: add minio==7.2.11
  - app/core/config.py: add MINIO_ENDPOINT, ACCESS_KEY, SECRET_KEY, BUCKET_FINANCE, USE_SSL
  - app/services/minio_client.py: new — upload_file(), get_presigned_url(), delete_file()
  - app/routes/pages.py: replace local /data/uploads/ write with MinIO upload to finance bucket
  - docker-compose.yml: pass MinIO env vars to container
  - .env.example: document MinIO vars
- 07-minio/.env.example: add MINIO_SVC_ACCESS_KEY/SECRET_KEY section
- 07-minio/README.md: add Python minio SDK and Airflow DAG usage guide
- CLAUDE.md: project context (servers, SSH, paths, service distribution)
- document-obsidiant/: initial Obsidian docs for all services
2026-05-20 17:42:39 +07:00
jigoong
9dcf24eeb7 update config for limit resouce size 2026-05-08 22:18:32 +07:00
58 changed files with 4157 additions and 963 deletions

View File

@@ -4,6 +4,7 @@ TZ=Asia/Bangkok
DB_HOST=postgres DB_HOST=postgres
DB_PORT=5432 DB_PORT=5432
DB_PORT_EXPOSE=5435
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=Secure_Hospital_Pass_2026 DB_PASSWORD=Secure_Hospital_Pass_2026
DB_NAME=postgres DB_NAME=postgres
@@ -13,6 +14,7 @@ POSTGRES_PASSWORD=Secure_Hospital_Pass_2026
KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=admin_secret_pass_2026 KEYCLOAK_ADMIN_PASSWORD=admin_secret_pass_2026
KEYCLOAK_DB_NAME=keycloak
SUPERSET_SECRET_KEY=superset_random_secret_key_change_me_2026 SUPERSET_SECRET_KEY=superset_random_secret_key_change_me_2026
SUPERSET_ADMIN_USERNAME=admin SUPERSET_ADMIN_USERNAME=admin
@@ -29,3 +31,14 @@ AIRBYTE_PORT=8030
AIRBYTE_BASIC_AUTH_USERNAME= AIRBYTE_BASIC_AUTH_USERNAME=
AIRBYTE_BASIC_AUTH_PASSWORD= AIRBYTE_BASIC_AUTH_PASSWORD=
AIRBYTE_BASIC_AUTH_PROXY_TIMEOUT=900 AIRBYTE_BASIC_AUTH_PROXY_TIMEOUT=900
# Dozzle - Docker Log Viewer & Monitoring
DOZZLE_PORT=9999
DOZZLE_LEVEL=info
DOZZLE_BASE=/dozzle
DOZZLE_HOSTNAME=Sriphat Main Server
DOZZLE_AUTH_PROVIDER=none
DOZZLE_RESTART_POLICY=unless-stopped
# Remote agents: Airbyte and Airflow on 192.168.100.9
# Format: host:port,host:port (comma-separated)
DOZZLE_REMOTE_AGENT=192.168.100.9:7007

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ ruff_cache/
*/data/ */data/
01-infra/letsencrypt/ 01-infra/letsencrypt/
.windsurf/ .windsurf/
_daily-log/
daily-log/

View File

@@ -343,6 +343,57 @@ server {
# proxy_request_buffering off; # proxy_request_buffering off;
# } # }
# =============================================
# MinIO Object Storage (Server 2: 192.168.100.9)
# =============================================
# MinIO S3 API — port 9000
# Path MUST be stripped before passing to MinIO
location /minio/ {
proxy_pass http://192.168.100.9:9000/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_connect_timeout 300;
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
client_max_body_size 1G;
proxy_request_buffering off;
proxy_buffering off;
}
# MinIO Console UI — port 9001 (NOT 9000!)
# Path MUST be stripped: /minio-console/foo → /foo
location /minio-console/ {
rewrite ^/minio-console/(.*) /$1 break;
proxy_pass http://192.168.100.9:9001;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
# WebSocket support (Console uses WebSocket for real-time updates)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
chunked_transfer_encoding off;
}
# Redirect /minio-console → /minio-console/
location = /minio-console {
return 301 $scheme://$http_host/minio-console/;
}
#listen 443 ssl; # managed by sriphat #listen 443 ssl; # managed by sriphat
#ssl_certificate /etc/letsencrypt/live/ai.bda.co.th/fullchain.pem; # managed by Certbot #ssl_certificate /etc/letsencrypt/live/ai.bda.co.th/fullchain.pem; # managed by Certbot
#ssl_certificate_key /etc/letsencrypt/live/ai.bda.co.th/privkey.pem; # managed by Certbot #ssl_certificate_key /etc/letsencrypt/live/ai.bda.co.th/privkey.pem; # managed by Certbot

View File

@@ -1,10 +0,0 @@
.env
__pycache__/
*.pyc
.venv/
venv/
.python-version
.pytest_cache/
.mypy_cache/
ruff_cache/
.windsurf/

View File

@@ -1,17 +0,0 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY ./app /app/app
ENV TZ=Asia/Bangkok
EXPOSE 8040
CMD ["gunicorn","-k","uvicorn.workers.UvicornWorker","app.main:app","--bind","0.0.0.0:8040","--workers","2","--access-logfile","-","--error-logfile","-"]

View File

@@ -1,44 +0,0 @@
# 03-apiservice: Custom FastAPI Service
## Build & Start
```bash
docker compose --env-file ../.env.global up --build -d
```
## Access
Internal only - access via Nginx Proxy Manager at `/apiservice`
## Admin UI
- Login: http://<domain>/apiservice/admin/
- Generate API Key: POST /apiservice/admin/api-keys/generate
## env
env that important for provision
```
## supabase
SUPABASE_DB_HOST=sdp-db
SUPABASE_DB_PORT=5432
SUPABASE_DB_USER=postgres.1
SUPABASE_DB_PASSWORD=
SUPABASE_DB_NAME=postgres
SUPABASE_DB_SSLMODE=disable
## pgsql
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=
DB_NAME=postgres
DB_SSLMODE=disable
AIRBYTE_DB_NAME=airbyte
KEYCLOAK_DB_NAME=keycloack
SUPERSET_DB_NAME=superset
#TEMPORAL_DB_NAME=temporal
## api
ROOT_PATH=/apiservice
APP_NAME=APIsService
ADMIN_SECRET_KEY=
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
```

View File

@@ -1,283 +0,0 @@
from __future__ import annotations
from fastapi import HTTPException, Request, status
from fastapi.staticfiles import StaticFiles
from sqladmin import Admin, ModelView
from sqladmin.authentication import AuthenticationBackend
from starlette.responses import HTMLResponse, RedirectResponse
from starlette.datastructures import URL
from sqlalchemy.orm import sessionmaker
from wtforms import BooleanField, SelectField, StringField
from wtforms.validators import Optional
from app.core.config import settings
from app.db.engine import engine
from app.db.models import ApiClient, ApiKey
from app.security.api_key import generate_api_key, get_prefix, hash_api_key
class AdminAuth(AuthenticationBackend):
async def login(self, request: Request) -> bool:
form = await request.form()
username = form.get("username")
password = form.get("password")
if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD:
request.session.update({"admin": True})
return True
return False
async def logout(self, request: Request) -> bool:
request.session.clear()
return True
async def authenticate(self, request: Request) -> bool:
return bool(request.session.get("admin"))
class ApiClientAdmin(ModelView, model=ApiClient):
column_list = [ApiClient.id, ApiClient.name, ApiClient.is_active]
async def insert_model(self, request: Request, data: dict) -> ApiClient:
obj: ApiClient = await super().insert_model(request, data)
plain_key = generate_api_key()
db = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
try:
api_key = ApiKey(
client_id=obj.id,
name="auto",
key_prefix=get_prefix(plain_key),
key_hash=hash_api_key(plain_key),
permissions=[],
is_active=True,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
request.session["generated_api_key"] = {
"client_id": obj.id,
"client_name": obj.name,
"key_id": api_key.id,
"api_key": plain_key,
}
finally:
db.close()
return obj
class ApiKeyAdmin(ModelView, model=ApiKey):
column_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.is_active, ApiKey.permissions]
form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at]
form_extra_fields = {
"plain_key": StringField("Plain Key", validators=[Optional()]),
"permissions_csv": StringField("Permissions (comma)", validators=[Optional()]),
"endpoint_path": SelectField("Endpoint", choices=[], validators=[Optional()]),
"perm_read": BooleanField("Read (GET)"),
"perm_write": BooleanField("Write (POST/PATCH)"),
"perm_delete": BooleanField("Delete (DELETE)"),
}
async def on_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None:
plain_key = data.get("plain_key")
if not plain_key and is_created:
plain_key = generate_api_key()
if plain_key:
model.key_prefix = get_prefix(plain_key)
model.key_hash = hash_api_key(plain_key)
if is_created:
request.state.generated_api_key_plain = plain_key
permissions: list[str] = []
endpoint_path = data.get("endpoint_path")
if endpoint_path:
if data.get("perm_read"):
permissions.append(f"{endpoint_path}:read")
if data.get("perm_write"):
permissions.append(f"{endpoint_path}:write")
if data.get("perm_delete"):
permissions.append(f"{endpoint_path}:delete")
permissions_csv = data.get("permissions_csv")
if permissions_csv is not None:
perms = [p.strip() for p in permissions_csv.split(",") if p.strip()]
permissions.extend(perms)
if permissions:
seen: set[str] = set()
deduped: list[str] = []
for p in permissions:
if p not in seen:
seen.add(p)
deduped.append(p)
model.permissions = deduped
async def after_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None:
if not is_created:
return
plain_key = getattr(request.state, "generated_api_key_plain", None)
if not plain_key:
return
request.session["generated_api_key"] = {
"client_id": model.client_id,
"client_name": str(getattr(model, "client", "")) if getattr(model, "client", None) else "",
"key_id": model.id,
"api_key": plain_key,
}
def mount_admin(app):
auth_backend = AdminAuth(secret_key=settings.ADMIN_SECRET_KEY)
class CustomAdmin(Admin):
def get_save_redirect_url(
self, request: Request, form, model_view: ModelView, obj
):
if (
getattr(model_view, "model", None) in (ApiClient, ApiKey)
and request.session.get("generated_api_key")
):
root_path = request.scope.get("root_path") or ""
return URL(f"{root_path}/admin/generated-api-key")
return super().get_save_redirect_url(
request=request,
form=form,
model_view=model_view,
obj=obj,
)
admin = CustomAdmin(
app=app,
engine=engine,
authentication_backend=auth_backend,
title="My Service Management",
base_url="/admin",
)
openapi = app.openapi()
paths = openapi.get("paths") or {}
endpoint_choices: list[tuple[str, str]] = []
for path in sorted(paths.keys()):
if not path.startswith("/api/"):
continue
methods = paths.get(path) or {}
available = sorted([m.upper() for m in methods.keys()])
label = f"{path} [{' '.join(available)}]" if available else path
endpoint_choices.append((path, label))
ApiKeyAdmin.form_extra_fields["endpoint_path"].kwargs["choices"] = endpoint_choices
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
admin.add_view(ApiClientAdmin)
admin.add_view(ApiKeyAdmin)
@app.get("/admin/generated-api-key")
async def _admin_generated_api_key(request: Request):
if not request.session.get("admin"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
key_info = request.session.pop("generated_api_key", None)
root_path = request.scope.get("root_path") or ""
clients_url = f"{root_path}/admin/{ApiClientAdmin.identity}/list"
if not key_info:
return HTMLResponse(
f"<h2>No API key to display</h2><p>The API key was already shown or expired.</p><p><a href=\"{clients_url}\">Back to clients</a></p>",
status_code=200,
)
client_name = key_info.get("client_name", "")
client_id = key_info.get("client_id", "")
key_id = key_info.get("key_id", "")
api_key = key_info.get("api_key", "")
return HTMLResponse(
(
"<h2>API key generated</h2>"
"<p>Copy this API key now. You won't be able to view it again.</p>"
f"<p><b>Client</b>: {client_name} (ID: {client_id})</p>"
f"<p><b>Key ID</b>: {key_id}</p>"
f"<pre style=\"padding:12px;border:1px solid #ddd;background:#f7f7f7;\">{api_key}</pre>"
f"<p><a href=\"{clients_url}\">Back to clients</a></p>"
),
status_code=200,
)
@app.get("/admin/clients/{client_id}/generate-api-key")
async def _admin_generate_api_key_get(
request: Request,
client_id: int,
permissions: str = "",
name: str | None = None,
):
if not request.session.get("admin"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
perms = [p.strip() for p in permissions.split(",") if p.strip()]
plain_key = generate_api_key()
db = SessionLocal()
try:
client = db.get(ApiClient, client_id)
if not client:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
api_key = ApiKey(
client_id=client_id,
name=name,
key_prefix=get_prefix(plain_key),
key_hash=hash_api_key(plain_key),
permissions=perms,
is_active=True,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
return {"key_id": api_key.id, "api_key": plain_key, "permissions": perms}
finally:
db.close()
@app.post("/admin/api-keys/generate")
async def _admin_generate_api_key(
request: Request,
client_id: int,
permissions: str = "",
name: str | None = None,
):
if not request.session.get("admin"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
perms = [p.strip() for p in permissions.split(",") if p.strip()]
plain_key = generate_api_key()
db = SessionLocal()
try:
client = db.get(ApiClient, client_id)
if not client:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
api_key = ApiKey(
client_id=client_id,
name=name,
key_prefix=get_prefix(plain_key),
key_hash=hash_api_key(plain_key),
permissions=perms,
is_active=True,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
return {"key_id": api_key.id, "api_key": plain_key, "permissions": perms}
finally:
db.close()

View File

@@ -1,127 +0,0 @@
from __future__ import annotations
import logging
from typing import Annotated
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.api.v1.schemas import FeedCheckpointIn
from app.core.config import settings
from app.db.models import RawOpdCheckpoint
from app.security.dependencies import get_db, get_supabase_db, require_permission
from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
PERM_FEED_CHECKPOINT_WRITE = "/api/v1/feed/checkpoint:write"
PERM_FEED_CHECKPOINT_WRITE_LEGACY = "feed.checkpoint:write"
def _to_tz(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo(settings.TIMEZONE))
return dt.astimezone(ZoneInfo(settings.TIMEZONE))
def _to_iso(dt):
"""Convert datetime to ISO 8601 string for Supabase API."""
if dt is None:
return None
return dt.isoformat()
@router.post("/feed/checkpoint")
def upsert_feed_checkpoint(
payload: list[FeedCheckpointIn],
db: Annotated[Session, Depends(get_db)],
):
rows = []
supabase_rows = []
#clean_data = payload.model_dump(exclude_none=True)
for item in payload:
# Prepare data for local database 'default' if item.id is None else
row = {
"id": item.id,
"hn": item.hn,
"vn": item.vn,
"location": item.location,
"type": item.type,
"timestamp_in": _to_tz(item.timestamp_in),
"timestamp_out": _to_tz(item.timestamp_out),
"waiting_time": item.waiting_time,
"bu": item.bu,
}
if item.id is None:
del(row["id"])
rows.append(row)
# Prepare data for Supabase API (convert datetime to ISO string) 'default' if item.id is None else
supabase_row = {
"id": item.id,
"hn": item.hn,
"vn": item.vn,
"location": item.location,
"type": item.type,
"timestamp_in": _to_iso(_to_tz(item.timestamp_in)),
"timestamp_out": _to_iso(_to_tz(item.timestamp_out)),
"waiting_time": item.waiting_time,
"bu": item.bu,
}
if item.id is None:
del(supabase_row["id"])
supabase_rows.append(supabase_row)
# Insert/update to local database
stmt = insert(RawOpdCheckpoint).values(rows)
update_cols = {
"id": stmt.excluded.id,
"type": stmt.excluded.type,
"timestamp_in": stmt.excluded.timestamp_in,
"timestamp_out": stmt.excluded.timestamp_out,
"waiting_time": stmt.excluded.waiting_time,
"bu": stmt.excluded.bu,
}
stmt = stmt.on_conflict_do_update(
index_elements=[RawOpdCheckpoint.hn, RawOpdCheckpoint.vn, RawOpdCheckpoint.location, RawOpdCheckpoint.timestamp_in],
set_=update_cols,
)
result = db.execute(stmt)
db.commit()
# Send data to Supabase via API call
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} records to Supabase API")
supabase_result = upsert_to_supabase_sync(
table="raw_opd_checkpoint",
data=supabase_rows,
on_conflict="hn,vn,location,timestamp_in",
)
logger.info(f"Successfully sent data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"upserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}

View File

@@ -1,15 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class FeedCheckpointIn(BaseModel):
id: int | None = None
hn: int
vn: int
location: str
type: str
timestamp_in: datetime
timestamp_out: datetime | None = None
waiting_time: int | None = None
bu: str | None = None

View File

@@ -1,35 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
APP_NAME: str = "APIsService"
DB_HOST: str
DB_PORT: int = 5432
DB_USER: str
DB_PASSWORD: str
DB_NAME: str
DB_SSLMODE: str = "disable"
SUPABASE_DB_HOST: str
SUPABASE_DB_PORT: int = 5432
SUPABASE_DB_USER: str
SUPABASE_DB_PASSWORD: str
SUPABASE_DB_NAME: str
SUPABASE_DB_SSLMODE: str = "disable"
SUPABASE_API_URL: str
SUPABASE_API_KEY: str
ROOT_PATH: str = ""
TIMEZONE: str = "Asia/Bangkok"
ADMIN_SECRET_KEY: str
ADMIN_USERNAME: str
ADMIN_PASSWORD: str
settings = Settings()

View File

@@ -1,5 +0,0 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -1,35 +0,0 @@
from urllib.parse import quote_plus
from sqlalchemy import create_engine
from app.core.config import settings
def build_db_url() -> str:
user = quote_plus(settings.DB_USER)
password = quote_plus(settings.DB_PASSWORD)
host = settings.DB_HOST
port = settings.DB_PORT
db = quote_plus(settings.DB_NAME)
return (
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}"
f"?sslmode={quote_plus(settings.DB_SSLMODE)}"
)
def build_supabase_db_url() -> str:
user = quote_plus(settings.SUPABASE_DB_USER)
password = quote_plus(settings.SUPABASE_DB_PASSWORD)
host = settings.SUPABASE_DB_HOST
port = settings.SUPABASE_DB_PORT
db = quote_plus(settings.SUPABASE_DB_NAME)
return (
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}"
f"?sslmode={quote_plus(settings.SUPABASE_DB_SSLMODE)}"
)
engine = create_engine(build_db_url(), pool_pre_ping=True)
supabase_engine = create_engine(build_supabase_db_url(), pool_pre_ping=True)

View File

@@ -1,13 +0,0 @@
from sqlalchemy import text
from app.db.base import Base
from app.db.engine import engine
def init_db() -> None:
# with engine.begin() as conn:
# conn.execute(text("CREATE SCHEMA IF NOT EXISTS fastapi"))
# conn.execute(text("CREATE SCHEMA IF NOT EXISTS operationbi"))
# Base.metadata.create_all(bind=conn)
pass

View File

@@ -1,74 +0,0 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class RawOpdCheckpoint(Base):
__tablename__ = "raw_opd_checkpoint"
__table_args__ = (
UniqueConstraint("hn", "vn", "location", name="uq_raw_opd_checkpoint_hn_vn_location"),
{"schema": "rawdata"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
hn: Mapped[int] = mapped_column(BigInteger, nullable=False)
vn: Mapped[int] = mapped_column(BigInteger, nullable=False)
location: Mapped[str] = mapped_column(Text, nullable=False)
type: Mapped[str] = mapped_column(String(64), nullable=False)
timestamp_in: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
timestamp_out: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
waiting_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
bu: Mapped[str | None] = mapped_column(String(128), nullable=True)
class ApiClient(Base):
__tablename__ = "api_client"
__table_args__ = {"schema": "fastapi"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
api_keys: Mapped[list[ApiKey]] = relationship(
back_populates="client",
cascade="all, delete-orphan",
passive_deletes=True,
)
def __str__(self) -> str:
client_id = getattr(self, "id", None)
if client_id is None:
return self.name
return f"{self.name} ({client_id})"
def __repr__(self) -> str:
return str(self)
class ApiKey(Base):
__tablename__ = "api_key"
__table_args__ = {"schema": "fastapi"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_id: Mapped[int] = mapped_column(
ForeignKey("fastapi.api_client.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False)
key_hash: Mapped[str] = mapped_column(Text, nullable=False)
permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
client: Mapped[ApiClient] = relationship(back_populates="api_keys")

View File

@@ -1,99 +0,0 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.datastructures import Headers
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
class ForceHTTPSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
# บังคับให้ FastAPI มองว่า Request ที่เข้ามาเป็น HTTPS เสมอ
# เพื่อให้ url_for() เจนลิงก์ CSS/JS เป็น https://
request.scope["scheme"] = "https"
response = await call_next(request)
return response
class ForwardedProtoMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] in {"http", "websocket"}:
headers = Headers(scope=scope)
forwarded_proto = headers.get("x-forwarded-proto")
if forwarded_proto:
proto = forwarded_proto.split(",", 1)[0].strip()
if proto:
new_scope = dict(scope)
new_scope["scheme"] = proto
return await self.app(new_scope, receive, send)
return await self.app(scope, receive, send)
# class RootPathStripMiddleware:
# def __init__(self, app, prefix: str):
# self.app = app
# self.prefix = (prefix or "").rstrip("/")
# async def __call__(self, scope, receive, send):
# if scope["type"] in {"http", "websocket"} and self.prefix:
# path = scope.get("path") or ""
# new_scope = dict(scope)
# new_scope["root_path"] = self.prefix
# if path == self.prefix or path.startswith(self.prefix + "/"):
# new_path = path[len(self.prefix) :]
# new_scope["path"] = new_path if new_path else "/"
# return await self.app(new_scope, receive, send)
# return await self.app(scope, receive, send)
from app.admin import mount_admin
from app.api.v1.routes import router as v1_router
from app.core.config import settings
from app.db.init_db import init_db
from fastapi.staticfiles import StaticFiles
from sqladmin import Admin
import os
import sqladmin
# รายชื่อ Origins ที่อนุญาตให้ยิง API มาหาเราได้
origins = [
"http://localhost:80400", # สำหรับตอนพัฒนา Frontend
"https://ai.sriphat.com", # Domain หลักของคุณ
"http://ai.sriphat.com",
]
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
yield
print(settings.ROOT_PATH, flush=True)
sqladmin_dir = os.path.dirname(sqladmin.__file__)
statics_path = os.path.join(sqladmin_dir, "statics")
app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan)
#if settings.ROOT_PATH:
# app.add_middleware(RootPathStripMiddleware, prefix=settings.ROOT_PATH)
app.add_middleware(ForceHTTPSMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.add_middleware(ForwardedProtoMiddleware)
app.include_router(v1_router)
app.mount("/admin/statics", StaticFiles(directory=statics_path), name="admin_statics")
app.mount("/apiservice/admin/statics", StaticFiles(directory=statics_path), name="proxy_admin_statics")
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # หรือ ["*"] ถ้าต้องการอนุญาตทั้งหมด (ไม่แนะนำใน production)
allow_credentials=True, # สำคัญมาก! ต้องเป็น True ถ้าหน้า Admin/API มีการใช้ Cookies/Sessions
allow_methods=["*"], # อนุญาตทุก HTTP Method (GET, POST, PUT, DELETE, etc.)
allow_headers=["*"], # อนุญาตทุก Headers
)
mount_admin(app)

View File

@@ -1,22 +0,0 @@
import secrets
import bcrypt
def generate_api_key(prefix_len: int = 8, token_bytes: int = 32) -> str:
prefix = secrets.token_urlsafe(prefix_len)[:prefix_len]
token = secrets.token_urlsafe(token_bytes)
return f"{prefix}.{token}"
def get_prefix(api_key: str) -> str:
return api_key.split(".", 1)[0]
def hash_api_key(api_key: str) -> str:
hashed = bcrypt.hashpw(api_key.encode("utf-8"), bcrypt.gensalt())
return hashed.decode("utf-8")
def verify_api_key(api_key: str, api_key_hash: str) -> bool:
return bcrypt.checkpw(api_key.encode("utf-8"), api_key_hash.encode("utf-8"))

View File

@@ -1,66 +0,0 @@
from typing import Annotated
from collections.abc import Sequence
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from app.db.engine import engine, supabase_engine
from app.db.models import ApiKey
from app.security.api_key import get_prefix, verify_api_key
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
SupabaseSessionLocal = sessionmaker(bind=supabase_engine, autoflush=False, autocommit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_supabase_db():
db = SupabaseSessionLocal()
try:
yield db
finally:
db.close()
def get_bearer_token(request: Request) -> str:
auth = request.headers.get("authorization")
if not auth:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization")
parts = auth.split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Authorization")
return parts[1].strip()
def require_permission(permission: str | Sequence[str]):
def _dep(
token: Annotated[str, Depends(get_bearer_token)],
db: Annotated[Session, Depends(get_db)],
) -> ApiKey:
prefix = get_prefix(token)
stmt = select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.is_active.is_(True))
api_key = db.execute(stmt).scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
if not verify_api_key(token, api_key.key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
allowed = set(api_key.permissions or [])
required = [permission] if isinstance(permission, str) else list(permission)
if not any(p in allowed for p in required):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
return api_key
return _dep

View File

@@ -1,37 +0,0 @@
services:
apiservice:
build: .
container_name: apiservice
env_file:
- ../.env.global
environment:
- TZ=${TZ:-Asia/Bangkok}
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_SSLMODE=${DB_SSLMODE}
- ROOT_PATH=${ROOT_PATH}
- APP_NAME=${APP_NAME}
- ADMIN_SECRET_KEY=${ADMIN_SECRET_KEY}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
networks:
- shared_data_network
restart: unless-stopped
volumes:
- ./app:/app/app
- .env:/app/.env
ports:
- 0.0.0.0:8040:8040
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8040/apiservice/docs', timeout=5).read()"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
shared_data_network:
external: true

View File

@@ -1,16 +0,0 @@
fastapi==0.115.8
uvicorn==0.34.0
gunicorn==23.0.0
SQLAlchemy==2.0.38
psycopg==3.2.5
pydantic==2.10.6
pydantic-settings==2.7.1
psycopg[binary]
sqladmin==0.20.1
itsdangerous==2.2.0
bcrypt==4.3.0
python-multipart==0.0.20
httpx==0.28.1
WTForms
#==3.2.1

View File

@@ -47,3 +47,11 @@ KEYCLOAK_REDIRECT_URI=http://localhost:8040/apiservice/auth/callback
AIRFLOW_API_URL=http://airflow-webserver:8080 AIRFLOW_API_URL=http://airflow-webserver:8080
AIRFLOW_API_TOKEN=your-airflow-api-token AIRFLOW_API_TOKEN=your-airflow-api-token
AIRFLOW_DAG_ID_FINANCE=process_finance_excel AIRFLOW_DAG_ID_FINANCE=process_finance_excel
# MinIO Object Storage (server 2: 192.168.100.9)
# ใช้ service account sp_service_ac (ไม่ใช้ root credentials)
MINIO_ENDPOINT=192.168.100.9:9000
MINIO_SVC_ACCESS_KEY=sp_service_ac
MINIO_SVC_SECRET_KEY=your-minio-service-account-secret
MINIO_BUCKET_FINANCE=finance
MINIO_USE_SSL=false

View File

@@ -0,0 +1,138 @@
# Changes — 2026-06-04
## สรุป
วันนี้เพิ่ม 2 feature ใหม่ใน `03-apiservice`:
1. **VOC Data endpoint** — รับข้อมูลข้อร้องเรียน (Voice of Customer) จาก programmatic client
2. **API Management page** — หน้าจัดการ API clients/keys ด้วย Keycloak admin auth แทน SQLAdmin basic auth เดิม
---
## Feature 1 — VOC Data Endpoint
### ไฟล์ที่แก้ไข
| ไฟล์ | การเปลี่ยนแปลง |
|------|----------------|
| `app/api/v1/schemas.py` | เพิ่ม `VocDataIn` schema |
| `app/db/models.py` | เพิ่ม `RawVocData` model (table: `rawdata.raw_voc_data`) |
| `app/api/v1/routes.py` | เพิ่ม `POST /api/v1/voc-data` endpoint |
### Endpoint
```
POST /api/v1/voc-data
Authorization: Bearer <api-key> (permission required: voc.data:write)
Content-Type: application/json
```
**Request body** (batch array):
```json
[
{
"date": "2026-06-04",
"topic": "บริการพยาบาล",
"sub_topic": "ความรวดเร็ว",
"level": "3",
"depart_id": "OPD01",
"dep_name": "ผู้ป่วยนอก"
}
]
```
**Response:**
```json
{
"inserted": 1,
"rowcount": 1,
"supabase": { "success": true, "result": {...}, "error": null }
}
```
### Database Table
```sql
CREATE TABLE rawdata.raw_voc_data (
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
topic VARCHAR(200) NOT NULL,
sub_topic VARCHAR(200) NOT NULL,
level VARCHAR(50) NOT NULL,
depart_id VARCHAR(50) NOT NULL,
dep_name VARCHAR(200),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
- `id` เป็น BIGSERIAL auto-increment — server generate เอง, client ไม่ต้องส่ง
- ทุก record ที่ส่งมาจะ INSERT เพิ่มเสมอ (ไม่มี upsert/on_conflict)
- สร้าง table อัตโนมัติจาก `Base.metadata.create_all()` ตอน startup
- Dual-write ไปยัง Supabase (`raw_voc_data` table) เหมือน endpoint อื่นๆ
### Deploy status
✅ Deploy แล้ว บน server .8 — table `rawdata.raw_voc_data` ถูกสร้างแล้ว
---
## Feature 2 — API Management Page (Keycloak auth)
### ปัญหาเดิม
SQLAdmin panel (`/admin/`) ใช้ basic auth (username/password จาก `.env`) แยกต่างหากจาก Keycloak ซึ่งเป็น auth system หลักของระบบ
### การแก้ไข
ปิด SQLAdmin และสร้างหน้าจัดการ API keys ใหม่ที่ใช้ Keycloak admin auth แทน
### ไฟล์ที่แก้ไข
| ไฟล์ | การเปลี่ยนแปลง |
|------|----------------|
| `app/main.py` | ลบ SQLAdmin imports/mounts (`sqladmin`, statics, `mount_admin`) |
| `app/admin.py` | Comment out `/admin` redirect route |
| `app/middleware/auth_middleware.py` | เพิ่ม `/api-management`, `/admin/users` ใน `PROTECTED_PATHS` |
| `app/routes/pages.py` | เพิ่ม `GET /api-management` route |
| `app/templates/index.html` | เปลี่ยน link จาก `/admin/``/api-management` |
### ไฟล์ใหม่
| ไฟล์ | คำอธิบาย |
|------|---------|
| `app/routes/admin_api_keys.py` | REST endpoints สำหรับจัดการ API clients/keys (Keycloak admin auth) |
| `app/templates/api_management.html` | หน้าจัดการ API clients และ keys |
### Endpoints ใหม่ (ทั้งหมดต้องการ Keycloak admin role)
| Method | Path | คำอธิบาย |
|--------|------|---------|
| GET | `/admin/api-keys/clients` | List ทุก API client พร้อม nested keys |
| POST | `/admin/api-keys/clients` | สร้าง API client ใหม่ |
| POST | `/admin/api-keys/generate` | สร้าง API key (คืน plaintext ครั้งเดียว) |
| POST | `/admin/api-keys/{id}/regenerate` | Regenerate key (คืน plaintext ครั้งเดียว) |
| PATCH | `/admin/api-keys/{id}/toggle` | Toggle is_active |
### Features ของหน้า `/api-management`
- Stats: จำนวน clients, total keys, active keys
- สร้าง API Client พร้อมกำหนดชื่อ
- สร้าง API Key พร้อมกำหนด permissions เป็น JSON array
- แสดง plaintext key ใน modal ครั้งเดียวหลัง generate/regenerate พร้อมปุ่ม Copy
- Activate/Deactivate key
- เข้าถึงได้ที่ `https://ai.sriphat.com/apiservice/api-management` (ต้อง login ด้วย Keycloak admin account)
### Deploy status
⏳ ยังไม่ deploy — รอ review ก่อน
---
## Blockers / สิ่งที่ค้างอยู่
- **Airflow API token** ยังไม่ได้ config → Finance upload จะ set status=error หลัง upload สำเร็จ (ไฟล์อัปขึ้น MinIO ได้ แต่ trigger DAG ไม่ได้)
- **VOC API key** — ยังต้องสร้าง ApiClient + ApiKey ที่มี permission `voc.data:write` สำหรับ client ที่จะส่งข้อมูล (ทำได้หลัง deploy Feature 2)
---
## 🧠 Decision & Lesson
_(เขียนเอง)_

View File

@@ -91,10 +91,11 @@ def mount_admin(app):
admin.add_view(ApiClientAdmin) admin.add_view(ApiClientAdmin)
admin.add_view(ApiKeyAdmin) admin.add_view(ApiKeyAdmin)
@app.get("/admin") # SQLAdmin /admin route disabled — replaced by Keycloak-protected /api-management page
async def _admin_redirect(request: Request): # @app.get("/admin")
root_path = request.scope.get("root_path") or "" # async def _admin_redirect(request: Request):
return RedirectResponse(url=f"{root_path}/admin/") # root_path = request.scope.get("root_path") or ""
# return RedirectResponse(url=f"{root_path}/admin/")
@app.post("/admin/api-keys/generate") @app.post("/admin/api-keys/generate")
async def _admin_generate_api_key( async def _admin_generate_api_key(

View File

@@ -9,9 +9,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn, VocDataIn
from app.core.config import settings from app.core.config import settings
from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment, RawVocData
from app.security.dependencies import get_db, require_permission from app.security.dependencies import get_db, require_permission
from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/v1")
PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write" PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write"
PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write" PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write"
PERM_FEED_PATIENT_APPOINTMENT_WRITE = "feed.patient-appointment:write" PERM_FEED_PATIENT_APPOINTMENT_WRITE = "feed.patient-appointment:write"
PERM_VOC_DATA_WRITE = "voc.data:write"
def _to_tz(dt): def _to_tz(dt):
@@ -304,3 +305,41 @@ def upsert_patient_appointment(
"error": supabase_error, "error": supabase_error,
}, },
} }
@router.post("/voc-data")
def insert_voc_data(
payload: list[VocDataIn],
_: Annotated[object, Depends(require_permission(PERM_VOC_DATA_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = [r.model_dump() for r in payload]
stmt = insert(RawVocData).values(rows)
result = db.execute(stmt)
db.commit()
supabase_rows = [{**r, "date": r["date"].isoformat()} for r in rows]
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} VOC records to Supabase API")
supabase_result = upsert_to_supabase_sync(table="raw_voc_data", data=supabase_rows)
logger.info(f"Successfully sent VOC data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send VOC data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending VOC data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"inserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}

View File

@@ -37,3 +37,12 @@ class PatientAppointmentIn(BaseModel):
doctor_code: str | None = None doctor_code: str | None = None
period: str | None = None period: str | None = None
appointment_type: str | None = None appointment_type: str | None = None
class VocDataIn(BaseModel):
date: date
topic: str
sub_topic: str
level: str
depart_id: str
dep_name: str | None = None

View File

@@ -48,5 +48,12 @@ class Settings(BaseSettings):
AIRFLOW_API_TOKEN: str = "" AIRFLOW_API_TOKEN: str = ""
AIRFLOW_DAG_ID_FINANCE: str = "process_finance_excel" AIRFLOW_DAG_ID_FINANCE: str = "process_finance_excel"
# MinIO Object Storage
MINIO_ENDPOINT: str = "192.168.100.9:9000"
MINIO_ACCESS_KEY: str = ""
MINIO_SECRET_KEY: str = ""
MINIO_BUCKET_FINANCE: str = "finance"
MINIO_USE_SSL: bool = False
settings = Settings() settings = Settings()

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import date, datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -65,6 +65,20 @@ class PatientAppointment(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class RawVocData(Base):
__tablename__ = "raw_voc_data"
__table_args__ = {"schema": "rawdata"}
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
date: Mapped[date] = mapped_column(Date, nullable=False)
topic: Mapped[str] = mapped_column(String(200), nullable=False)
sub_topic: Mapped[str] = mapped_column(String(200), nullable=False)
level: Mapped[str] = mapped_column(String(50), nullable=False)
depart_id: Mapped[str] = mapped_column(String(50), nullable=False)
dep_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ApiClient(Base): class ApiClient(Base):
__tablename__ = "api_client" __tablename__ = "api_client"
__table_args__ = {"schema": "fastapi"} __table_args__ = {"schema": "fastapi"}

View File

@@ -1,20 +1,16 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging import logging
import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.datastructures import Headers from starlette.datastructures import Headers
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
import sqladmin
from app.admin import mount_admin
from app.api.v1.routes import router as v1_router from app.api.v1.routes import router as v1_router
from app.routes.pages import router as pages_router from app.routes.pages import router as pages_router
from app.routes.auth import router as auth_router from app.routes.auth import router as auth_router
from app.routes.admin_users import router as admin_users_router from app.routes.admin_users import router as admin_users_router
from app.routes.admin_api_keys import router as admin_api_keys_router
from app.middleware.auth_middleware import WebAuthenticationMiddleware from app.middleware.auth_middleware import WebAuthenticationMiddleware
from app.core.config import settings from app.core.config import settings
from app.db.init_db import init_db from app.db.init_db import init_db
@@ -26,7 +22,6 @@ logging.basicConfig(
) )
logging.getLogger("uvicorn.error").setLevel(logging.DEBUG) logging.getLogger("uvicorn.error").setLevel(logging.DEBUG)
logging.getLogger("uvicorn.access").setLevel(logging.INFO) logging.getLogger("uvicorn.access").setLevel(logging.INFO)
logging.getLogger("sqladmin").setLevel(logging.DEBUG)
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
@@ -68,9 +63,6 @@ async def lifespan(_: FastAPI):
yield yield
sqladmin_dir = os.path.dirname(sqladmin.__file__)
statics_path = os.path.join(sqladmin_dir, "statics")
app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan) app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan)
# Add exception handler to log all errors with traceback # Add exception handler to log all errors with traceback
@@ -105,7 +97,4 @@ app.include_router(v1_router) # API endpoints - use API Key auth
app.include_router(pages_router) # Web pages - use Keycloak auth app.include_router(pages_router) # Web pages - use Keycloak auth
app.include_router(auth_router) # Authentication routes app.include_router(auth_router) # Authentication routes
app.include_router(admin_users_router) # Admin user management API app.include_router(admin_users_router) # Admin user management API
app.include_router(admin_api_keys_router) # API key management - use Keycloak admin auth
app.mount("/admin/statics", StaticFiles(directory=statics_path), name="admin_statics")
app.mount("/apiservice/admin/statics", StaticFiles(directory=statics_path), name="proxy_admin_statics")
mount_admin(app)

View File

@@ -34,14 +34,16 @@ class WebAuthenticationMiddleware(BaseHTTPMiddleware):
"/docs", "/docs",
"/redoc", "/redoc",
"/openapi.json", "/openapi.json",
"/data-management" "/data-management",
"/api-management",
"/admin/users",
] ]
# Routes that are excluded from user authentication # Routes that are excluded from user authentication
EXCLUDED_PATHS = [ EXCLUDED_PATHS = [
"/auth", # Authentication endpoints "/auth", # Authentication endpoints
"/api/v1", # API endpoints (use API Key) "/api/v1", # API endpoints (use API Key)
"/admin", # SQLAdmin (has own auth) "/admin", # Admin API endpoints (use require_role dependency)
] ]
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):

View File

@@ -0,0 +1,196 @@
"""
API Client and API Key management endpoints (Admin only)
Uses Keycloak admin role authentication — same pattern as admin_users.py
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from app.db.session import get_db
from app.db.models import ApiClient, ApiKey
from app.security.permissions import require_role, Roles
from app.security.api_key import generate_api_key, hash_api_key, encrypt_api_key, get_prefix
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"])
class ApiKeySchema(BaseModel):
id: int
name: str | None = None
key_prefix: str
permissions: list
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class ApiClientSchema(BaseModel):
id: int
name: str
is_active: bool
api_keys: List[ApiKeySchema] = []
class Config:
from_attributes = True
class ApiClientCreateSchema(BaseModel):
name: str
class ApiClientUpdateSchema(BaseModel):
name: str | None = None
is_active: bool | None = None
class ApiKeyCreateSchema(BaseModel):
client_id: int
name: str | None = None
permissions: list[str] = []
class ApiKeyUpdateSchema(BaseModel):
name: str | None = None
permissions: list[str] | None = None
@router.get("/clients", response_model=List[ApiClientSchema])
async def list_clients(
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""List all API clients with their keys (Admin only)"""
return db.query(ApiClient).order_by(ApiClient.id).all()
@router.patch("/clients/{client_id}", response_model=ApiClientSchema)
async def update_client(
client_id: int,
data: ApiClientUpdateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""Update API client name or active status (Admin only)"""
client = db.get(ApiClient, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
if data.name is not None:
existing = db.query(ApiClient).filter(ApiClient.name == data.name, ApiClient.id != client_id).first()
if existing:
raise HTTPException(status_code=400, detail="Client name already exists")
client.name = data.name
if data.is_active is not None:
client.is_active = data.is_active
db.commit()
db.refresh(client)
logger.info(f"Admin {current_user.get('username')} updated client {client_id}")
return client
@router.post("/clients", response_model=ApiClientSchema)
async def create_client(
data: ApiClientCreateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""Create a new API client (Admin only)"""
existing = db.query(ApiClient).filter(ApiClient.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail="Client name already exists")
client = ApiClient(name=data.name, is_active=True)
db.add(client)
db.commit()
db.refresh(client)
return client
@router.post("/generate")
async def generate_key(
data: ApiKeyCreateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""Generate a new API key for a client (Admin only). Returns plaintext key once."""
client = db.get(ApiClient, data.client_id)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
plain_key = generate_api_key()
api_key = ApiKey(
client_id=data.client_id,
name=data.name,
key_prefix=get_prefix(plain_key),
key_hash=hash_api_key(plain_key),
encrypted_key=encrypt_api_key(plain_key),
permissions=data.permissions,
is_active=True,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
logger.info(f"Admin {current_user.get('username')} created API key {api_key.id} for client {client.name}")
return {"key_id": api_key.id, "api_key": plain_key, "key_prefix": api_key.key_prefix, "permissions": data.permissions}
@router.post("/{key_id}/regenerate")
async def regenerate_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""Regenerate an API key — preserves permissions, returns new plaintext once (Admin only)"""
api_key = db.get(ApiKey, key_id)
if not api_key:
raise HTTPException(status_code=404, detail="API Key not found")
plain_key = generate_api_key()
api_key.key_prefix = get_prefix(plain_key)
api_key.key_hash = hash_api_key(plain_key)
api_key.encrypted_key = encrypt_api_key(plain_key)
db.commit()
db.refresh(api_key)
logger.info(f"Admin {current_user.get('username')} regenerated API key {key_id}")
return {"key_id": api_key.id, "api_key": plain_key, "key_prefix": api_key.key_prefix, "permissions": api_key.permissions}
@router.patch("/{key_id}", response_model=ApiKeySchema)
async def update_key(
key_id: int,
data: ApiKeyUpdateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""Update API key name or permissions (Admin only)"""
api_key = db.get(ApiKey, key_id)
if not api_key:
raise HTTPException(status_code=404, detail="API Key not found")
if data.name is not None:
api_key.name = data.name
if data.permissions is not None:
api_key.permissions = data.permissions
db.commit()
db.refresh(api_key)
logger.info(f"Admin {current_user.get('username')} updated API key {key_id}")
return api_key
@router.patch("/{key_id}/toggle")
async def toggle_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN)),
):
"""Toggle API key active/inactive (Admin only)"""
api_key = db.get(ApiKey, key_id)
if not api_key:
raise HTTPException(status_code=404, detail="API Key not found")
api_key.is_active = not api_key.is_active
db.commit()
return {"key_id": key_id, "is_active": api_key.is_active}

View File

@@ -19,6 +19,7 @@ from app.security.permissions import require_role, Roles
from app.db.session import get_db from app.db.session import get_db
from app.models.upload import UploadHistory from app.models.upload import UploadHistory
from app.services.airflow_client import airflow_client from app.services.airflow_client import airflow_client
from app.services import minio_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ router = APIRouter()
templates_dir = Path(__file__).parent.parent / "templates" templates_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir)) templates = Jinja2Templates(directory=str(templates_dir))
# Upload directory # Local fallback directory (used only if MinIO is not configured)
UPLOAD_DIR = Path("/data/uploads") UPLOAD_DIR = Path("/data/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@@ -94,6 +95,22 @@ async def admin_users_page(
) )
@router.get("/api-management", response_class=HTMLResponse)
async def api_management_page(
request: Request,
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""API Key management page - Admin only"""
return templates.TemplateResponse(
"api_management.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": current_user
}
)
@router.post("/data-management/finance/upload") @router.post("/data-management/finance/upload")
async def upload_finance_file( async def upload_finance_file(
request: Request, request: Request,
@@ -120,18 +137,26 @@ async def upload_finance_file(
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = file.filename.replace(" ", "_") safe_filename = file.filename.replace(" ", "_")
unique_filename = f"{timestamp}_{safe_filename}" unique_filename = f"{timestamp}_{safe_filename}"
filepath = UPLOAD_DIR / unique_filename
# Save file # Read file content
try: try:
content = await file.read() content = await file.read()
with open(filepath, "wb") as f:
f.write(content)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(status_code=500, detail=f"Failed to read file: {str(e)}")
status_code=500,
detail=f"Failed to save file: {str(e)}" # Upload to MinIO finance bucket
object_key = f"finance/{unique_filename}"
try:
minio_client.upload_file(
bucket=settings.MINIO_BUCKET_FINANCE,
object_name=object_key,
data=content,
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
) )
filepath_stored = object_key # store MinIO key in DB
except Exception as e:
logger.error(f"MinIO upload failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to upload file to storage: {str(e)}")
# Get username from session # Get username from session
user = request.session.get("user") user = request.session.get("user")
@@ -142,7 +167,7 @@ async def upload_finance_file(
upload_record = UploadHistory( upload_record = UploadHistory(
upload_id=upload_id, upload_id=upload_id,
filename=file.filename, filename=file.filename,
filepath=str(filepath), filepath=filepath_stored, # MinIO object key: finance/<filename>
description=description, description=description,
status="pending", status="pending",
uploaded_by=username uploaded_by=username
@@ -166,7 +191,7 @@ async def upload_finance_file(
result = await airflow_client.trigger_finance_dag( result = await airflow_client.trigger_finance_dag(
upload_id=upload_id, upload_id=upload_id,
filepath=str(filepath), filepath=str(filepath_stored),
filename=file.filename, filename=file.filename,
uploaded_by=username, uploaded_by=username,
description=description description=description

View File

@@ -0,0 +1,48 @@
import io
import logging
from minio import Minio
from minio.error import S3Error
from app.core.config import settings
logger = logging.getLogger(__name__)
_client: Minio | None = None
def get_client() -> Minio:
global _client
if _client is None:
_client = Minio(
endpoint=settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_USE_SSL,
)
return _client
def upload_file(bucket: str, object_name: str, data: bytes, content_type: str = "application/octet-stream") -> str:
"""Upload bytes to MinIO. Returns the object key."""
client = get_client()
client.put_object(
bucket_name=bucket,
object_name=object_name,
data=io.BytesIO(data),
length=len(data),
content_type=content_type,
)
logger.info(f"Uploaded {object_name} to bucket {bucket}")
return object_name
def get_presigned_url(bucket: str, object_name: str, expires_seconds: int = 3600) -> str:
"""Generate presigned GET URL valid for expires_seconds (default 1h)."""
from datetime import timedelta
client = get_client()
return client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=expires_seconds))
def delete_file(bucket: str, object_name: str) -> None:
client = get_client()
client.remove_object(bucket, object_name)
logger.info(f"Deleted {object_name} from bucket {bucket}")

View File

@@ -0,0 +1,620 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Management - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
h1 { color: #333; font-size: 32px; }
.user-info { display: flex; align-items: center; gap: 15px; }
.role-badge {
padding: 5px 15px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #667eea;
text-decoration: none;
font-weight: 600;
font-size: 16px;
}
.back-link:hover { text-decoration: underline; }
.alert {
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 8px;
font-weight: 500;
}
.alert-success { background: #51cf66; color: white; }
.alert-error { background: #ff6b6b; color: white; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-number { font-size: 36px; font-weight: bold; margin-bottom: 5px; }
.stat-label { font-size: 14px; opacity: 0.9; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 30px 0 15px;
}
.section-header h2 { font-size: 22px; color: #333; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #667eea; color: white; }
.btn-primary:hover { background: #5568d3; transform: translateY(-1px); }
.btn-success { background: #51cf66; color: white; }
.btn-success:hover { background: #40c057; transform: translateY(-1px); }
.btn-warning { background: #fcc419; color: #333; }
.btn-warning:hover { background: #fab005; transform: translateY(-1px); }
.btn-danger { background: #ff6b6b; color: white; }
.btn-danger:hover { background: #ee5a52; transform: translateY(-1px); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.client-card {
border: 1px solid #e0e0e0;
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
}
.client-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.client-name { font-size: 18px; font-weight: 600; color: #333; }
.client-meta { font-size: 13px; color: #666; margin-top: 3px; }
.client-actions { display: flex; gap: 8px; align-items: center; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { font-size: 12px; font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: 0.5px; background: #fafafa; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8f9ff; }
.status-active { color: #51cf66; font-weight: 600; }
.status-inactive { color: #ff6b6b; font-weight: 600; }
.key-prefix {
font-family: monospace;
background: #f1f3f5;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
}
.perm-tag {
display: inline-block;
background: #e7f5ff;
color: #1c7ed6;
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
margin: 2px 2px 2px 0;
}
.empty-keys {
padding: 20px;
text-align: center;
color: #aaa;
font-size: 14px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: white;
border-radius: 16px;
padding: 32px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal h3 { font-size: 20px; margin-bottom: 20px; color: #333; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 14px; font-weight: 600; color: #555; margin-bottom: 6px; }
.form-group input, .form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.form-group input:focus, .form-group textarea:focus { border-color: #667eea; }
.form-group small { font-size: 12px; color: #888; margin-top: 4px; display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
.key-result-box {
background: #f1f3f5;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px 16px;
font-family: monospace;
font-size: 14px;
word-break: break-all;
margin: 10px 0;
}
.key-warning {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: #856404;
margin-bottom: 12px;
}
.loading { text-align: center; padding: 40px; color: #666; }
.toggle-row { display: flex; align-items: center; gap: 10px; }
.toggle-switch {
position: relative; width: 44px; height: 24px; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; background: #ccc; border-radius: 24px; transition: 0.3s;
}
.toggle-slider:before {
content: ''; position: absolute; width: 18px; height: 18px;
left: 3px; top: 3px; background: white; border-radius: 50%; transition: 0.3s;
}
.toggle-switch input:checked + .toggle-slider { background: #51cf66; }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); }
</style>
</head>
<body>
<div class="container">
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
<div class="header">
<h1>🔑 API Management</h1>
{% if user %}
<div class="user-info">
<span>{{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge">{{ role }}</span>
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
<div id="alertContainer"></div>
<div class="stats">
<div class="stat-card">
<div class="stat-number" id="totalClients">-</div>
<div class="stat-label">API Clients</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalKeys">-</div>
<div class="stat-label">Total Keys</div>
</div>
<div class="stat-card">
<div class="stat-number" id="activeKeys">-</div>
<div class="stat-label">Active Keys</div>
</div>
</div>
<div class="section-header">
<h2>API Clients</h2>
<button class="btn btn-primary" onclick="openNewClientModal()">+ New Client</button>
</div>
<div id="clientsContainer">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Modal: New Client -->
<div class="modal-overlay" id="newClientModal">
<div class="modal">
<h3>New API Client</h3>
<div class="form-group">
<label>Client Name</label>
<input type="text" id="newClientName" placeholder="e.g. hospital-erp" />
<small>Unique identifier for this client system</small>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('newClientModal')">Cancel</button>
<button class="btn btn-primary" onclick="createClient()">Create</button>
</div>
</div>
</div>
<!-- Modal: New Key -->
<div class="modal-overlay" id="newKeyModal">
<div class="modal">
<h3>Generate API Key</h3>
<input type="hidden" id="newKeyClientId" />
<div class="form-group">
<label>Key Name (optional)</label>
<input type="text" id="newKeyName" placeholder="e.g. production" />
</div>
<div class="form-group">
<label>Permissions</label>
<textarea id="newKeyPermissions" rows="4" placeholder='["voc.data:write", "feed.checkpoint:write"]'></textarea>
<small>JSON array of permission strings</small>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('newKeyModal')">Cancel</button>
<button class="btn btn-success" onclick="generateKey()">Generate</button>
</div>
</div>
</div>
<!-- Modal: Edit Client -->
<div class="modal-overlay" id="editClientModal">
<div class="modal">
<h3>Edit API Client</h3>
<input type="hidden" id="editClientId" />
<div class="form-group">
<label>Client Name</label>
<input type="text" id="editClientName" />
</div>
<div class="form-group">
<label>Status</label>
<div class="toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="editClientActive" onchange="updateClientActiveLabel()" />
<span class="toggle-slider"></span>
</label>
<span id="editClientActiveLabel">Active</span>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('editClientModal')">Cancel</button>
<button class="btn btn-primary" onclick="saveEditClient()">Save</button>
</div>
</div>
</div>
<!-- Modal: Edit Key -->
<div class="modal-overlay" id="editKeyModal">
<div class="modal">
<h3>Edit API Key</h3>
<input type="hidden" id="editKeyId" />
<div class="form-group">
<label>Key Name (optional)</label>
<input type="text" id="editKeyName" placeholder="e.g. production" />
</div>
<div class="form-group">
<label>Permissions</label>
<textarea id="editKeyPermissions" rows="4" placeholder='["voc.data:write"]'></textarea>
<small>JSON array of permission strings</small>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('editKeyModal')">Cancel</button>
<button class="btn btn-primary" onclick="saveEditKey()">Save</button>
</div>
</div>
</div>
<!-- Modal: Show Key -->
<div class="modal-overlay" id="keyResultModal">
<div class="modal">
<h3>🔑 API Key Generated</h3>
<div class="key-warning">⚠️ Copy this key now — it will NOT be shown again after closing this dialog.</div>
<div class="form-group">
<label>API Key</label>
<div class="key-result-box" id="keyResultValue"></div>
<button class="btn btn-primary btn-sm" onclick="copyKey()" style="margin-top:8px;">Copy to Clipboard</button>
</div>
<div class="form-group">
<label>Permissions</label>
<div id="keyResultPerms"></div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeKeyResult()">Done</button>
</div>
</div>
</div>
<script>
const rootPath = "{{ root_path }}";
async function loadClients() {
try {
const res = await fetch(`${rootPath}/admin/api-keys/clients`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const clients = await res.json();
const totalKeys = clients.reduce((s, c) => s + c.api_keys.length, 0);
const activeKeys = clients.reduce((s, c) => s + c.api_keys.filter(k => k.is_active).length, 0);
document.getElementById('totalClients').textContent = clients.length;
document.getElementById('totalKeys').textContent = totalKeys;
document.getElementById('activeKeys').textContent = activeKeys;
const container = document.getElementById('clientsContainer');
if (clients.length === 0) {
container.innerHTML = '<div class="empty-keys">No API clients yet. Create one to get started.</div>';
return;
}
container.innerHTML = clients.map(client => `
<div class="client-card">
<div class="client-header">
<div>
<div class="client-name">${escapeHtml(client.name)}</div>
<div class="client-meta">ID: ${client.id} &nbsp;·&nbsp; ${client.api_keys.length} key(s)</div>
</div>
<div class="client-actions">
<span class="${client.is_active ? 'status-active' : 'status-inactive'}">${client.is_active ? '● Active' : '● Inactive'}</span>
<button class="btn btn-success btn-sm" onclick="openNewKeyModal(${client.id})">+ Add Key</button>
<button class="btn btn-warning btn-sm" data-id="${client.id}" data-name="${escapeHtml(client.name)}" data-active="${client.is_active}" onclick="openEditClientModal(this)">Edit</button>
</div>
</div>
${client.api_keys.length === 0
? '<div class="empty-keys">No API keys yet.</div>'
: `<table>
<thead><tr>
<th>ID</th><th>Name</th><th>Prefix</th><th>Permissions</th><th>Status</th><th>Created</th><th>Actions</th>
</tr></thead>
<tbody>
${client.api_keys.map(key => `
<tr>
<td>${key.id}</td>
<td>${key.name ? escapeHtml(key.name) : '-'}</td>
<td><span class="key-prefix">${escapeHtml(key.key_prefix)}...</span></td>
<td>${key.permissions.map(p => `<span class="perm-tag">${escapeHtml(p)}</span>`).join('') || '<span style="color:#aaa">none</span>'}</td>
<td class="${key.is_active ? 'status-active' : 'status-inactive'}">${key.is_active ? '● Active' : '● Inactive'}</td>
<td>${formatDate(key.created_at)}</td>
<td>
<button class="btn btn-primary btn-sm" data-id="${key.id}" data-name="${escapeHtml(key.name || '')}" data-perms="${JSON.stringify(key.permissions).replace(/"/g, '&quot;')}" onclick="openEditKeyModal(this)">Edit</button>
<button class="btn btn-warning btn-sm" onclick="regenerateKey(${key.id})">Regenerate</button>
<button class="btn btn-sm" style="background:#dee2e6" onclick="toggleKey(${key.id})">${key.is_active ? 'Deactivate' : 'Activate'}</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`
}
</div>
`).join('');
} catch (err) {
document.getElementById('clientsContainer').innerHTML = `<div class="empty-keys" style="color:#ff6b6b">Error: ${escapeHtml(err.message)}</div>`;
showAlert('Failed to load clients: ' + err.message, 'error');
}
}
function updateClientActiveLabel() {
const cb = document.getElementById('editClientActive');
document.getElementById('editClientActiveLabel').textContent = cb.checked ? 'Active' : 'Inactive';
}
function openEditClientModal(btn) {
const clientId = btn.dataset.id;
const name = btn.dataset.name;
const isActive = btn.dataset.active === 'true';
document.getElementById('editClientId').value = clientId;
document.getElementById('editClientName').value = name;
document.getElementById('editClientActive').checked = isActive;
document.getElementById('editClientActiveLabel').textContent = isActive ? 'Active' : 'Inactive';
document.getElementById('editClientModal').classList.add('active');
}
async function saveEditClient() {
const clientId = document.getElementById('editClientId').value;
const name = document.getElementById('editClientName').value.trim();
const isActive = document.getElementById('editClientActive').checked;
if (!name) return showAlert('Client name is required', 'error');
try {
const res = await fetch(`${rootPath}/admin/api-keys/clients/${clientId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, is_active: isActive })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
closeModal('editClientModal');
showAlert('Client updated', 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function openEditKeyModal(btn) {
const keyId = btn.dataset.id;
const name = btn.dataset.name;
let permissions = [];
try { permissions = JSON.parse(btn.dataset.perms); } catch {}
document.getElementById('editKeyId').value = keyId;
document.getElementById('editKeyName').value = name || '';
document.getElementById('editKeyPermissions').value = JSON.stringify(permissions, null, 2);
document.getElementById('editKeyModal').classList.add('active');
}
async function saveEditKey() {
const keyId = document.getElementById('editKeyId').value;
const name = document.getElementById('editKeyName').value.trim() || null;
const permsRaw = document.getElementById('editKeyPermissions').value.trim();
let permissions;
try { permissions = permsRaw ? JSON.parse(permsRaw) : []; }
catch { return showAlert('Permissions must be a valid JSON array', 'error'); }
try {
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, permissions })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
closeModal('editKeyModal');
showAlert('Key updated', 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function openNewClientModal() {
document.getElementById('newClientName').value = '';
document.getElementById('newClientModal').classList.add('active');
}
async function createClient() {
const name = document.getElementById('newClientName').value.trim();
if (!name) return showAlert('Client name is required', 'error');
try {
const res = await fetch(`${rootPath}/admin/api-keys/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
closeModal('newClientModal');
showAlert(`Client "${name}" created`, 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function openNewKeyModal(clientId) {
document.getElementById('newKeyClientId').value = clientId;
document.getElementById('newKeyName').value = '';
document.getElementById('newKeyPermissions').value = '';
document.getElementById('newKeyModal').classList.add('active');
}
async function generateKey() {
const clientId = parseInt(document.getElementById('newKeyClientId').value);
const name = document.getElementById('newKeyName').value.trim() || null;
const permsRaw = document.getElementById('newKeyPermissions').value.trim();
let permissions = [];
if (permsRaw) {
try { permissions = JSON.parse(permsRaw); }
catch { return showAlert('Permissions must be a valid JSON array', 'error'); }
}
try {
const res = await fetch(`${rootPath}/admin/api-keys/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId, name, permissions })
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
const data = await res.json();
closeModal('newKeyModal');
showKeyResult(data.api_key, data.permissions);
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
async function regenerateKey(keyId) {
if (!confirm('Regenerate this key? The current key will stop working immediately.')) return;
try {
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}/regenerate`, { method: 'POST' });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
const data = await res.json();
showKeyResult(data.api_key, data.permissions);
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
async function toggleKey(keyId) {
try {
const res = await fetch(`${rootPath}/admin/api-keys/${keyId}/toggle`, { method: 'PATCH' });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
const data = await res.json();
showAlert(`Key ${data.is_active ? 'activated' : 'deactivated'}`, 'success');
loadClients();
} catch (err) { showAlert('Failed: ' + err.message, 'error'); }
}
function showKeyResult(apiKey, permissions) {
document.getElementById('keyResultValue').textContent = apiKey;
document.getElementById('keyResultPerms').innerHTML = permissions.length
? permissions.map(p => `<span class="perm-tag">${escapeHtml(p)}</span>`).join('')
: '<span style="color:#aaa">none</span>';
document.getElementById('keyResultModal').classList.add('active');
}
function copyKey() {
const key = document.getElementById('keyResultValue').textContent;
navigator.clipboard.writeText(key).then(() => showAlert('Copied to clipboard', 'success'));
}
function closeKeyResult() {
closeModal('keyResultModal');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
function showAlert(message, type) {
const el = document.getElementById('alertContainer');
el.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
setTimeout(() => el.innerHTML = '', 5000);
}
function formatDate(s) {
return new Date(s).toLocaleString('th-TH', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = String(text);
return d.innerHTML;
}
loadClients();
</script>
</body>
</html>

View File

@@ -275,6 +275,15 @@
<p>Manage users and roles (Admin only)</p> <p>Manage users and roles (Admin only)</p>
</a> </a>
{% endif %} {% endif %}
<!-- API Management (Admin only) -->
{% if user and user.roles and 'admin' in user.roles %}
<a href="{{ root_path }}/api-management" class="menu-card card-admin">
<span class="icon">🔑</span>
<h3>API Management</h3>
<p>Manage API clients and keys (Admin only)</p>
</a>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
x-common-configs: &common-config x-common-configs: &common-config
extra_hosts: extra_hosts:
- "dev.sriphat.com:192.168.100.9" - "dev.sriphat.com:192.168.100.9"
- "ai.sriphat.com:192.168.100.8"
pull_policy: ${DOCKER_PULL_POLICY:-missing} pull_policy: ${DOCKER_PULL_POLICY:-missing}
services: services:
@@ -30,6 +31,11 @@ services:
- KEYCLOAK_CLIENT_ID=${API_KEYCLOAK_CLIENT_ID} - KEYCLOAK_CLIENT_ID=${API_KEYCLOAK_CLIENT_ID}
- KEYCLOAK_CLIENT_SECRET=${API_KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_CLIENT_SECRET=${API_KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_REDIRECT_URI=${API_KEYCLOAK_REDIRECT_URI} - KEYCLOAK_REDIRECT_URI=${API_KEYCLOAK_REDIRECT_URI}
- MINIO_ENDPOINT=${MINIO_ENDPOINT:-192.168.100.9:9000}
- MINIO_ACCESS_KEY=${MINIO_SVC_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SVC_SECRET_KEY}
- MINIO_BUCKET_FINANCE=${MINIO_BUCKET_FINANCE:-finance}
- MINIO_USE_SSL=${MINIO_USE_SSL:-false}
- LOG_LEVEL=debug - LOG_LEVEL=debug
ports: ports:
- "8040:8040" - "8040:8040"

View File

@@ -17,4 +17,4 @@ cryptography==42.0.5
python-keycloak==3.9.0 python-keycloak==3.9.0
Authlib==1.3.0 Authlib==1.3.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
minio==7.2.11

View File

@@ -40,7 +40,7 @@ might_contain_dag_callable = airflow.utils.file.might_contain_dag_via_default_he
# #
# Variable: AIRFLOW__CORE__DEFAULT_TIMEZONE # Variable: AIRFLOW__CORE__DEFAULT_TIMEZONE
# #
default_timezone = utc default_timezone = Asia/Bangkok
# The executor class that airflow should use. Choices include # The executor class that airflow should use. Choices include
# ``LocalExecutor``, ``CeleryExecutor``, # ``LocalExecutor``, ``CeleryExecutor``,
@@ -90,7 +90,7 @@ simple_auth_manager_all_admins = False
# #
# Variable: AIRFLOW__CORE__PARALLELISM # Variable: AIRFLOW__CORE__PARALLELISM
# #
parallelism = 8 parallelism = 2
# The maximum number of task instances allowed to run concurrently in each dag run. # The maximum number of task instances allowed to run concurrently in each dag run.
# This is also configurable per-dag with ``max_active_tasks``, # This is also configurable per-dag with ``max_active_tasks``,
@@ -115,7 +115,7 @@ dags_are_paused_at_creation = True
# #
# Variable: AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG # Variable: AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG
# #
max_active_runs_per_dag = 16 max_active_runs_per_dag = 1
# (experimental) The maximum number of consecutive DAG failures before DAG is automatically paused. # (experimental) The maximum number of consecutive DAG failures before DAG is automatically paused.
# This is also configurable per DAG level with ``max_consecutive_failed_dag_runs``, # This is also configurable per DAG level with ``max_consecutive_failed_dag_runs``,
@@ -2166,7 +2166,7 @@ refresh_interval = 300
# #
# Variable: AIRFLOW__DAG_PROCESSOR__PARSING_PROCESSES # Variable: AIRFLOW__DAG_PROCESSOR__PARSING_PROCESSES
# #
parsing_processes = 2 parsing_processes = 1
# One of ``modified_time``, ``random_seeded_by_host`` and ``alphabetical``. # One of ``modified_time``, ``random_seeded_by_host`` and ``alphabetical``.
# The DAG processor will list and sort the dag files to decide the parsing order. # The DAG processor will list and sort the dag files to decide the parsing order.
@@ -2193,7 +2193,9 @@ max_callbacks_per_loop = 20
# #
# Variable: AIRFLOW__DAG_PROCESSOR__MIN_FILE_PROCESS_INTERVAL # Variable: AIRFLOW__DAG_PROCESSOR__MIN_FILE_PROCESS_INTERVAL
# #
min_file_process_interval = 30 min_file_process_interval = 90
dag_dir_list_interval = 90
# How long (in seconds) to wait after we have re-parsed a DAG file before deactivating stale # How long (in seconds) to wait after we have re-parsed a DAG file before deactivating stale
# DAGs (DAGs which are no longer present in the expected files). The reason why we need # DAGs (DAGs which are no longer present in the expected files). The reason why we need
@@ -2491,7 +2493,7 @@ flower_basic_auth =
# #
# Variable: AIRFLOW__CELERY__SYNC_PARALLELISM # Variable: AIRFLOW__CELERY__SYNC_PARALLELISM
# #
sync_parallelism = 0 sync_parallelism = 2
# Import path for celery configuration options # Import path for celery configuration options
# #

View File

@@ -64,7 +64,7 @@ x-airflow-common:
AIRFLOW__CELERY__BROKER_URL: redis://:@redis:6379/0 AIRFLOW__CELERY__BROKER_URL: redis://:@redis:6379/0
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW__CORE__FERNET_KEY:-} AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW__CORE__FERNET_KEY:-}
AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: 'true' AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: 'true'
AIRFLOW__CORE__LOAD_EXAMPLES: ${AIRFLOW__CORE__LOAD_EXAMPLES:-'false'} AIRFLOW__CORE__LOAD_EXAMPLES: ${AIRFLOW__CORE__LOAD_EXAMPLES:-False}
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: 'http://airflow-apiserver:8080/execution/' AIRFLOW__CORE__EXECUTION_API_SERVER_URL: 'http://airflow-apiserver:8080/execution/'
# yamllint disable rule:line-length # yamllint disable rule:line-length
# Use simple http server on scheduler for health checks # Use simple http server on scheduler for health checks
@@ -76,18 +76,22 @@ x-airflow-common:
_PIP_ADDITIONAL_REQUIREMENTS: ${_PIP_ADDITIONAL_REQUIREMENTS:-} _PIP_ADDITIONAL_REQUIREMENTS: ${_PIP_ADDITIONAL_REQUIREMENTS:-}
# The following line can be used to set a custom config file, stored in the local config folder # The following line can be used to set a custom config file, stored in the local config folder
AIRFLOW_CONFIG: '/opt/airflow/config/airflow.cfg' AIRFLOW_CONFIG: '/opt/airflow/config/airflow.cfg'
AIRFLOW__WEBSERVER__BASE_URL: ${AIRFLOW__WEBSERVER__BASE_URL:-https://ai.sriphat.com/airflow}
AIRFLOW__API__BASE_URL: ${AIRFLOW__WEBSERVER__BASE_URL:-https://ai.sriphat.com/airflow}
AIRFLOW__WEBSERVER__WEB_SERVER_PORT: ${AIRFLOW__WEBSERVER__WEB_SERVER_PORT:-8080}
volumes: volumes:
- ${AIRFLOW_PROJ_DIR:-.}/dags:/opt/airflow/dags - ${AIRFLOW_PROJ_DIR:-.}/dags:/opt/airflow/dags
- ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs - ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs
- ${AIRFLOW_PROJ_DIR:-.}/config:/opt/airflow/config - ${AIRFLOW_PROJ_DIR:-.}/config:/opt/airflow/config
- ${AIRFLOW_PROJ_DIR:-.}/plugins:/opt/airflow/plugins - ${AIRFLOW_PROJ_DIR:-.}/plugins:/opt/airflow/plugins
user: "${AIRFLOW_UID:-50000}:0" user: "${AIRFLOW_UID:-50000}:0"
depends_on: x-depends_on:
&airflow-common-depends-on &airflow-common-depends-on
{}
# airflow-base: # airflow-base:
# condition: service_completed_successfully # condition: service_completed_successfully
redis: # redis:
condition: service_healthy # condition: service_healthy
networks: networks:
- shared_data_network - shared_data_network
@@ -114,19 +118,19 @@ services:
# start_period: 5s # start_period: 5s
# restart: always # restart: always
redis: # redis:
# Redis is limited to 7.2-bookworm due to licencing change # # Redis is limited to 7.2-bookworm due to licencing change
# https://redis.io/blog/redis-adopts-dual-source-available-licensing/ # # https://redis.io/blog/redis-adopts-dual-source-available-licensing/
image: redis:7.2-bookworm # image: redis:7.2-bookworm
expose: # expose:
- 6379 # - 6379
healthcheck: # healthcheck:
test: ["CMD", "redis-cli", "ping"] # test: ["CMD", "redis-cli", "ping"]
interval: 10s # interval: 10s
timeout: 30s # timeout: 30s
retries: 50 # retries: 50
start_period: 30s # start_period: 30s
restart: always # restart: always
airflow-apiserver: airflow-apiserver:
<<: *airflow-common <<: *airflow-common

View File

@@ -2,9 +2,58 @@ import os
SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY') SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY')
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{os.environ.get('DATABASE_USER')}:{os.environ.get('DATABASE_PASSWORD')}@{os.environ.get('DATABASE_HOST')}:{os.environ.get('DATABASE_PORT')}/{os.environ.get('DATABASE_DB')}" SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{os.environ.get('DATABASE_USER')}:{os.environ.get('DATABASE_PASSWORD')}@{os.environ.get('DATABASE_HOST')}:{os.environ.get('DATABASE_PORT')}/{os.environ.get('DATABASE_DB')}"
ENABLE_PROXY_FIX = True ENABLE_PROXY_FIX = True
PUBLIC_ROLE_LIKE = "Gamma" PUBLIC_ROLE_LIKE = "Gamma"
WTF_CSRF_ENABLED = True WTF_CSRF_ENABLED = False
WTF_CSRF_TIME_LIMIT = None WTF_CSRF_TIME_LIMIT = None
FEATURE_FLAGS = {
"EMBEDDED_SUPERSET": True,
}
GUEST_ROLE_NAME = "Gamma"
ENABLE_CORS = True
CORS_OPTIONS = {
'supports_credentials': True,
'allow_headers': ['*'],
'resources': ['*'],
'origins': ['*']
}
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = False
GUEST_TOKEN_JWT_SECRET = 'RgSCvATmH8fzluoFB6cqkdCXsY7jjq/zwGLRatoxYtI='
GUEST_TOKEN_JWT_EXP_SECONDS = 86400 # 24 hours
# Logo link configuration
LOGO_TARGET_PATH = '/superset/welcome/'
# Embedded SDK Configuration
EMBEDDED_SUPERSET = True
TALISMAN_ENABLED = False
ENABLE_TEMPLATE_PROCESSING = True
# Guest token configuration for embedded SDK
GUEST_TOKEN_JWT_ALGORITHM = "HS256"
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes
# Domain whitelist for embedded dashboards
WEBDRIVER_BASEURL_USER_FRIENDLY_NAME = "Sriphat Dashboard"
# Embedded SDK Domain Whitelist
EMBEDDED_SDK_HOST_WHITELIST = [
"http://localhost:8800",
"https://ai.sriphat.com",
"http://127.0.0.1:8800"
]
# Allow embedding from specific domains
TALISMAN_ALLOWED_DOMAINS = [
"http://localhost:8800",
"https://ai.sriphat.com",
"http://127.0.0.1:8800"
]

View File

@@ -49,6 +49,14 @@ MINIO_IDENTITY_OPENID_SCOPES=openid,profile,email
# Redirect URI after authentication # Redirect URI after authentication
MINIO_IDENTITY_OPENID_REDIRECT_URI=https://ai.sriphat.com/minio-console/oauth_callback MINIO_IDENTITY_OPENID_REDIRECT_URI=https://ai.sriphat.com/minio-console/oauth_callback
# ============================================================================
# Service Account — Web Service
# สร้างหลัง MinIO start แล้วด้วย mc CLI
# mc admin user svcacct add --access-key <KEY> --secret-key <SECRET> sriphat admin
# ============================================================================
MINIO_SVC_ACCESS_KEY=sp_service_ac
MINIO_SVC_SECRET_KEY=your-service-account-secret-here
# ============================================================================ # ============================================================================
# Timezone # Timezone
# ============================================================================ # ============================================================================

View File

@@ -166,6 +166,133 @@ mc ls myminio/my-bucket
mc rm myminio/my-bucket/myfile.txt mc rm myminio/my-bucket/myfile.txt
``` ```
### **Python SDK (minio — แนะนำสำหรับ Sriphat Platform)**
ใช้ `minio` package (Official MinIO Python SDK) แทน boto3 สำหรับ internal services:
```python
from minio import Minio
import io
# Connection — ใช้ internal IP จาก service บน server อื่น
client = Minio(
endpoint="192.168.100.9:9000", # internal IP, ไม่ใช่ public URL
access_key="sp_service_ac",
secret_key="<MINIO_SVC_SECRET_KEY>",
secure=False, # HTTP ภายใน network
)
# Upload file
with open("report.xlsx", "rb") as f:
data = f.read()
client.put_object(
bucket_name="finance",
object_name="finance/20260520_report.xlsx",
data=io.BytesIO(data),
length=len(data),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
# Download file (คืนค่าเป็น HTTPResponse)
response = client.get_object("finance", "finance/20260520_report.xlsx")
data = response.read()
response.close()
response.release_conn()
# Download ไปยัง file โดยตรง
client.fget_object("finance", "finance/20260520_report.xlsx", "/tmp/report.xlsx")
# List objects ใน bucket
for obj in client.list_objects("finance", prefix="finance/", recursive=True):
print(obj.object_name, obj.size)
# Generate presigned URL (สำหรับให้ภายนอกดาวน์โหลด ใช้ได้ 1 ชั่วโมง)
from datetime import timedelta
url = client.presigned_get_object("finance", "finance/report.xlsx", expires=timedelta(hours=1))
print(url)
```
---
### **Airflow DAG — อ่านไฟล์จาก MinIO finance bucket**
Airflow อยู่บน server .9 (server เดียวกับ MinIO) ใช้ container name `minio:9000` หรือ `192.168.100.9:9000`:
```python
from minio import Minio
import pandas as pd
import io
from airflow.decorators import dag, task
from airflow.utils.dates import days_ago
@dag(schedule=None, start_date=days_ago(1), catchup=False)
def process_finance_excel():
@task
def download_and_process(filepath: str, **context):
"""
filepath = MinIO object key เช่น "finance/20260520_123000_report.xlsx"
ส่งมาจาก API Service ผ่าน DAG trigger conf
"""
client = Minio(
endpoint="minio:9000", # container name บน shared_data_network
access_key="sp_service_ac", # ใช้ service account เดียวกับ API
secret_key="{{ var.value.MINIO_SVC_SECRET_KEY }}", # เก็บใน Airflow Variables
secure=False,
)
# Download file จาก MinIO
response = client.get_object(bucket_name="finance", object_name=filepath)
file_bytes = response.read()
response.close()
response.release_conn()
# ประมวลผลด้วย pandas
df = pd.read_excel(io.BytesIO(file_bytes))
print(f"Loaded {len(df)} rows from {filepath}")
# ... process data ...
return {"rows": len(df), "filepath": filepath}
@task
def get_filepath(**context):
conf = context["dag_run"].conf or {}
return conf.get("filepath", "")
fp = get_filepath()
download_and_process(fp)
process_finance_excel()
```
**ตั้งค่า Airflow Connection (ทางเลือก — ใช้ S3Hook)**
ถ้าต้องการใช้ `S3Hook` หรือ Airflow Operators:
```
Connection ID : minio_s3
Connection Type: Amazon Web Services
Extra (JSON) : {"endpoint_url": "http://minio:9000", "region_name": "ap-southeast-1"}
Login : sp_service_ac
Password : <MINIO_SVC_SECRET_KEY>
```
```python
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
hook = S3Hook(aws_conn_id="minio_s3")
obj = hook.get_key(key="finance/20260520_report.xlsx", bucket_name="finance")
data = obj.get()["Body"].read()
df = pd.read_excel(io.BytesIO(data))
```
**Airflow Variables ที่ต้องสร้าง:**
| Key | Value |
|-----|-------|
| `MINIO_ENDPOINT` | `192.168.100.9:9000` |
| `MINIO_SVC_SECRET_KEY` | (ดูจาก `07-minio/.env`) |
---
### **Python SDK (boto3)** ### **Python SDK (boto3)**
```python ```python

55
CLAUDE.md Normal file
View File

@@ -0,0 +1,55 @@
# Sriphat Data Platform — Claude Context
## Project Paths
| Environment | Path |
|-------------|------|
| **Local (WSL2)** | `/mnt/e/git3/sriphat-dataplatform` |
| **Server .8** | `/home/bdadmin/sriphat-dataplatform` |
| **Server .9** | `/home/bdadmin/sriphat-dataplatform` (เฉพาะ folders ของ service ที่รันบนเครื่องนี้) |
| **Windows** | `E:\git3\sriphat-dataplatform` |
## Remote Hosts (WSL2 SSH)
| Server | IP | SSH Script |
|--------|----|-----------|
| **Server 1** | 192.168.100.8 | `~/key/ssh_sriphat_8.sh` |
| **Server 2** | 192.168.100.9 | `~/key/ssh_sriphat_9.sh` |
## Service Distribution
### Server 1 — 192.168.100.8 (folders ทั้งหมด)
```
00-network/ # Docker network setup
01-infra/ # Nginx, Keycloak, PostgreSQL, Redis, Dozzle
02-supabase/ # Supabase full stack (13 containers)
03-apiservice/ # Custom FastAPI service
06-analytics/ # Apache Superset
```
### Server 2 — 192.168.100.9 (เฉพาะ folders ของ service ที่ใช้บนเครื่องนี้)
```
05-airflow/ # Apache Airflow (CeleryExecutor)
07-minio/ # MinIO Object Storage
```
> **หมายเหตุ:** OpenMetadata อยู่บน server .9 แต่ไม่อยู่ใน repo นี้
## Global Environment File
- **Local dev:** `.env.global` (root of project)
- **Server:** `/home/bdadmin/sriphat-dataplatform/.env.global`
- ไฟล์นี้อยู่ใน `.gitignore` — ต้องสร้างและ sync เองบนแต่ละเครื่อง
## Docker Network
ทุก service ใช้ network ร่วม ต้องสร้างก่อนรัน:
```bash
docker network create shared_data_network
```
## Daily Log Convention
- Path: `_daily-log/YYYY-MM-DD-sriphat-dataplatform.md`
- Folder `_daily-log/` อยู่ใน `.gitignore`
- ดู convention ที่: `_daily-log/` (ถ้ามี README) หรือจาก BDA standard

View File

@@ -0,0 +1,400 @@
# Dozzle Multi-Host Setup Guide
คู่มือการตั้งค่า Dozzle สำหรับ monitor Docker containers บนหลาย hosts
## 🏗️ Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Main Server (Current Host) │
│ ├─ Nginx Proxy Manager │
│ ├─ Keycloak │
│ ├─ PostgreSQL │
│ ├─ API Service │
│ ├─ Supabase │
│ ├─ Superset │
│ └─ Dozzle (Main UI) ──────────────┐ │
└───────────────────────────────────┼─────────────────────────┘
┌───────────────┴───────────────┐
│ │
┌───────────▼──────────┐ ┌────────────▼─────────┐
│ 192.168.100.9 │ │ 192.168.100.9 │
│ Airbyte Host │ │ Airflow Host │
│ ├─ Airbyte Services │ │ ├─ Airflow Services │
│ └─ Dozzle Agent │ │ └─ Dozzle Agent │
│ (Port 7007) │ │ (Port 7008) │
└──────────────────────┘ └──────────────────────┘
```
## 📋 Setup Steps
### **Step 1: ติดตั้ง Dozzle Agent บน Remote Hosts**
#### **สำหรับ Airbyte Host (192.168.100.9:7007)**
สร้าง/แก้ไข `docker-compose.yml` ใน Airbyte directory:
```yaml
services:
# ... existing Airbyte services ...
dozzle-agent:
image: amir20/dozzle:latest
container_name: dozzle-agent-airbyte
command: agent
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "7007:7007"
environment:
DOZZLE_LEVEL: info
DOZZLE_HOSTNAME: Airbyte Server
TZ: Asia/Bangkok
restart: unless-stopped
networks:
- airbyte_network # ใช้ network ของ Airbyte
```
**Start agent:**
```bash
docker compose up -d dozzle-agent
```
#### **สำหรับ Airflow Host (192.168.100.9:7008)**
สร้าง/แก้ไข `docker-compose.yml` ใน Airflow directory:
```yaml
services:
# ... existing Airflow services ...
dozzle-agent:
image: amir20/dozzle:latest
container_name: dozzle-agent-airflow
command: agent
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "7008:7007" # External: 7008, Internal: 7007
environment:
DOZZLE_LEVEL: info
DOZZLE_HOSTNAME: Airflow Server
TZ: Asia/Bangkok
restart: unless-stopped
networks:
- shared_data_network # ใช้ network ของ Airflow
```
**Start agent:**
```bash
docker compose up -d dozzle-agent
```
### **Step 2: ตรวจสอบ Agents**
```bash
# ตรวจสอบ Airbyte agent
curl http://192.168.100.9:7007/healthcheck
# ตรวจสอบ Airflow agent
curl http://192.168.100.9:7008/healthcheck
# ดู logs
docker logs dozzle-agent-airbyte
docker logs dozzle-agent-airflow
```
### **Step 3: Start Dozzle Main UI (Main Server)**
```bash
cd 01-infra
docker compose up -d dozzle
# ตรวจสอบ
docker logs dozzle -f
```
### **Step 4: เข้าใช้งาน**
**Direct access:**
```
http://localhost:9999/dozzle
```
**ผ่าน Nginx:**
```
http://ai.sriphat.com/dozzle
```
## 🔧 Configuration Details
### **Main Server (.env.global)**
```bash
# Dozzle - Docker Log Viewer & Monitoring
DOZZLE_PORT=9999
DOZZLE_LEVEL=info
DOZZLE_BASE=/dozzle
DOZZLE_HOSTNAME=Sriphat Main Server
DOZZLE_AUTH_PROVIDER=none
DOZZLE_RESTART_POLICY=unless-stopped
# Remote agents: Airbyte and Airflow on 192.168.100.9
# Format: host:port,host:port (comma-separated)
DOZZLE_REMOTE_AGENT=192.168.100.9:7007,192.168.100.9:7008
```
### **Agent Configuration**
**Airbyte Agent:**
- Port: 7007
- Hostname: Airbyte Server
- Monitors: Airbyte containers
**Airflow Agent:**
- Port: 7008
- Hostname: Airflow Server
- Monitors: Airflow containers
## 🌐 Nginx Configuration
Dozzle config ถูกเพิ่มใน:
- `01-infra/nginx-configs/dozzle.conf`
- `01-infra/nginx-configs/complete-example.conf`
**ตั้งค่าใน Nginx Proxy Manager:**
1. ไปที่ Proxy Host → Edit
2. Tab "Advanced"
3. เพิ่ม Dozzle config จาก `complete-example.conf`
## 🔍 Features
### **1. Multi-Host Monitoring**
- ✅ ดู logs จาก Main Server
- ✅ ดู logs จาก Airbyte Host (192.168.100.9:7007)
- ✅ ดู logs จาก Airflow Host (192.168.100.9:7008)
- ✅ Switch ระหว่าง hosts ผ่าน dropdown
### **2. Real-time Log Streaming**
- Live log updates
- Color-coded logs
- JSON formatting
- Multi-line grouping
### **3. Container Management**
- View container stats (CPU, Memory, Network)
- Start/Stop/Restart containers
- Interactive shell access
- Container filtering
### **4. Advanced Features**
- Search และ filter logs
- Download logs
- Multiple container view
- SQL-based log querying
## 🐛 Troubleshooting
### **Issue: Agent ไม่ปรากฏใน UI**
**ตรวจสอบ:**
```bash
# 1. Agent ทำงานหรือไม่
docker ps | grep dozzle-agent
# 2. Port เปิดหรือไม่
netstat -tulpn | grep 7007
netstat -tulpn | grep 7008
# 3. Firewall
sudo ufw status
sudo ufw allow 7007
sudo ufw allow 7008
# 4. Network connectivity
ping 192.168.100.9
telnet 192.168.100.9 7007
telnet 192.168.100.9 7008
```
### **Issue: Connection Refused**
**สาเหตุ:**
- Agent ไม่ทำงาน
- Firewall block port
- Network ไม่เชื่อมต่อ
**วิธีแก้:**
```bash
# Restart agent
docker restart dozzle-agent-airbyte
docker restart dozzle-agent-airflow
# ตรวจสอบ logs
docker logs dozzle-agent-airbyte
docker logs dozzle-agent-airflow
# ทดสอบ connectivity
curl http://192.168.100.9:7007/healthcheck
curl http://192.168.100.9:7008/healthcheck
```
### **Issue: Containers ไม่แสดงใน Agent**
**สาเหตุ:**
- Docker socket ไม่ mount
- Agent ไม่มี permission
**วิธีแก้:**
```bash
# ตรวจสอบ volume mount
docker inspect dozzle-agent-airbyte | grep docker.sock
# ตรวจสอบ permissions
ls -la /var/run/docker.sock
# Restart agent
docker restart dozzle-agent-airbyte
```
## 🔐 Security Considerations
### **1. Network Security**
**ใช้ Internal Network (แนะนำ):**
```yaml
# Agent ไม่ expose port ออกภายนอก
# ใช้ Docker network แทน
dozzle-agent:
# ไม่ต้องมี ports section
networks:
- shared_data_network
```
**Main UI เชื่อมต่อผ่าน network:**
```yaml
DOZZLE_REMOTE_AGENT=dozzle-agent-airbyte:7007,dozzle-agent-airflow:7007
```
### **2. Firewall Rules**
```bash
# อนุญาตเฉพาะ Main Server
sudo ufw allow from <main-server-ip> to any port 7007
sudo ufw allow from <main-server-ip> to any port 7008
```
### **3. Authentication**
**Enable simple auth:**
```yaml
DOZZLE_AUTH_PROVIDER: simple
```
สร้าง `01-infra/data/dozzle/users.yml`:
```yaml
users:
- name: admin
username: admin
password: $2a$10$...
email: admin@sriphat.com
```
### **4. Read-only Docker Socket**
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
```
## 📊 Monitoring
### **Health Checks**
```bash
# Main UI
curl http://localhost:9999/dozzle/healthcheck
# Airbyte Agent
curl http://192.168.100.9:7007/healthcheck
# Airflow Agent
curl http://192.168.100.9:7008/healthcheck
```
### **Logs**
```bash
# Main UI
docker logs dozzle -f
# Agents
docker logs dozzle-agent-airbyte -f
docker logs dozzle-agent-airflow -f
```
## 🎯 Best Practices
1. **ใช้ Internal Network** - ไม่ expose agent ports ออกภายนอก
2. **Enable Authentication** - ใช้ simple auth หรือ forward proxy
3. **Monitor Agent Health** - ตั้ง healthcheck และ alerting
4. **Backup Configuration** - backup `users.yml` และ `.env` files
5. **Update Regularly** - อัพเดท Dozzle image เป็นประจำ
6. **Use HTTPS** - ใช้ SSL/TLS สำหรับ production
7. **Limit Access** - ใช้ firewall และ access lists
## 📚 References
- [Dozzle Documentation](https://dozzle.dev/)
- [Agent Mode Guide](https://dozzle.dev/guide/agent)
- [Authentication Guide](https://dozzle.dev/guide/authentication)
- [Remote Hosts Guide](https://dozzle.dev/guide/remote-hosts)
## 🔄 Maintenance
### **Update Dozzle**
```bash
# Main UI
cd 01-infra
docker compose pull dozzle
docker compose up -d dozzle
# Agents
docker pull amir20/dozzle:latest
docker restart dozzle-agent-airbyte
docker restart dozzle-agent-airflow
```
### **Backup Configuration**
```bash
# Backup .env
cp .env.global .env.global.backup
# Backup users.yml (if using auth)
cp 01-infra/data/dozzle/users.yml users.yml.backup
```
## 🎉 Summary
**ตอนนี้คุณมี:**
- ✅ Dozzle Main UI บน Main Server
- ✅ Dozzle Agent บน Airbyte Host (192.168.100.9:7007)
- ✅ Dozzle Agent บน Airflow Host (192.168.100.9:7008)
- ✅ Nginx reverse proxy สำหรับ `/dozzle` subpath
- ✅ Multi-host monitoring ผ่าน single UI
- ✅ Real-time log streaming จากทุก hosts
**เข้าใช้งานที่:**
```
http://ai.sriphat.com/dozzle
```
**Features:**
- Monitor logs จาก Main Server, Airbyte, และ Airflow
- Real-time streaming
- Container stats
- Interactive shell
- Search และ filter

View File

@@ -0,0 +1,112 @@
---
tags:
- project/sriphat
- dataplatform
- infrastructure
created: 2026-05-07
status: active
project: 2026-SRI-PJ-001
---
# Sriphat Data Platform — Project Overview
## ข้อมูลโครงการ
| รายการ | รายละเอียด |
|--------|-----------|
| **โครงการ** | Sriphat AI Transformation Data Platform |
| **รหัสโครงการ** | 2026-SRI-PJ-001 |
| **องค์กร** | โรงพยาบาลศรีพัฒน์ (Sriphat Hospital) |
| **Domain** | `ai.sriphat.com` / `sriphat.local` |
| **Server IP** | `192.168.100.9` |
| **Timezone** | Asia/Bangkok (UTC+7) |
## วัตถุประสงค์
สร้างระบบ **Modern Data Stack** สำหรับโรงพยาบาลศรีพัฒน์ โดยเน้น:
- **Security** — ระบบยืนยันตัวตนกลาง (SSO) ผ่าน Keycloak
- **Versatility** — รองรับข้อมูลหลายรูปแบบ (SQL Server, Oracle, REST API, Excel)
- **Single Sign-On** — ผู้ใช้ล็อกอินครั้งเดียวเข้าได้ทุก service
## Architecture Overview
```
┌──────────────────────────────────────────────────────────┐
│ Nginx Reverse Proxy │
│ (Gateway + SSL + Subpath Routing) │
│ ai.sriphat.com │
└──────────────────────────────────────────────────────────┘
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌────▼────────┐
│ Keycloak │ │ API Service │ │ Superset │
│ (SSO) │ │ (FastAPI) │ │ (BI) │
│ /keycloak │ │ /apiservice │ │ /superset │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
└────────────────┼───────────────┘
┌─────────────┼──────────────┐
│ │ │
┌─────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
│PostgreSQL│ │ Supabase │ │ MinIO │
│(Infra DB)│ │(BaaS/API) │ │(S3 Store)│
└──────────┘ └───────────┘ └──────────┘
┌──────▼──────┐
│ Airflow │
│ (Workflow) │
└─────────────┘
```
## Tech Stack (Layer Map)
| Layer | Tool | หน้าที่ |
|-------|------|--------|
| **Gateway** | Nginx | จัดการ Domain, SSL, Subpath routing |
| **Identity (SSO)** | Keycloak 23.0 | ยืนยันตัวตนกลาง (OIDC/OAuth2), รองรับ LDAP/AD |
| **Backend API** | FastAPI (Python) | Custom API endpoints, API Key management |
| **Database (Infra)** | PostgreSQL 15 | ฐานข้อมูลหลักสำหรับ Keycloak, API Service |
| **BaaS** | Supabase | PostgreSQL + Auth + Realtime + Storage + Edge Functions |
| **Workflow** | Apache Airflow 3.1.5 | DAG-based workflow orchestration (CeleryExecutor) |
| **Ingestion** | Airbyte | ETL จาก HIS, Oracle, REST API (ปัจจุบัน commented out) |
| **Analytics** | Apache Superset | Business Intelligence Dashboard |
| **Object Storage** | MinIO | S3-compatible storage, รองรับ ML/AI workflows |
| **Cache/Queue** | Redis 7.2 | Celery broker สำหรับ Airflow |
| **Monitoring** | Dozzle | Docker container log viewer |
## Docker Network
ทุก service ใช้ network ร่วมกันชื่อ `shared_data_network` (external)
```bash
docker network create shared_data_network
```
## Service Ports (Quick Reference)
| Service | Container Port | Host Port | URL |
|---------|---------------|-----------|-----|
| Nginx Proxy | 80 | 8020 | `http://localhost:8020` |
| Keycloak | 8080 | 8085 | `http://localhost:8085/keycloak` |
| PostgreSQL (Infra) | 5432 | 5435 | internal |
| Supabase Studio | 3000 | 3010 | `http://localhost:3010` |
| Supabase Kong API | 8000 | 8100 | `http://localhost:8100` |
| Supabase DB | 5432 | 5434 | internal |
| Supabase Pooler | 6543 | 6544 | internal |
| API Service | 8040 | 8040 | `http://localhost:8040/apiservice` |
| Airflow API Server | 8080 | 8200 | `http://localhost:8200` |
| Superset | 8088 | 8088 | `http://localhost:8088` |
| MinIO API | 9000 | 9000 | `http://localhost:9000` |
| MinIO Console | 9001 | 9001 | `http://localhost:9001` |
| Dozzle | 8080 | 9999 | `http://localhost:9999/dozzle` |
## Related Documents
- [[01-Infrastructure]] — Nginx, Keycloak, PostgreSQL, Redis, Dozzle
- [[02-Supabase]] — BaaS layer (PostgreSQL + Auth + Realtime + Storage)
- [[03-API-Service]] — FastAPI custom endpoints
- [[04-Airflow]] — Workflow orchestration
- [[05-Analytics-Superset]] — BI Dashboard
- [[06-MinIO]] — Object Storage
- [[07-Security-Strategy]] — Security model และ SSO
- [[08-Operations-Runbook]] — Deploy, Backup, Troubleshoot

View File

@@ -0,0 +1,202 @@
---
tags:
- project/sriphat
- infrastructure
- nginx
- keycloak
- postgresql
created: 2026-05-07
status: active
folder: 01-infra
---
# Infrastructure Layer (01-infra)
> **Docker Compose:** `01-infra/docker-compose.yml`
> **Env File:** `.env.global`
## Services ใน Layer นี้
| Container | Image | Port | หน้าที่ |
|-----------|-------|------|--------|
| `nginx-proxy-manager` | nginx:latest | `8020:80` | Reverse proxy + Subpath routing |
| `keycloak` | quay.io/keycloak/keycloak:23.0 | `8085:8080` | SSO / Identity Provider |
| `postgres` | postgres:15-alpine | `5435:5432` | ฐานข้อมูลหลัก (Keycloak + API Service) |
| `redis` | redis:7.2-bookworm | internal | Cache / Message broker สำหรับ Airflow |
| `dozzle` | amir20/dozzle:latest | `9999:8080` | Docker log monitoring |
---
## Nginx Proxy Manager
**Image:** `nginx:latest`
### Subpath Routing Table
| Service | Subpath | Backend |
|---------|---------|---------|
| API Service | `/apiservice` | `apiservice:8040` |
| Supabase Studio | `/supabase` | `sdp-studio:3000` |
| Supabase Kong API | `/supabase-api` | `sdp-kong:8000` |
| Keycloak | `/keycloak` | `keycloak:8080` |
| Superset | `/superset` | `superset:8088` |
| Airflow | `/airflow` | `airflow-apiserver:8080` |
| Dozzle | `/dozzle` | `dozzle:8080` |
| MinIO API | `/minio` | `minio:9000` |
| MinIO Console | `/minio-console` | `minio:9001` |
**Config directory:** `01-infra/nginx-configs/`
### การตั้งค่า Nginx
```nginx
# เพิ่ม config ผ่าน Custom Nginx Configuration ใน Proxy Host
# หรือ mount file ไปที่ /etc/nginx/conf.d/default.conf
```
---
## Keycloak (SSO)
**Image:** `quay.io/keycloak/keycloak:23.0`
**URL:** `http://localhost:8085/keycloak` หรือ `https://ai.sriphat.com/keycloak`
### Configuration
```yaml
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${KEYCLOAK_DB_NAME}
KC_HTTP_RELATIVE_PATH: "/keycloak"
KC_HOSTNAME_PATH: "/keycloak"
KC_PROXY: edge
KC_HTTP_ENABLED: "true"
```
### Keycloak SSO Clients ที่ต้องสร้าง
| Client ID | Service | Protocol |
|-----------|---------|---------|
| `apiservice` | API Service | OIDC |
| `superset-client` | Apache Superset | OIDC |
| `minio-client` | MinIO | OIDC |
| `airflow-client` | Apache Airflow | OIDC |
### ขั้นตอนตั้งค่า Keycloak หลัง Deploy
1. เข้า Admin Console: `/keycloak/admin`
2. สร้าง Realm: `sriphat`
3. สร้าง Clients สำหรับแต่ละ service
4. เชื่อมต่อ LDAP/AD ของโรงพยาบาล (optional)
5. สร้าง Groups และ Roles
6. Map roles ให้กับ users
---
## PostgreSQL (Infra DB)
**Image:** `postgres:15-alpine`
**Port:** `5435` (host) → `5432` (container)
### Databases ใน PostgreSQL นี้
| Database | เจ้าของ |
|----------|--------|
| `postgres` | Default + API Service |
| `keycloak` | Keycloak |
| `superset` | Apache Superset |
| `airflow` | Apache Airflow |
### Init Scripts
**Path:** `01-infra/init/`
| File | หน้าที่ |
|------|--------|
| `00-create-keycloak-database.sql` | สร้าง database สำหรับ Keycloak |
| `03-create-airflow-databases.sql` | สร้าง database สำหรับ Airflow |
### Connection String
```
Host: postgres (internal) / 192.168.100.9 (external)
Port: 5432 (internal) / 5435 (external)
User: ${DB_USER}
Password: ${DB_PASSWORD}
Database: postgres
```
---
## Redis
**Image:** `redis:7.2-bookworm`
**Port:** `6379` (internal only)
ใช้เป็น:
- Celery broker สำหรับ Apache Airflow
- Message queue
```
URL: redis://:@redis:6379/0
```
---
## Dozzle (Log Monitoring)
**Image:** `amir20/dozzle:latest`
**URL:** `http://localhost:9999/dozzle` หรือ `https://ai.sriphat.com/dozzle`
### Features
- ดู Docker container logs แบบ real-time
- รองรับ Remote Agent (เชื่อมต่อ server อื่น)
- Filter และ search logs
### Remote Agent Configuration
```bash
# ใน .env.global
DOZZLE_REMOTE_AGENT=192.168.100.9:7007
```
Server ที่ monitor:
- Main server (local)
- `192.168.100.9` — Airflow + MinIO + OpenMetadata server
**Setup Guide:** `REMOTE_HOSTS_DOZZLE_SETUP.md` — คู่มือตั้งค่า Dozzle agent บน remote server
---
## Environment Variables (.env.global)
```bash
# Project
PROJECT_NAME=sriphat-data
DOMAIN=sriphat.local
TZ=Asia/Bangkok
# Database
DB_HOST=postgres
DB_PORT=5432
DB_PORT_EXPOSE=5435
DB_USER=postgres
DB_PASSWORD=<secret>
DB_NAME=postgres
# Keycloak
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=<secret>
KEYCLOAK_DB_NAME=keycloak
# Dozzle
DOZZLE_PORT=9999
DOZZLE_BASE=/dozzle
DOZZLE_HOSTNAME=Sriphat Main Server
DOZZLE_REMOTE_AGENT=192.168.100.9:7007
```
## Related
- [[00-Project-Overview]]
- [[07-Security-Strategy]]
- [[08-Operations-Runbook]]

View File

@@ -0,0 +1,232 @@
---
tags:
- project/sriphat
- supabase
- postgresql
- baas
created: 2026-05-07
status: active
folder: 02-supabase
---
# Supabase Layer (02-supabase)
> **Docker Compose:** `02-supabase/docker-compose.yml`
> **Env File:** `02-supabase/.env`
Supabase เป็น Backend-as-a-Service (BaaS) แบบ self-hosted ที่รวม PostgreSQL, Auth, Realtime, Storage และ Edge Functions ไว้ในที่เดียว
## Services
| Container | Image | Port | หน้าที่ |
|-----------|-------|------|--------|
| `sdp-supabase-studio` | supabase/studio:2026.02.16 | `3010:3000` | Web UI สำหรับจัดการ database |
| `sdp-supabase-kong` | kong:2.8.1 | `8100:8000`, `8444:8443` | API Gateway (routing ทุก request) |
| `sdp-supabase-auth` | supabase/gotrue:v2.186.0 | internal | Authentication service |
| `sdp-supabase-rest` | postgrest/postgrest:v12.2.3 | internal | Auto-generated REST API จาก PostgreSQL |
| `sdp-realtime-dev` | supabase/realtime:v2.76.5 | internal | WebSocket realtime subscriptions |
| `sdp-supabase-storage` | supabase/storage-api:v1.37.8 | internal | File storage |
| `sdp-supabase-imgproxy` | darthsim/imgproxy:v3.30.1 | internal | Image transformation |
| `sdp-supabase-meta` | supabase/postgres-meta:v0.95.2 | internal | PostgreSQL metadata API |
| `sdp-supabase-edge-functions` | supabase/edge-runtime:v1.70.3 | internal | Deno edge functions |
| `sdp-supabase-analytics` | supabase/logflare:1.31.2 | internal | Log analytics (Logflare) |
| `sdp-supabase-db` | supabase/postgres:15.8.1.085 | `5434:5432` | PostgreSQL database หลัก |
| `sdp-supabase-vector` | timberio/vector:0.53.0-alpine | internal | Log collector |
| `sdp-supabase-pooler` | supabase/supavisor:2.7.4 | `6544:6543` | Connection pooler (PgBouncer-like) |
---
## สถาปัตยกรรม Supabase
```
Client / API Service
sdp-supabase-kong (API Gateway: port 8100)
┌────┼────────────────────┐
│ │ │
▼ ▼ ▼
Auth REST API Realtime
GoTrue PostgREST Supabase Realtime
│ │ │
└────┴────────────────────┘
sdp-supabase-db (PostgreSQL 15)
sdp-supabase-pooler
(Supavisor: port 6544)
```
---
## PostgreSQL Database (sdp-supabase-db)
**Image:** `supabase/postgres:15.8.1.085`
**Port:** `5434` (host)
### Init SQL Files
| File | หน้าที่ |
|------|--------|
| `volumes/db/realtime.sql` | Setup replication สำหรับ Realtime |
| `volumes/db/webhooks.sql` | Database webhook functions |
| `volumes/db/roles.sql` | PostgreSQL roles setup |
| `volumes/db/jwt.sql` | JWT helper functions |
| `volumes/db/_supabase.sql` | Internal Supabase schema |
| `volumes/db/logs.sql` | Logging tables |
| `volumes/db/pooler.sql` | Connection pooler config |
### Connection Strings
```
# Direct Connection
postgresql://postgres:<password>@sdp-supabase-db:5432/postgres
# Via Pooler (Transaction mode)
postgresql://postgres.tenant:<password>@sdp-supabase-pooler:6543/postgres
# External (from host)
postgresql://postgres:<password>@192.168.100.9:5434/postgres
```
---
## Kong API Gateway
**Image:** `kong:2.8.1`
**Port:** `8100` (HTTP), `8444` (HTTPS)
Kong ทำหน้าที่ route requests ไปยัง services ต่างๆ:
```
/auth/v1/* → sdp-supabase-auth (GoTrue)
/rest/v1/* → sdp-supabase-rest (PostgREST)
/realtime/v1/ → sdp-realtime (WebSocket)
/storage/v1/* → sdp-supabase-storage
/functions/v1/*→ sdp-supabase-edge-functions
/meta/* → sdp-supabase-meta
```
**Kong Config:** `volumes/api/kong.yml`
---
## Supavisor (Connection Pooler)
**Image:** `supabase/supavisor:2.7.4`
**Port:** `6544` (transaction mode pooler)
```bash
# Transaction mode (ใช้สำหรับ serverless/edge functions)
postgresql://postgres.sriphat:<password>@sdp-supabase-pooler:6543/postgres
POOLER_TENANT_ID: sriphat
POOLER_DEFAULT_POOL_SIZE: <from env>
POOLER_MAX_CLIENT_CONN: <from env>
```
---
## Authentication (GoTrue)
**Image:** `supabase/gotrue:v2.186.0`
### Features ที่เปิดใช้
| Feature | ค่า |
|---------|-----|
| Email Signup | `${ENABLE_EMAIL_SIGNUP}` |
| Anonymous Users | `${ENABLE_ANONYMOUS_USERS}` |
| Email Autoconfirm | `${ENABLE_EMAIL_AUTOCONFIRM}` |
| Phone Signup | `${ENABLE_PHONE_SIGNUP}` |
### JWT Configuration
```
JWT_SECRET: <จาก env>
JWT_EXPIRY: <จาก env>
JWT_AUD: authenticated
JWT_DEFAULT_GROUP: authenticated
```
---
## Storage
**Image:** `supabase/storage-api:v1.37.8`
**Data path:** `volumes/storage/`
```
FILE_SIZE_LIMIT: 52428800 (50MB)
STORAGE_BACKEND: file
ENABLE_IMAGE_TRANSFORMATION: true
```
---
## Supabase Studio
**URL:** `http://localhost:3010` หรือ `https://ai.sriphat.com/supabase`
**Image:** `supabase/studio:2026.02.16-sha-26c615c`
Studio เชื่อมต่อผ่าน:
- PostgreSQL Meta API (`sdp-meta:8080`)
- Kong API (`sdp-kong:8000`)
- Logflare (`sdp-analytics:4000`)
**Snippets path:** `volumes/snippets/`
**Functions path:** `volumes/functions/`
---
## Environment Variables (สำคัญ)
```bash
# PostgreSQL
POSTGRES_HOST=sdp-supabase-db
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_PASSWORD=<secret>
# JWT
JWT_SECRET=<long-random-string>
JWT_EXPIRY=3600
# API Keys
ANON_KEY=<jwt-anon-key>
SERVICE_ROLE_KEY=<jwt-service-role-key>
# Studio
STUDIO_DEFAULT_ORGANIZATION=Sriphat Hospital
STUDIO_DEFAULT_PROJECT=DataPlatform
SUPABASE_PUBLIC_URL=https://ai.sriphat.com/supabase-api
# Logflare
LOGFLARE_PUBLIC_ACCESS_TOKEN=<token>
LOGFLARE_PRIVATE_ACCESS_TOKEN=<token>
```
---
## การใช้งาน Supabase จาก API Service
```python
# ใน 03-apiservice
SUPABASE_DB_HOST=sdp-supabase-db
SUPABASE_DB_PORT=5432
SUPABASE_DB_USER=postgres.1
SUPABASE_DB_NAME=postgres
SUPABASE_API_URL=http://sdp-kong:8000
SUPABASE_API_KEY=<anon-or-service-role-key>
```
---
## Related
- [[00-Project-Overview]]
- [[03-API-Service]]
- [[07-Security-Strategy]]

View File

@@ -0,0 +1,251 @@
---
tags:
- project/sriphat
- apiservice
- fastapi
- python
created: 2026-05-07
status: active
folder: 03-apiservice
---
# API Service (03-apiservice)
> **Docker Compose:** `03-apiservice/docker-compose.yml`
> **Env File:** `03-apiservice/.env`
> **Language:** Python / FastAPI
## Overview
Custom FastAPI service สำหรับ:
- รับข้อมูล Checkpoint จาก HIS (Hospital Information System)
- จัดการ API Keys แบบ permission-based
- Admin UI สำหรับบริหาร API Clients
- รองรับ Keycloak SSO สำหรับหน้าเว็บ Admin
## Container
| รายการ | ค่า |
|--------|-----|
| **Container** | `apiservice` |
| **Image** | `03-apiservice-apiservice:latest` (build local) |
| **Port** | `8040:8040` |
| **URL** | `https://ai.sriphat.com/apiservice` |
| **Health Check** | `http://localhost:8040/apiservice/docs` |
---
## API Endpoints (หลัก)
### Data Feed Endpoints
```
POST /apiservice/api/v1/feed/checkpoint
```
**Payload ตัวอย่าง:**
```json
[
{
"id": 1,
"hn": 123,
"vn": 456,
"location": "OPD",
"type": "Scan",
"timestamp_in": "2026-02-16T10:00:00",
"timestamp_out": null,
"waiting_time": null,
"bu": "SRIPHAT"
}
]
```
**Required Permission:** `feed.checkpoint:write`
### Admin Endpoints
```
GET /apiservice/admin/ # Admin dashboard
POST /apiservice/admin/api-keys/generate # สร้าง API Key ใหม่
GET /apiservice/admin/api-clients # รายการ API Clients
```
### Documentation
```
GET /apiservice/docs # Swagger UI
GET /apiservice/redoc # ReDoc
```
---
## Database Schema
API Service ใช้ PostgreSQL (Infra) และ Supabase:
### Tables (PostgreSQL Infra)
| Table | ใช้สำหรับ |
|-------|---------|
| `fastapi.ApiClient` | ข้อมูล API Client (ระบบที่ขอใช้ API) |
| `fastapi.ApiKey` | API Keys ที่เข้ารหัสแล้ว |
### Tables (Supabase)
| Table | Schema | ใช้สำหรับ |
|-------|--------|---------|
| `RawWaitingTime` | `operationbi` | ข้อมูล waiting time ดิบ |
| `RawOpdCheckpoint` | — | ข้อมูล OPD checkpoint |
---
## Authentication
### 1. API Key Authentication (สำหรับ System Integration)
```bash
# Request header
Authorization: Bearer <api-key>
# หรือ query param
?api_key=<api-key>
```
API Key สร้างได้จาก Admin UI โดยกำหนด permissions:
- `feed.checkpoint:write` — บันทึกข้อมูล checkpoint
- (สามารถเพิ่ม permissions เพิ่มเติมได้)
### 2. Keycloak SSO (สำหรับ Admin Web UI)
```bash
# Environment variables
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=apiservice
KEYCLOAK_CLIENT_SECRET=<secret>
KEYCLOAK_REDIRECT_URI=http://localhost:8040/apiservice/auth/callback
```
---
## File Structure
```
03-apiservice/
├── app/
│ ├── api/v1/
│ │ ├── routes.py # API endpoints
│ │ └── schemas.py # Pydantic schemas
│ ├── core/
│ │ └── config.py # Settings / Config
│ ├── db/
│ │ ├── models.py # SQLAlchemy models
│ │ ├── init_db.py # Database initialization
│ │ └── session.py # DB session
│ ├── middleware/ # Custom middleware
│ ├── models/ # Additional models
│ ├── routes/ # Additional routes
│ ├── security/
│ │ ├── api_key.py # API Key handling
│ │ ├── keycloak_auth.py # Keycloak integration
│ │ ├── permissions.py # Permission system
│ │ └── dependencies.py # FastAPI dependencies
│ ├── services/ # Business logic
│ ├── templates/ # HTML templates (Admin UI)
│ └── utils/
│ └── supabase_client.py
├── data/uploads/ # File uploads
├── docker-compose.yml
├── requirements.txt
└── .env
```
---
## Environment Variables
```bash
# Application
APP_NAME=APIsService
ROOT_PATH=/apiservice
TIMEZONE=Asia/Bangkok
# PostgreSQL (Infra DB)
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=<secret>
DB_NAME=postgres
DB_SSLMODE=prefer
# Supabase DB (สำหรับ RawOpdCheckpoint)
SUPABASE_DB_HOST=sdp-supabase-db
SUPABASE_DB_PORT=5432
SUPABASE_DB_USER=postgres.1
SUPABASE_DB_NAME=postgres
# Supabase API
SUPABASE_API_URL=http://sdp-kong:8000
SUPABASE_API_KEY=<anon-or-service-role-key>
# Admin
ADMIN_SECRET_KEY=<secret>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<secret>
API_KEY_ENC_SECRET=<encryption-key>
# Keycloak
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=apiservice
KEYCLOAK_CLIENT_SECRET=<secret>
KEYCLOAK_REDIRECT_URI=<redirect-url>
# Airflow Integration
AIRFLOW_API_URL=http://airflow-webserver:8080
AIRFLOW_API_TOKEN=<token>
AIRFLOW_DAG_ID_FINANCE=process_finance_excel
# Debug
DEBUG_AUTH=false
LOG_LEVEL=debug
```
---
## Build & Deploy
```bash
# Build image
cd 03-apiservice
docker compose --env-file ../.env.global build
# Start service
docker compose --env-file ../.env.global up -d
# View logs
docker logs apiservice -f
# Restart
docker restart apiservice
```
---
## Airflow Integration
API Service มี integration กับ Apache Airflow:
- ส่ง trigger ไปยัง Airflow DAG
- DAG `process_finance_excel` สำหรับประมวลผล Excel files
ดูรายละเอียดที่ `03-apiservice/AIRFLOW_INTEGRATION.md`
---
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]]
- [[02-Supabase]]
- [[04-Airflow]]
- [[07-Security-Strategy]]

View File

@@ -0,0 +1,237 @@
---
tags:
- project/sriphat
- airflow
- workflow
- etl
created: 2026-05-07
status: active
folder: 05-airflow
---
# Apache Airflow (05-airflow)
> **Docker Compose:** `05-airflow/docker-compose.yaml`
> **Env File:** `05-airflow/.env`
> **Version:** Apache Airflow 3.1.5
## Overview
Apache Airflow ใช้สำหรับ Workflow Orchestration:
- รัน DAGs (Directed Acyclic Graphs) แบบตั้งเวลา
- ประมวลผล Excel/CSV files จาก Finance
- ETL pipeline orchestration
- Integration กับ API Service
**Executor:** CeleryExecutor (ใช้ Redis เป็น broker)
---
## Services
| Container | หน้าที่ | Port |
|-----------|--------|------|
| `airflow-apiserver` | REST API + Web UI | `8200:8080` |
| `airflow-scheduler` | DAG scheduling | internal |
| `airflow-dag-processor` | DAG file parsing | internal |
| `airflow-worker` | Task execution (Celery) | internal |
| `airflow-triggerer` | Deferred task triggering | internal |
| `airflow-init` | Database migration (one-time) | — |
| `airflow-cli` | CLI tool (debug profile) | — |
| `flower` | Celery monitoring (optional) | `5555:5555` |
---
## Architecture
```
┌─────────────────┐
│ airflow- │
│ apiserver │ ← Web UI + REST API (port 8200)
│ (port 8080) │
└────────┬────────┘
┌───────────────┼───────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐
│ airflow- │ │ airflow- │ │ airflow- │
│ scheduler │ │ dag- │ │ triggerer │
│ │ │ processor │ │ │
└──────┬──────┘ └─────────────┘ └────────────┘
▼ (Celery tasks via Redis)
┌──────────────┐
│ airflow- │
│ worker │ ← รัน tasks จริง
└──────────────┘
┌──────────────┐
│ PostgreSQL │ (Airflow metadata DB)
│ Redis │ (Celery broker)
└──────────────┘
```
---
## Database Configuration
Airflow ใช้ PostgreSQL บน Infra server:
```bash
# Connection string
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=
postgresql+psycopg2://${AIRFLOW_DB_USER}:${AIRFLOW_DB_PASSWD}@${AIRFLOW_DB_HOST}:${AIRFLOW_DB_PORT}/${AIRFLOW_DB_NAME}
AIRFLOW__CELERY__RESULT_BACKEND=
db+postgresql://${AIRFLOW_DB_USER}:${AIRFLOW_DB_PASSWD}@${AIRFLOW_DB_HOST}:${AIRFLOW_DB_PORT}/${AIRFLOW_DB_NAME}
# Redis broker
AIRFLOW__CELERY__BROKER_URL=redis://:@redis:6379/0
```
---
## Volume Mounts
```
05-airflow/
├── dags/ → /opt/airflow/dags (DAG files)
├── logs/ → /opt/airflow/logs (Task logs)
├── config/ → /opt/airflow/config (airflow.cfg)
│ └── airflow.cfg
└── plugins/ → /opt/airflow/plugins (Custom plugins)
```
---
## Web UI
**URL:** `http://localhost:8200` หรือ `https://ai.sriphat.com/airflow`
```bash
# Config
AIRFLOW__WEBSERVER__BASE_URL=https://ai.sriphat.com/airflow
AIRFLOW__WEBSERVER__WEB_SERVER_PORT=8080
```
Default credentials (ถ้าไม่เปลี่ยน):
- Username: `airflow`
- Password: `airflow`
---
## DAGs ที่มีอยู่
| DAG ID | หน้าที่ | ถูก Trigger จาก |
|--------|--------|----------------|
| `process_finance_excel` | ประมวลผล Excel ของ Finance | API Service |
---
## Airflow Configuration (airflow.cfg)
**Path:** `05-airflow/config/airflow.cfg`
Key settings:
```ini
[core]
executor = CeleryExecutor
load_examples = False
dags_are_paused_at_creation = True
[webserver]
base_url = https://ai.sriphat.com/airflow
[execution_api]
execution_api_server_url = http://airflow-apiserver:8080/execution/
```
---
## Environment Variables
```bash
# Airflow image
AIRFLOW_IMAGE_NAME=apache/airflow:3.1.5
# Database
AIRFLOW_DB_USER=<user>
AIRFLOW_DB_PASSWD=<password>
AIRFLOW_DB_HOST=<postgres-host>
AIRFLOW_DB_PORT=5432
AIRFLOW_DB_NAME=airflow
# Security
AIRFLOW__CORE__FERNET_KEY=<fernet-key>
# Admin user
_AIRFLOW_WWW_USER_USERNAME=airflow
_AIRFLOW_WWW_USER_PASSWORD=<password>
# Optional pip packages
_PIP_ADDITIONAL_REQUIREMENTS=
```
---
## Deploy Commands
```bash
cd 05-airflow
# Initialize (first time only)
docker compose up airflow-init
# Start all services
docker compose up -d
# View logs
docker logs airflow-apiserver -f
docker logs airflow-scheduler -f
docker logs airflow-worker -f
# Run Celery Flower monitoring
docker compose --profile flower up -d
# Scale workers (เพิ่ม worker)
docker compose up -d --scale airflow-worker=3
```
---
## System Requirements
Airflow ต้องการ resources ขั้นต่ำ:
- **RAM:** ≥ 4 GB
- **CPU:** ≥ 2 cores
- **Disk:** ≥ 10 GB
---
## Ingestion Layer (04-ingestion / Airbyte)
> **หมายเหตุ:** `04-ingestion/docker-compose.yml` ปัจจุบัน **commented out ทั้งหมด**
> Airbyte ถูก deploy แยกต่างหาก (ผ่าน `abctl` หรือ standalone)
### Airbyte ที่ระบุในแผน
| Source | ชนิดข้อมูล |
|--------|----------|
| SQL Server (HIS) | ข้อมูลผู้ป่วย, OPD |
| Oracle (Lab) | ผลตรวจทางห้องปฏิบัติการ |
| REST API | External data |
| Excel/CSV | Finance, รายงาน |
**Destination:** PostgreSQL `raw_data` schema
**Port:** `8030` (เมื่อ deploy แล้ว)
---
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]]
- [[03-API-Service]]
- [[08-Operations-Runbook]]

View File

@@ -0,0 +1,243 @@
---
tags:
- project/sriphat
- superset
- analytics
- bi
- dashboard
created: 2026-05-07
status: active
folder: 06-analytics
---
# Apache Superset — Analytics Layer (06-analytics)
> **Docker Compose:** `06-analytics/docker-compose.yml`
> **Env File:** `.env` (global)
## Overview
Apache Superset ใช้เป็น Business Intelligence (BI) platform สำหรับ:
- สร้าง Dashboard และ Visualization
- เชื่อมต่อกับ PostgreSQL Data Warehouse
- **Embedded Superset SDK** — embed dashboard ใน applications อื่นโดยไม่ต้อง login
- สร้าง Report สำหรับผู้บริหารและแพทย์
> **SSO Keycloak:** ยังอยู่ในแผน ยังไม่ได้ implement
---
## Container
| รายการ | ค่า |
|--------|-----|
| **Container** | `superset` |
| **Image** | Build จาก `Dockerfile` ใน `06-analytics/` |
| **Port** | `8088:8088` |
| **URL** | `http://localhost:8088` หรือ `https://ai.sriphat.com/superset` |
| **Network** | `shared_data_network` |
---
## Configuration
### Database Connection (Superset metadata)
Superset เก็บ metadata ใน PostgreSQL (Infra):
```
Database: superset
Host: ${DB_HOST}
Port: 5432
User: ${DB_USER}
Password: ${DB_PASSWORD}
```
### Superset Config File
**Path:** `06-analytics/superset_config.py`
```python
SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY')
ENABLE_PROXY_FIX = True
PUBLIC_ROLE_LIKE = "Gamma"
GUEST_ROLE_NAME = "Gamma"
# CSRF
WTF_CSRF_ENABLED = False
WTF_CSRF_TIME_LIMIT = None
# CORS — อนุญาตทุก origin (ปรับ production ให้ restrictive กว่านี้)
ENABLE_CORS = True
CORS_OPTIONS = {
'supports_credentials': True,
'allow_headers': ['*'],
'resources': ['*'],
'origins': ['*']
}
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = False
# Embedded Superset SDK
FEATURE_FLAGS = {"EMBEDDED_SUPERSET": True}
EMBEDDED_SUPERSET = True
TALISMAN_ENABLED = False
ENABLE_TEMPLATE_PROCESSING = True
LOGO_TARGET_PATH = '/superset/welcome/'
# Guest Token (สำหรับ embedded dashboard ไม่ต้อง login)
GUEST_TOKEN_JWT_SECRET = '<secret>'
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 นาที
GUEST_TOKEN_JWT_ALGORITHM = "HS256"
# Domain whitelist สำหรับ embed
EMBEDDED_SDK_HOST_WHITELIST = [
"https://ai.sriphat.com",
"http://localhost:8800",
"http://127.0.0.1:8800"
]
```
> **หมายเหตุ:** Keycloak SSO ยังไม่ได้ integrate — ปัจจุบันใช้ Username/Password login + Embedded SDK สำหรับ embed dashboard ใน applications อื่น
### Environment Variables
```bash
SUPERSET_SECRET_KEY=${SUPERSET_SECRET_KEY}
DATABASE_DIALECT=postgresql
DATABASE_HOST=${DB_HOST}
DATABASE_PORT=5432
DATABASE_DB=superset
DATABASE_USER=${DB_USER}
DATABASE_PASSWORD=${DB_PASSWORD}
SUPERSET_LOAD_EXAMPLES=no
SUPERSET_BIND_ADDRESS=0.0.0.0
SUPERSET_PORT=8088
TZ=Asia/Bangkok
```
---
## Startup Process
เมื่อ container เริ่มทำงาน จะรันคำสั่งต่อไปนี้โดยอัตโนมัติ:
```bash
# 1. Migrate database
superset db upgrade
# 2. Create admin user
superset fab create-admin \
--username ${SUPERSET_ADMIN_USERNAME} \
--firstname Admin \
--lastname User \
--email admin@sriphat.local \
--password ${SUPERSET_ADMIN_PASSWORD}
# 3. Initialize Superset
superset init
# 4. Start Gunicorn server
gunicorn --bind 0.0.0.0:8088 \
--workers 4 \
--timeout 120 \
'superset.app:create_app()'
```
---
## Data Sources ที่ Connect ได้
### PostgreSQL Data Warehouse (Infra)
```
postgresql://postgres:<password>@postgres:5432/postgres
```
**Schemas ที่แนะนำให้ expose:**
- `analytics` — ข้อมูลที่ transform แล้ว (read-only สำหรับ BI)
- `operationbi` — ข้อมูล Operation BI
### Supabase PostgreSQL
```
postgresql://postgres:<password>@sdp-supabase-db:5432/postgres
```
---
## Dashboard ที่ควรสร้าง
| Dashboard | ข้อมูล | ผู้ใช้ |
|-----------|--------|--------|
| OPD Waiting Time | `RawWaitingTime`, `RawOpdCheckpoint` | ผู้บริหาร, พยาบาล |
| Patient Flow | HIS data จาก Airbyte | แพทย์, ผู้บริหาร |
| Finance Overview | Excel จาก Finance | CFO, ผู้บริหาร |
| Department KPIs | Aggregated data | หัวหน้าแผนก |
---
## Security (Row-Level Security)
ตั้งค่า RLS ใน Superset เพื่อจำกัดข้อมูล:
```sql
-- ตัวอย่าง: แพทย์เห็นเฉพาะผู้ป่วยของตัวเอง
-- ใน Superset: Security → Row Level Security → Add Rule
-- Filter: department = '{{current_username}}'
```
---
## Volume Mounts
```
06-analytics/
├── data/superset_home/ # Superset config + cache
└── superset_config.py # Custom configuration
```
---
## Build & Deploy
```bash
cd 06-analytics
# Build image (มี custom Dockerfile สำหรับเพิ่ม packages)
docker compose --env-file ../.env.global build
# Start
docker compose --env-file ../.env.global up -d
# View logs
docker logs superset -f
```
---
## Development Version (06-analytics-dev)
มี docker-compose สำหรับ development แยกต่างหาก:
- **Path:** `06-analytics-dev/docker-compose.yml`
- ใช้สำหรับทดสอบ config ก่อน deploy production
---
## Access
| รายการ | ค่า |
|--------|-----|
| URL | `http://localhost:8088` |
| Admin Username | ค่าจาก `SUPERSET_ADMIN_USERNAME` |
| Admin Password | ค่าจาก `SUPERSET_ADMIN_PASSWORD` |
---
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]] (Keycloak SSO)
- [[02-Supabase]] (Data Source)
- [[07-Security-Strategy]]

View File

@@ -0,0 +1,264 @@
---
tags:
- project/sriphat
- minio
- storage
- s3
created: 2026-05-07
status: active
folder: 07-minio
---
# MinIO Object Storage (07-minio)
> **Docker Compose:** `07-minio/docker-compose.yml`
> **Env File:** `07-minio/.env`
## Overview
MinIO เป็น S3-compatible object storage สำหรับ:
- เก็บ raw data files (CSV, JSON, Parquet)
- เก็บ ML/AI models และ training data
- เก็บ backups และ reports
- Keycloak SSO integration
---
## Container
| รายการ | ค่า |
|--------|-----|
| **Container** | `minio` |
| **Image** | `minio/minio:latest` |
| **API Port** | `9000:9000` |
| **Console Port** | `9001:9001` |
| **Console URL** | `https://ai.sriphat.com/minio-console` |
| **API URL** | `https://ai.sriphat.com/minio` |
| **Direct (Dev)** | `http://192.168.100.9:9001` (console) |
| **Region** | `ap-southeast-1` |
---
## Use Cases
| Use Case | ตัวอย่าง |
|----------|---------|
| **Data Lake** | Raw CSV, JSON, Parquet จาก Airbyte |
| **ML/AI Workflows** | Model files, training datasets, experiment artifacts |
| **Backup Storage** | Database dumps, application backups |
| **Report Files** | Excel, PDF reports จาก Finance |
| **Media Storage** | Images, documents จากระบบ HIS |
| **Application Storage** | File uploads จาก API Service |
---
## Authentication
### 1. Root Credentials (Default)
```bash
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=<strong-password>
```
### 2. Keycloak SSO (แนะนำ)
เชื่อมต่อผ่าน OpenID Connect:
```bash
MINIO_IDENTITY_OPENID_CONFIG_URL=https://ai.sriphat.com/keycloak/realms/sriphat/.well-known/openid-configuration
MINIO_IDENTITY_OPENID_CLIENT_ID=minio-client
MINIO_IDENTITY_OPENID_CLIENT_SECRET=<secret>
MINIO_IDENTITY_OPENID_CLAIM_NAME=policy
MINIO_IDENTITY_OPENID_SCOPES=openid,profile,email
MINIO_IDENTITY_OPENID_REDIRECT_URI=https://ai.sriphat.com/minio-console/oauth_callback
```
**Policy Mapping:** User ใน Keycloak ต้องมี attribute `policy` ที่ map กับ MinIO policy
---
## Environment Variables
```bash
# Credentials
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=<secret>
# URLs
MINIO_SERVER_URL=https://ai.sriphat.com/minio
MINIO_BROWSER_REDIRECT_URL=https://ai.sriphat.com/minio-console
# Region
MINIO_REGION=ap-southeast-1
# Keycloak SSO
MINIO_IDENTITY_OPENID_CONFIG_URL=<keycloak-oidc-url>
MINIO_IDENTITY_OPENID_CLIENT_ID=<client-id>
MINIO_IDENTITY_OPENID_CLIENT_SECRET=<secret>
MINIO_IDENTITY_OPENID_CLAIM_NAME=policy
MINIO_IDENTITY_OPENID_SCOPES=openid,profile,email
MINIO_IDENTITY_OPENID_REDIRECT_URI=<redirect-uri>
TZ=Asia/Bangkok
```
---
## Volume Mounts
```
07-minio/
├── data/ → /data (object storage data)
└── certs/ → /root/.minio/certs:ro (SSL certificates)
```
---
## การใช้งาน MinIO Client (mc)
```bash
# Install
wget https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc && sudo mv mc /usr/local/bin/
# Config alias
mc alias set sriphat https://ai.sriphat.com/minio minioadmin <password>
# List buckets
mc ls sriphat
# Create bucket
mc mb sriphat/raw-data
mc mb sriphat/ml-models
mc mb sriphat/backups
mc mb sriphat/reports
# Upload
mc cp data.csv sriphat/raw-data/
mc cp -r ./models/ sriphat/ml-models/
# Set bucket policy
mc anonymous set none sriphat/raw-data # private
mc anonymous set download sriphat/public # public read
```
---
## Python SDK (boto3)
```python
import boto3
from botocore.client import Config
s3 = boto3.client(
's3',
endpoint_url='https://ai.sriphat.com/minio',
aws_access_key_id='minioadmin',
aws_secret_access_key='<password>',
config=Config(signature_version='s3v4'),
region_name='ap-southeast-1'
)
# Upload file
s3.upload_file('data.csv', 'raw-data', 'data.csv')
# Download file
s3.download_file('raw-data', 'data.csv', 'local-data.csv')
# List objects
for obj in s3.list_objects_v2(Bucket='raw-data').get('Contents', []):
print(obj['Key'])
```
---
## Recommended Bucket Structure
```
sriphat/
├── raw-data/ # ข้อมูลดิบจาก Airbyte / HIS
│ ├── his/
│ ├── oracle-lab/
│ └── finance-excel/
├── processed-data/ # ข้อมูลที่ transform แล้ว
├── ml-models/ # ML/AI model files
│ ├── waiting-time/
│ └── patient-flow/
├── reports/ # Excel, PDF reports
├── backups/ # Database backups
│ └── postgres/
└── uploads/ # User uploads จาก API Service
```
---
## Security
```bash
# สร้าง read-only policy
cat > readonly-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::*"]
}
]
}
EOF
mc admin policy create sriphat readonly-policy readonly-policy.json
# Assign policy ให้ user
mc admin policy attach sriphat readonly-policy --user=analyst-user
```
---
## Health Check
```bash
# ตรวจสอบสถานะ
curl -f http://localhost:9000/minio/health/live
docker exec minio curl -f http://localhost:9000/minio/health/live
```
---
## Backup Strategy
```bash
# Backup data directory
tar -czf minio-backup-$(date +%Y%m%d).tar.gz 07-minio/data/
# Sync to remote
rsync -avz 07-minio/data/ backup-server:/backups/minio/
# Restore
docker compose down
tar -xzf minio-backup-20260501.tar.gz
docker compose up -d
```
---
## Keycloak Setup (สำหรับ SSO)
ดูรายละเอียดที่ `07-minio/KEYCLOAK_INTEGRATION.md`
1. สร้าง Client `minio-client` ใน Keycloak Realm `sriphat`
2. ตั้งค่า Valid Redirect URIs: `https://ai.sriphat.com/minio-console/oauth_callback`
3. สร้าง Client Scope `minio-policy`
4. เพิ่ม User Attribute Mapper `policy`
5. กำหนด `policy` attribute ให้กับ users ตาม MinIO policies
---
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]] (Keycloak SSO)
- [[07-Security-Strategy]]

View File

@@ -0,0 +1,217 @@
---
tags:
- project/sriphat
- security
- sso
- keycloak
- rbac
created: 2026-05-07
status: active
---
# Security Strategy
## หลักการออกแบบ
ระบบออกแบบตามหลัก **Defense in Depth** สำหรับสภาพแวดล้อมโรงพยาบาล:
1. **Network Isolation** — ทุก service อยู่ใน `shared_data_network`
2. **Centralized Authentication** — SSO ผ่าน Keycloak เท่านั้น
3. **Schema Separation** — แยก raw / analytics / production data
4. **Row-Level Security** — PostgreSQL RLS จำกัดข้อมูลต่อ user
5. **API Key Management** — Permission-based API access
---
## Network Security
```
Internet
Nginx Reverse Proxy (port 80/443)
│ ← ทุก request ต้องผ่าน Nginx
shared_data_network (Docker internal)
├── keycloak
├── postgres
├── sdp-supabase-* (12 containers)
├── apiservice
├── superset
├── airflow-*
├── minio
└── redis
```
**Rules:**
- ไม่มี service ใดที่ bind ตรงกับ `0.0.0.0` ยกเว้นผ่าน Nginx
- Redis ไม่ expose port ออกภายนอก
- Supabase services ไม่ expose port ออกภายนอก (ผ่าน Kong เท่านั้น)
---
## Authentication (SSO with Keycloak)
### Flow
```
User
├─ Web UI → Keycloak (OIDC Login) → redirect back with token
└─ API Client → API Key (จาก Admin UI) → Bearer token
```
### Keycloak Realms
| Realm | Services |
|-------|---------|
| `master` | Admin (ปิด public access) |
| `sriphat` | Superset, MinIO, Airflow, API Service |
### Client Configurations
| Service | Client ID | Flow |
|---------|-----------|------|
| API Service | `apiservice` | Authorization Code |
| Superset | `superset-client` | Authorization Code |
| MinIO | `minio-client` | Authorization Code + PKCE |
| Airflow | `airflow-client` | Authorization Code |
---
## API Key Security (API Service)
### Permission System
```
API Client (ระบบที่ขอใช้ API)
└── API Keys (ถูก encrypt ด้วย AES)
└── Permissions:
- feed.checkpoint:write
- (เพิ่มได้ตามต้องการ)
```
### API Key Lifecycle
```bash
# 1. Admin สร้าง API Client
POST /apiservice/admin/api-clients
# 2. Generate API Key สำหรับ Client
POST /apiservice/admin/api-keys/generate
?client_id=1
&permissions=feed.checkpoint:write
&name=his-production-key
# 3. ใช้งาน API Key
curl -H "Authorization: Bearer <key>" \
https://ai.sriphat.com/apiservice/api/v1/feed/checkpoint
```
---
## Database Security (Schema Separation)
### PostgreSQL Schemas
| Schema | ใช้งาน | สิทธิ์ |
|--------|--------|--------|
| `public` | Default | แล้วแต่ config |
| `raw_data` | ข้อมูลดิบจาก Airbyte | Airflow write, BI read-only |
| `analytics` | ข้อมูลที่ transform แล้ว | BI read-only |
| `operationbi` | Operation KPIs | API Service write, Superset read |
| `fastapi` | API Service metadata | API Service only |
| `_analytics` | Supabase Logflare | Internal only |
| `_realtime` | Supabase Realtime | Internal only |
### Row-Level Security (RLS)
```sql
-- ตัวอย่าง: จำกัดข้อมูลตาม department
ALTER TABLE patient_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY department_isolation ON patient_data
USING (department = current_setting('app.current_department'));
-- Set context ใน connection
SET app.current_department = 'OPD';
```
---
## Secrets Management
### ไฟล์ Secrets
| ไฟล์ | ความสำคัญ | ต้องทำ |
|------|---------|--------|
| `.env.global` | HIGH | ไม่ commit ลง git |
| `02-supabase/.env` | HIGH | ไม่ commit ลง git |
| `03-apiservice/.env` | HIGH | ไม่ commit ลง git |
| `07-minio/.env` | HIGH | ไม่ commit ลง git |
### Key Secrets ที่ต้อง Rotate
| Secret | Location | แนะนำ Rotation |
|--------|----------|--------------|
| `DB_PASSWORD` | `.env.global` | 90 วัน |
| `JWT_SECRET` (Supabase) | `02-supabase/.env` | เมื่อมีเหตุ |
| `KEYCLOAK_ADMIN_PASSWORD` | `.env.global` | 90 วัน |
| `ADMIN_SECRET_KEY` (API) | `03-apiservice/.env` | 90 วัน |
| `MINIO_ROOT_PASSWORD` | `07-minio/.env` | 90 วัน |
| `AIRFLOW__CORE__FERNET_KEY` | `05-airflow/.env` | ไม่ rotate (data loss) |
| `SUPERSET_SECRET_KEY` | `.env.global` | ไม่ rotate (session loss) |
---
## Security Checklist
### Pre-Production
- [ ] เปลี่ยน passwords ทั้งหมดจาก default
- [ ] เปิด HTTPS ใน Nginx (Let's Encrypt หรือ internal CA)
- [ ] ตั้งค่า Keycloak realm `sriphat` (ไม่ใช่ `master`)
- [ ] เชื่อมต่อ LDAP/AD ของโรงพยาบาล
- [ ] Enable RLS ใน PostgreSQL
- [ ] ตั้งค่า firewall rules (จำกัด inbound ports)
- [ ] Setup audit logging
- [ ] กำหนด session timeout ใน Keycloak
### Ongoing
- [ ] Review API Keys ที่ active ทุก 30 วัน
- [ ] Monitor Dozzle สำหรับ unusual access patterns
- [ ] Backup secrets ไว้ใน secure vault (HashiCorp Vault หรือ similar)
- [ ] Rotate passwords ตามกำหนด
---
## SSL/TLS Configuration
Nginx จัดการ SSL termination:
```nginx
server {
listen 443 ssl;
server_name ai.sriphat.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Force HTTPS
# Redirect http → https ผ่าน Nginx
}
```
---
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]] (Keycloak setup)
- [[03-API-Service]] (API Key management)
- [[08-Operations-Runbook]]

View File

@@ -0,0 +1,401 @@
---
tags:
- project/sriphat
- operations
- runbook
- deployment
- troubleshooting
created: 2026-05-07
status: active
---
# Operations Runbook
## Quick Reference — Service Status
```bash
# ดู containers ทั้งหมดที่รันอยู่
docker ps
# ดู resource usage
docker stats
# ดู logs แบบ realtime (ผ่าน Dozzle)
# https://ai.sriphat.com/dozzle
```
---
## First-Time Deployment
### Prerequisites
- Docker + Docker Compose installed
- RAM ≥ 8 GB
- Disk ≥ 50 GB
- Port 80, 443 accessible
### Step 1: Setup Network
```bash
# สร้าง Docker network ร่วม
docker network create shared_data_network
```
### Step 2: Configure Environment
```bash
# Copy และแก้ไขค่า
cp .env.example .env.global
nano .env.global
# Supabase env
cd 02-supabase
cp .env.example .env
nano .env
# API Service env
cd ../03-apiservice
cp .env.example .env
nano .env
# MinIO env
cd ../07-minio
cp .env.example .env
nano .env
```
### Step 3: Start Services (ตามลำดับ)
```bash
# 1. Infrastructure (Nginx + Keycloak + PostgreSQL + Redis)
cd 01-infra
docker compose --env-file ../.env.global up -d
# รอ PostgreSQL พร้อม (~30 วินาที)
sleep 30
# 2. Supabase
cd ../02-supabase
docker compose up -d
# รอ Supabase DB พร้อม (~60 วินาที)
sleep 60
# 3. API Service
cd ../03-apiservice
docker compose --env-file ../.env.global up --build -d
# 4. Airflow (ถ้าใช้งาน)
cd ../05-airflow
docker compose up airflow-init # รอให้ init เสร็จ
docker compose up -d
# 5. Analytics (Superset)
cd ../06-analytics
docker compose --env-file ../.env.global build
docker compose --env-file ../.env.global up -d
# 6. MinIO
cd ../07-minio
docker compose up -d
```
### Step 4: Verify Services
```bash
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
```
Expected containers:
- `nginx-proxy-manager`
- `keycloak`
- `postgres`
- `redis`
- `dozzle`
- `sdp-supabase-db`
- `sdp-supabase-studio`
- `sdp-supabase-kong`
- `sdp-supabase-auth`
- `sdp-supabase-rest`
- `sdp-realtime-dev`
- `sdp-supabase-storage`
- `sdp-supabase-pooler`
- `sdp-supabase-analytics`
- `sdp-supabase-meta`
- `apiservice`
- `superset`
- `minio`
---
## Post-Installation Setup
### Setup Keycloak
1. เข้า `https://ai.sriphat.com/keycloak/admin`
2. Login ด้วย `KEYCLOAK_ADMIN` credentials
3. สร้าง Realm: `sriphat`
4. สร้าง Clients:
- `apiservice` (API Service)
- `superset-client` (Apache Superset)
- `minio-client` (MinIO)
- `airflow-client` (Apache Airflow)
5. เชื่อมต่อ LDAP/AD (optional)
### Initialize API Service
```bash
# เข้า Admin UI
https://ai.sriphat.com/apiservice/admin/
# สร้าง API Client และ Generate Key
curl -X POST "https://ai.sriphat.com/apiservice/admin/api-keys/generate" \
-H "Cookie: session=<admin-session>" \
-d "client_id=1&permissions=feed.checkpoint:write&name=his-key"
```
### Setup Superset Data Sources
1. เข้า `https://ai.sriphat.com/superset`
2. Settings → Database Connections → Add
3. เพิ่ม PostgreSQL:
```
postgresql://postgres:<password>@postgres:5432/postgres
```
---
## Daily Operations
### Start All Services
```bash
# Infrastructure
cd 01-infra && docker compose --env-file ../.env.global up -d && cd ..
# Supabase
cd 02-supabase && docker compose up -d && cd ..
# API Service
cd 03-apiservice && docker compose --env-file ../.env.global up -d && cd ..
# Airflow
cd 05-airflow && docker compose up -d && cd ..
# Analytics
cd 06-analytics && docker compose --env-file ../.env.global up -d && cd ..
# MinIO
cd 07-minio && docker compose up -d && cd ..
```
### Stop All Services
```bash
cd 01-infra && docker compose down && cd ..
cd 02-supabase && docker compose down && cd ..
cd 03-apiservice && docker compose down && cd ..
cd 05-airflow && docker compose down && cd ..
cd 06-analytics && docker compose down && cd ..
cd 07-minio && docker compose down && cd ..
```
---
## Backup & Restore
### Backup PostgreSQL (Infra)
```bash
# Backup ทั้ง database
docker exec postgres pg_dumpall -U postgres > backup_all_$(date +%Y%m%d_%H%M).sql
# Backup เฉพาะ database
docker exec postgres pg_dump -U postgres postgres > backup_postgres_$(date +%Y%m%d).sql
docker exec postgres pg_dump -U postgres superset > backup_superset_$(date +%Y%m%d).sql
docker exec postgres pg_dump -U postgres airflow > backup_airflow_$(date +%Y%m%d).sql
```
### Backup Supabase PostgreSQL
```bash
docker exec sdp-supabase-db pg_dump -U postgres postgres > backup_supabase_$(date +%Y%m%d).sql
```
### Backup MinIO
```bash
# ใช้ mc mirror
mc mirror sriphat/ ./minio-backup-$(date +%Y%m%d)/
# หรือ tar data directory
tar -czf minio-backup-$(date +%Y%m%d).tar.gz 07-minio/data/
```
### Restore PostgreSQL
```bash
# Restore
docker exec -i postgres psql -U postgres postgres < backup_postgres_20260501.sql
```
---
## Update Services
```bash
# Pull latest images
cd 01-infra && docker compose --env-file ../.env.global pull && cd ..
cd 02-supabase && docker compose pull && cd ..
cd 06-analytics && docker compose --env-file ../.env.global pull && cd ..
cd 07-minio && docker compose pull && cd ..
# Rebuild API Service (มี code changes)
cd 03-apiservice
docker compose --env-file ../.env.global build
docker compose --env-file ../.env.global up -d
```
---
## Troubleshooting
### PostgreSQL ไม่ start / connection refused
```bash
# ตรวจสอบ health
docker exec postgres pg_isready -U postgres
# ดู logs
docker logs postgres --tail 50
# Check schemas
docker exec postgres psql -U postgres -c "\dn"
```
### Keycloak ไม่ start
```bash
# ดู logs (มักเกิดจาก PostgreSQL ยังไม่พร้อม)
docker logs keycloak --tail 50
# Restart หลัง PostgreSQL พร้อม
docker restart keycloak
```
### API Service ไม่ connect database
```bash
# ตรวจสอบ network
docker network inspect shared_data_network
# ตรวจสอบ env vars
docker exec apiservice env | grep DB_
# Test connection จาก container
docker exec apiservice python -c "import psycopg2; psycopg2.connect(host='postgres', user='postgres', password='<pass>')"
```
### Supabase services unhealthy
```bash
# ตรวจสอบทุก container
docker ps --filter "name=sdp-"
# Restart ตามลำดับ dependency
docker restart sdp-supabase-db
sleep 10
docker restart sdp-supabase-analytics
sleep 10
docker restart sdp-supabase-kong
docker restart sdp-supabase-auth
docker restart sdp-supabase-rest
```
### Airflow worker ไม่ pick tasks
```bash
# ตรวจสอบ Redis connectivity
docker exec airflow-worker redis-cli -h redis ping
# ตรวจสอบ worker
docker logs airflow-worker --tail 50
# Restart worker
docker restart airflow-worker
```
### Nginx 502 Bad Gateway
```bash
# ตรวจสอบว่า backend container ทำงานอยู่
docker ps | grep <service-name>
# ตรวจสอบ logs
docker logs nginx-proxy-manager --tail 50
docker logs <service-name> --tail 50
# ตรวจสอบ network
docker network inspect shared_data_network | grep -A5 <service-name>
```
### MinIO ไม่ accessible
```bash
# Health check
curl -f http://localhost:9000/minio/health/live
# Logs
docker logs minio --tail 50
# Disk space
df -h
```
---
## Monitoring
### Dozzle (Docker Logs)
**URL:** `https://ai.sriphat.com/dozzle`
Dozzle monitor ทั้ง:
- Main server (local)
- Remote agent: `192.168.100.9:7007`
### Container Health
```bash
# ดู health status
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Health}}"
# ดู resource usage
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
```
---
## Access Points Summary
| Service | URL | Credentials |
|---------|-----|-------------|
| Nginx Proxy | `http://192.168.100.9:8020` | — |
| Keycloak Admin | `https://ai.sriphat.com/keycloak/admin` | `KEYCLOAK_ADMIN` |
| Supabase Studio | `https://ai.sriphat.com/supabase` | DB credentials |
| Supabase API | `https://ai.sriphat.com/supabase-api` | ANON_KEY / SERVICE_ROLE_KEY |
| API Service | `https://ai.sriphat.com/apiservice` | Admin credentials |
| Airflow | `https://ai.sriphat.com/airflow` | Airflow admin |
| Superset | `https://ai.sriphat.com/superset` | `SUPERSET_ADMIN_*` |
| MinIO Console | `https://ai.sriphat.com/minio-console` | `MINIO_ROOT_*` |
| MinIO API | `https://ai.sriphat.com/minio` | S3 credentials |
| Dozzle | `https://ai.sriphat.com/dozzle` | — (no auth default) |
---
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]]
- [[07-Security-Strategy]]

View File

@@ -0,0 +1,111 @@
---
tags:
- project/sriphat
- reference
- ports
- network
created: 2026-05-07
status: active
---
# Port & Service Reference
## Host Ports (External Access)
| Port | Service | Container | Protocol |
|------|---------|-----------|---------|
| **8020** | Nginx Reverse Proxy | `nginx-proxy-manager` | HTTP |
| **8085** | Keycloak | `keycloak` | HTTP |
| **5435** | PostgreSQL (Infra) | `postgres` | TCP |
| **9999** | Dozzle (Log Monitor) | `dozzle` | HTTP |
| **3010** | Supabase Studio | `sdp-supabase-studio` | HTTP |
| **8100** | Supabase Kong API | `sdp-supabase-kong` | HTTP |
| **8444** | Supabase Kong API | `sdp-supabase-kong` | HTTPS |
| **5434** | Supabase PostgreSQL | `sdp-supabase-db` | TCP |
| **6544** | Supabase Pooler | `sdp-supabase-pooler` | TCP |
| **8040** | API Service | `apiservice` | HTTP |
| **8200** | Airflow API Server | `airflow-apiserver` | HTTP |
| **5555** | Flower (Celery UI) | `flower` | HTTP (optional) |
| **8088** | Apache Superset | `superset` | HTTP |
| **9000** | MinIO API | `minio` | HTTP |
| **9001** | MinIO Console | `minio` | HTTP |
## Internal-Only Ports (Docker Network)
| Port | Service | Container | ใช้งาน |
|------|---------|-----------|--------|
| **5432** | PostgreSQL (Infra) | `postgres` | Keycloak, API Service, Superset, Airflow |
| **6379** | Redis | `redis` | Airflow Celery broker |
| **8080** | Keycloak | `keycloak` | Internal (Nginx proxy → external 8085) |
| **9999** | GoTrue (Auth) | `sdp-supabase-auth` | Supabase auth |
| **3000** | PostgREST | `sdp-supabase-rest` | Supabase REST API |
| **4000** | Realtime | `sdp-realtime-dev` | WebSocket |
| **5000** | Storage API | `sdp-supabase-storage` | File storage |
| **5001** | ImgProxy | `sdp-supabase-imgproxy` | Image transform |
| **8080** | Postgres Meta | `sdp-supabase-meta` | DB metadata API |
| **4000** | Logflare | `sdp-supabase-analytics` | Log analytics |
| **4000** | Supavisor | `sdp-supabase-pooler` | Pooler management |
| **9001** | Vector | `sdp-supabase-vector` | Health check |
| **8080** | Airflow Scheduler | `airflow-scheduler` | Health check |
| **8974** | Airflow Scheduler | `airflow-scheduler` | Health check endpoint |
## Nginx Subpath Routing
| Subpath | Backend Container | Port |
|---------|-----------------|------|
| `/apiservice` | `apiservice` | 8040 |
| `/keycloak` | `keycloak` | 8080 |
| `/supabase` | `sdp-supabase-studio` | 3000 |
| `/supabase-api` | `sdp-supabase-kong` | 8000 |
| `/superset` | `superset` | 8088 |
| `/airflow` | `airflow-apiserver` | 8080 |
| `/minio` | `minio` | 9000 |
| `/minio-console` | `minio` | 9001 |
| `/dozzle` | `dozzle` | 8080 |
## DNS / Hosts
| Name | IP | ใช้งาน |
|------|----|--------|
| `dev.sriphat.com` | `192.168.100.9` | extra_hosts ใน containers |
| `ai.sriphat.com` | ตาม production DNS | Production URL |
## Docker Network
```
Network: shared_data_network (external, bridge)
Containers ที่ join:
├── nginx-proxy-manager
├── keycloak
├── postgres
├── redis
├── dozzle
├── sdp-supabase-studio
├── sdp-supabase-kong
├── sdp-supabase-auth
├── sdp-supabase-rest
├── sdp-realtime-dev
├── sdp-supabase-storage
├── sdp-supabase-imgproxy
├── sdp-supabase-meta
├── sdp-supabase-edge-functions
├── sdp-supabase-analytics
├── sdp-supabase-db
├── sdp-supabase-vector
├── sdp-supabase-pooler
├── apiservice
├── airflow-apiserver
├── airflow-scheduler
├── airflow-dag-processor
├── airflow-worker
├── airflow-triggerer
├── superset
└── minio
```
## Related
- [[00-Project-Overview]]
- [[01-Infrastructure]]
- [[08-Operations-Runbook]]