Spaces:
Sleeping
Sleeping
anoderb commited on
Commit ·
39018b4
1
Parent(s): f325b17
feat: complete Docker setup for Next.js & FastAPI deployment
Browse files- .dockerignore +23 -0
- .gitignore +38 -0
- Dockerfile +53 -0
- backend/database.py +252 -0
- backend/main.py +306 -0
- backend/ml_pipeline.py +483 -0
- backend/requirements.txt +15 -0
- frontend/app/dashboard/layout.tsx +48 -0
- frontend/app/dashboard/page.tsx +69 -0
- frontend/app/forecast/page.tsx +123 -0
- frontend/app/globals.css +8 -0
- frontend/app/layout.tsx +22 -0
- frontend/app/page.tsx +49 -0
- frontend/app/prediksi/page.tsx +268 -0
- frontend/app/settings/page.tsx +60 -0
- frontend/lib/api.ts +42 -0
- frontend/next.config.js +9 -0
- frontend/package.json +25 -0
- frontend/postcss.config.js +6 -0
- frontend/tailwind.config.ts +14 -0
- frontend/tsconfig.json +26 -0
.dockerignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
frontend/node_modules/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
|
| 7 |
+
# Build outputs
|
| 8 |
+
.next/
|
| 9 |
+
frontend/.next/
|
| 10 |
+
frontend/out/
|
| 11 |
+
|
| 12 |
+
# Environments
|
| 13 |
+
.env
|
| 14 |
+
.env.*
|
| 15 |
+
|
| 16 |
+
# Logs
|
| 17 |
+
npm-debug.log*
|
| 18 |
+
yarn-debug.log*
|
| 19 |
+
yarn-error.log*
|
| 20 |
+
|
| 21 |
+
# OS Files
|
| 22 |
+
.DS_Store
|
| 23 |
+
Thumbs.db
|
.gitignore
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environments
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# Node
|
| 7 |
+
node_modules/
|
| 8 |
+
frontend/node_modules/
|
| 9 |
+
.next/
|
| 10 |
+
frontend/.next/
|
| 11 |
+
frontend/out/
|
| 12 |
+
npm-debug.log*
|
| 13 |
+
yarn-debug.log*
|
| 14 |
+
yarn-error.log*
|
| 15 |
+
|
| 16 |
+
# Python
|
| 17 |
+
__pycache__/
|
| 18 |
+
*.pyc
|
| 19 |
+
*.pyo
|
| 20 |
+
*.pyd
|
| 21 |
+
.Python
|
| 22 |
+
env/
|
| 23 |
+
venv/
|
| 24 |
+
.venv/
|
| 25 |
+
env.bak/
|
| 26 |
+
venv.bak/
|
| 27 |
+
|
| 28 |
+
# Models & Data
|
| 29 |
+
models/
|
| 30 |
+
*.h5
|
| 31 |
+
*.pkl
|
| 32 |
+
*.pt
|
| 33 |
+
*.pth
|
| 34 |
+
*.onnx
|
| 35 |
+
|
| 36 |
+
# OS Files
|
| 37 |
+
.DS_Store
|
| 38 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# Dockerfile Multi-stage untuk HuggingFace Space
|
| 3 |
+
# Port wajib: 7860
|
| 4 |
+
# ============================================================
|
| 5 |
+
|
| 6 |
+
# ── STAGE 1: Build Frontend (Next.js) ──
|
| 7 |
+
FROM node:20-alpine AS builder
|
| 8 |
+
WORKDIR /app/frontend
|
| 9 |
+
|
| 10 |
+
# Copy frontend source
|
| 11 |
+
COPY frontend/ ./
|
| 12 |
+
# Install dependencies & build static files
|
| 13 |
+
RUN npm install
|
| 14 |
+
RUN npm run build
|
| 15 |
+
|
| 16 |
+
# ── STAGE 2: Setup Backend & Run (Python) ──
|
| 17 |
+
FROM python:3.10-slim
|
| 18 |
+
# HuggingFace memerlukan user non-root
|
| 19 |
+
RUN useradd -m -u 1000 user
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Install system dependencies (untuk LightGBM dll)
|
| 23 |
+
RUN apt-get update && apt-get install -y \
|
| 24 |
+
libgomp1 \
|
| 25 |
+
gcc \
|
| 26 |
+
g++ \
|
| 27 |
+
curl \
|
| 28 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
+
|
| 30 |
+
# Copy backend requirements dan install dependencies Python
|
| 31 |
+
COPY --chown=user backend/requirements.txt ./
|
| 32 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 33 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 34 |
+
|
| 35 |
+
# Copy seluruh kode backend
|
| 36 |
+
COPY --chown=user backend/ ./
|
| 37 |
+
|
| 38 |
+
# Buat folder model & static
|
| 39 |
+
RUN mkdir -p /app/models && chown -R user:user /app/models
|
| 40 |
+
RUN mkdir -p /app/static && chown -R user:user /app/static
|
| 41 |
+
|
| 42 |
+
# Copy file statis hasil build Next.js dari STAGE 1 ke folder static backend
|
| 43 |
+
COPY --chown=user --from=builder /app/frontend/out /app/static
|
| 44 |
+
|
| 45 |
+
# Switch ke user non-root (wajib di HuggingFace)
|
| 46 |
+
USER user
|
| 47 |
+
|
| 48 |
+
# Expose port 7860 (wajib di HuggingFace Space)
|
| 49 |
+
EXPOSE 7860
|
| 50 |
+
|
| 51 |
+
# Jalankan FastAPI dengan uvicorn
|
| 52 |
+
# workers=1 karena APScheduler tidak kompatibel dengan multi-worker
|
| 53 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
backend/database.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# database.py — Koneksi MySQL, baca & tulis semua tabel
|
| 2 |
+
import os
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import sqlalchemy
|
| 5 |
+
from sqlalchemy import text
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
def get_engine():
|
| 11 |
+
url = (
|
| 12 |
+
f"mysql+pymysql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
|
| 13 |
+
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT', 3306)}"
|
| 14 |
+
f"/{os.getenv('DB_NAME')}"
|
| 15 |
+
)
|
| 16 |
+
return sqlalchemy.create_engine(url, pool_pre_ping=True, pool_recycle=3600)
|
| 17 |
+
|
| 18 |
+
# ── READ ──────────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
def get_all_komoditas():
|
| 21 |
+
"""Ambil semua komoditas aktif."""
|
| 22 |
+
engine = get_engine()
|
| 23 |
+
return pd.read_sql(
|
| 24 |
+
"SELECT id, nama, slug, unit, volatile, volatilitas_skor FROM komoditas WHERE is_active=1",
|
| 25 |
+
engine
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def get_harga_harian(komoditas_id: int, days: int = 730):
|
| 29 |
+
"""Ambil data harga harian untuk satu komoditas (default 2 tahun terakhir)."""
|
| 30 |
+
engine = get_engine()
|
| 31 |
+
query = f"""
|
| 32 |
+
SELECT hh.tanggal AS date, p.nama AS pasar_nama, hh.harga
|
| 33 |
+
FROM harga_harian hh
|
| 34 |
+
JOIN pasar p ON p.id = hh.pasar_id
|
| 35 |
+
WHERE hh.komoditas_id = {komoditas_id}
|
| 36 |
+
AND hh.tanggal >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)
|
| 37 |
+
ORDER BY hh.tanggal ASC
|
| 38 |
+
"""
|
| 39 |
+
df_raw = pd.read_sql(query, engine)
|
| 40 |
+
if df_raw.empty:
|
| 41 |
+
return pd.DataFrame()
|
| 42 |
+
# Pivot → wide format
|
| 43 |
+
df = df_raw.pivot_table(
|
| 44 |
+
index='date', columns='pasar_nama', values='harga'
|
| 45 |
+
).reset_index()
|
| 46 |
+
df.columns.name = None
|
| 47 |
+
df['date'] = pd.to_datetime(df['date'])
|
| 48 |
+
df = df.sort_values('date').reset_index(drop=True)
|
| 49 |
+
return df
|
| 50 |
+
|
| 51 |
+
def get_pasar_list(komoditas_id: int):
|
| 52 |
+
"""Ambil daftar pasar yang punya data untuk komoditas ini."""
|
| 53 |
+
engine = get_engine()
|
| 54 |
+
return pd.read_sql(f"""
|
| 55 |
+
SELECT DISTINCT p.id, p.nama
|
| 56 |
+
FROM harga_harian hh
|
| 57 |
+
JOIN pasar p ON p.id = hh.pasar_id
|
| 58 |
+
WHERE hh.komoditas_id = {komoditas_id}
|
| 59 |
+
""", engine)
|
| 60 |
+
|
| 61 |
+
# ── WRITE ─────────────────────────────────────────────────────────────────────
|
| 62 |
+
|
| 63 |
+
def save_model_ml(data: dict):
|
| 64 |
+
"""Upsert ke tabel model_ml."""
|
| 65 |
+
engine = get_engine()
|
| 66 |
+
with engine.begin() as conn:
|
| 67 |
+
conn.execute(text("""
|
| 68 |
+
INSERT INTO model_ml (
|
| 69 |
+
komoditas_id, nama_model, versi, deskripsi, file_path,
|
| 70 |
+
mape, rmse, mae, r2_score, confidence_level,
|
| 71 |
+
stabilitas, status_validasi, catatan_validasi,
|
| 72 |
+
tanggal_training, tanggal_evaluasi, is_active,
|
| 73 |
+
created_at, updated_at
|
| 74 |
+
) VALUES (
|
| 75 |
+
:komoditas_id, :nama_model, :versi, :deskripsi, :file_path,
|
| 76 |
+
:mape, :rmse, :mae, :r2_score, :confidence_level,
|
| 77 |
+
:stabilitas, :status_validasi, :catatan_validasi,
|
| 78 |
+
:tanggal_training, :tanggal_evaluasi, 1, NOW(), NOW()
|
| 79 |
+
)
|
| 80 |
+
ON DUPLICATE KEY UPDATE
|
| 81 |
+
nama_model = VALUES(nama_model),
|
| 82 |
+
versi = VALUES(versi),
|
| 83 |
+
deskripsi = VALUES(deskripsi),
|
| 84 |
+
file_path = VALUES(file_path),
|
| 85 |
+
mape = VALUES(mape),
|
| 86 |
+
rmse = VALUES(rmse),
|
| 87 |
+
mae = VALUES(mae),
|
| 88 |
+
r2_score = VALUES(r2_score),
|
| 89 |
+
confidence_level = VALUES(confidence_level),
|
| 90 |
+
stabilitas = VALUES(stabilitas),
|
| 91 |
+
status_validasi = VALUES(status_validasi),
|
| 92 |
+
catatan_validasi = VALUES(catatan_validasi),
|
| 93 |
+
tanggal_evaluasi = VALUES(tanggal_evaluasi),
|
| 94 |
+
updated_at = NOW()
|
| 95 |
+
"""), data)
|
| 96 |
+
|
| 97 |
+
return engine.execute(
|
| 98 |
+
text("SELECT id FROM model_ml WHERE komoditas_id=:kid ORDER BY updated_at DESC LIMIT 1"),
|
| 99 |
+
{'kid': data['komoditas_id']}
|
| 100 |
+
).scalar()
|
| 101 |
+
|
| 102 |
+
def save_hasil_prediksi(predictions: list, komoditas_id: int, pasar_id, model_id: int, meta: dict):
|
| 103 |
+
"""Hapus prediksi lama & insert 7 baris baru."""
|
| 104 |
+
engine = get_engine()
|
| 105 |
+
with engine.begin() as conn:
|
| 106 |
+
conn.execute(text("""
|
| 107 |
+
DELETE FROM hasil_prediksi
|
| 108 |
+
WHERE komoditas_id = :kid AND pasar_id = :pid
|
| 109 |
+
AND tanggal_target >= CURDATE()
|
| 110 |
+
"""), {'kid': komoditas_id, 'pid': pasar_id})
|
| 111 |
+
|
| 112 |
+
for p in predictions:
|
| 113 |
+
conn.execute(text("""
|
| 114 |
+
INSERT INTO hasil_prediksi (
|
| 115 |
+
komoditas_id, pasar_id, model_id,
|
| 116 |
+
tanggal_prediksi, tanggal_target,
|
| 117 |
+
harga_prediksi, confidence_level,
|
| 118 |
+
model_name, mape, rmse, created_at
|
| 119 |
+
) VALUES (
|
| 120 |
+
:kid, :pid, :model_id,
|
| 121 |
+
CURDATE(), :tanggal_target,
|
| 122 |
+
:harga_prediksi, :confidence_level,
|
| 123 |
+
:model_name, :mape, :rmse, NOW()
|
| 124 |
+
)
|
| 125 |
+
"""), {
|
| 126 |
+
'kid' : komoditas_id,
|
| 127 |
+
'pid' : pasar_id,
|
| 128 |
+
'model_id' : model_id,
|
| 129 |
+
'tanggal_target' : p['tanggal'],
|
| 130 |
+
'harga_prediksi' : p['harga_prediksi'],
|
| 131 |
+
'confidence_level': meta['confidence_level'],
|
| 132 |
+
'model_name' : meta['nama_model'],
|
| 133 |
+
'mape' : meta['mape'],
|
| 134 |
+
'rmse' : meta['rmse'],
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
def save_ringkasan_prediksi(data: dict, komoditas_id: int, model_id: int):
|
| 138 |
+
"""Upsert ringkasan prediksi."""
|
| 139 |
+
engine = get_engine()
|
| 140 |
+
with engine.begin() as conn:
|
| 141 |
+
conn.execute(text("""
|
| 142 |
+
INSERT INTO ringkasan_prediksi (
|
| 143 |
+
komoditas_id, model_id,
|
| 144 |
+
harga_min, harga_max, tren, confidence_level,
|
| 145 |
+
status_analisis, deskripsi_status,
|
| 146 |
+
tanggal_mulai, tanggal_akhir, created_at
|
| 147 |
+
) VALUES (
|
| 148 |
+
:komoditas_id, :model_id,
|
| 149 |
+
:harga_min, :harga_max, :tren, :confidence_level,
|
| 150 |
+
:status_analisis, :deskripsi_status,
|
| 151 |
+
:tanggal_mulai, :tanggal_akhir, NOW()
|
| 152 |
+
)
|
| 153 |
+
ON DUPLICATE KEY UPDATE
|
| 154 |
+
harga_min = VALUES(harga_min),
|
| 155 |
+
harga_max = VALUES(harga_max),
|
| 156 |
+
tren = VALUES(tren),
|
| 157 |
+
confidence_level = VALUES(confidence_level),
|
| 158 |
+
status_analisis = VALUES(status_analisis),
|
| 159 |
+
deskripsi_status = VALUES(deskripsi_status),
|
| 160 |
+
tanggal_mulai = VALUES(tanggal_mulai),
|
| 161 |
+
tanggal_akhir = VALUES(tanggal_akhir)
|
| 162 |
+
"""), {**data, 'komoditas_id': komoditas_id, 'model_id': model_id})
|
| 163 |
+
|
| 164 |
+
def save_insight_prediksi(insights: list, komoditas_id: int, model_id: int):
|
| 165 |
+
"""Hapus insight lama & insert baru."""
|
| 166 |
+
engine = get_engine()
|
| 167 |
+
with engine.begin() as conn:
|
| 168 |
+
conn.execute(text(
|
| 169 |
+
"DELETE FROM insight_prediksi WHERE komoditas_id = :kid"
|
| 170 |
+
), {'kid': komoditas_id})
|
| 171 |
+
for ins in insights:
|
| 172 |
+
conn.execute(text("""
|
| 173 |
+
INSERT INTO insight_prediksi (
|
| 174 |
+
komoditas_id, model_id, konten, tipe, ikon,
|
| 175 |
+
urutan, is_active, created_at, updated_at
|
| 176 |
+
) VALUES (
|
| 177 |
+
:kid, :model_id, :konten, :tipe, :ikon,
|
| 178 |
+
:urutan, 1, NOW(), NOW()
|
| 179 |
+
)
|
| 180 |
+
"""), {
|
| 181 |
+
'kid' : komoditas_id,
|
| 182 |
+
'model_id': model_id,
|
| 183 |
+
'konten' : ins['konten'],
|
| 184 |
+
'tipe' : ins['tipe'],
|
| 185 |
+
'ikon' : ins['ikon'],
|
| 186 |
+
'urutan' : ins['urutan'],
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
def update_komoditas_volatilitas(komoditas_id: int, volatile: int, skor: float):
|
| 190 |
+
engine = get_engine()
|
| 191 |
+
with engine.begin() as conn:
|
| 192 |
+
conn.execute(text("""
|
| 193 |
+
UPDATE komoditas SET volatile=:v, volatilitas_skor=:s, updated_at=NOW()
|
| 194 |
+
WHERE id=:kid
|
| 195 |
+
"""), {'v': volatile, 's': skor, 'kid': komoditas_id})
|
| 196 |
+
|
| 197 |
+
# ── READ untuk API response ───────────────────────────────────────────────────
|
| 198 |
+
|
| 199 |
+
def get_prediksi_data(komoditas_id: int):
|
| 200 |
+
"""Ambil semua data yang dibutuhkan frontend untuk satu komoditas."""
|
| 201 |
+
engine = get_engine()
|
| 202 |
+
|
| 203 |
+
komoditas = pd.read_sql(f"""
|
| 204 |
+
SELECT k.*, kk.nama as kategori_nama
|
| 205 |
+
FROM komoditas k
|
| 206 |
+
JOIN kategori_komoditas kk ON kk.id = k.kategori_id
|
| 207 |
+
WHERE k.id = {komoditas_id}
|
| 208 |
+
""", engine).to_dict('records')
|
| 209 |
+
komoditas = komoditas[0] if komoditas else None
|
| 210 |
+
|
| 211 |
+
harga_terkini = pd.read_sql(f"""
|
| 212 |
+
SELECT hh.harga, hh.tanggal, p.nama as pasar_nama, p.id as pasar_id
|
| 213 |
+
FROM harga_harian hh
|
| 214 |
+
JOIN pasar p ON p.id = hh.pasar_id
|
| 215 |
+
WHERE hh.komoditas_id = {komoditas_id}
|
| 216 |
+
ORDER BY hh.tanggal DESC LIMIT 1
|
| 217 |
+
""", engine).to_dict('records')
|
| 218 |
+
harga_terkini = harga_terkini[0] if harga_terkini else None
|
| 219 |
+
|
| 220 |
+
model = pd.read_sql(f"""
|
| 221 |
+
SELECT * FROM model_ml WHERE komoditas_id={komoditas_id} AND is_active=1
|
| 222 |
+
ORDER BY updated_at DESC LIMIT 1
|
| 223 |
+
""", engine).to_dict('records')
|
| 224 |
+
model = model[0] if model else None
|
| 225 |
+
|
| 226 |
+
ringkasan = pd.read_sql(f"""
|
| 227 |
+
SELECT * FROM ringkasan_prediksi WHERE komoditas_id={komoditas_id}
|
| 228 |
+
ORDER BY created_at DESC LIMIT 1
|
| 229 |
+
""", engine).to_dict('records')
|
| 230 |
+
ringkasan = ringkasan[0] if ringkasan else None
|
| 231 |
+
|
| 232 |
+
hasil = pd.read_sql(f"""
|
| 233 |
+
SELECT tanggal_target, harga_prediksi, confidence_level
|
| 234 |
+
FROM hasil_prediksi
|
| 235 |
+
WHERE komoditas_id={komoditas_id} AND tanggal_target >= CURDATE()
|
| 236 |
+
ORDER BY tanggal_target ASC
|
| 237 |
+
""", engine).to_dict('records')
|
| 238 |
+
|
| 239 |
+
insights = pd.read_sql(f"""
|
| 240 |
+
SELECT * FROM insight_prediksi
|
| 241 |
+
WHERE komoditas_id={komoditas_id} AND is_active=1
|
| 242 |
+
ORDER BY urutan ASC
|
| 243 |
+
""", engine).to_dict('records')
|
| 244 |
+
|
| 245 |
+
return {
|
| 246 |
+
'komoditas' : komoditas,
|
| 247 |
+
'harga_terkini': harga_terkini,
|
| 248 |
+
'model' : model,
|
| 249 |
+
'ringkasan' : ringkasan,
|
| 250 |
+
'prediksi_7hari': hasil,
|
| 251 |
+
'insights' : insights,
|
| 252 |
+
}
|
backend/main.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# main.py — FastAPI: endpoint training, forecasting, CRUD, scheduler, env
|
| 2 |
+
import os, json
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
from dotenv import load_dotenv, set_key
|
| 11 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
from database import (
|
| 16 |
+
get_all_komoditas, get_harga_harian, get_pasar_list,
|
| 17 |
+
get_prediksi_data, save_model_ml, save_hasil_prediksi,
|
| 18 |
+
save_ringkasan_prediksi, save_insight_prediksi,
|
| 19 |
+
update_komoditas_volatilitas, get_engine
|
| 20 |
+
)
|
| 21 |
+
from ml_pipeline import run_pipeline
|
| 22 |
+
from sqlalchemy import text
|
| 23 |
+
|
| 24 |
+
app = FastAPI(title="SIKOMO ML API")
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=["*"],
|
| 29 |
+
allow_methods=["*"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
| 34 |
+
security = HTTPBearer()
|
| 35 |
+
API_KEY = os.getenv('API_SECRET_KEY', 'default-key')
|
| 36 |
+
|
| 37 |
+
def verify_token(creds: HTTPAuthorizationCredentials = Depends(security)):
|
| 38 |
+
if creds.credentials != API_KEY:
|
| 39 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 40 |
+
return creds.credentials
|
| 41 |
+
|
| 42 |
+
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
| 43 |
+
scheduler = BackgroundScheduler()
|
| 44 |
+
forecast_log = [] # in-memory log untuk SSE
|
| 45 |
+
|
| 46 |
+
def auto_forecast_job():
|
| 47 |
+
forecast_log.append(f"[{datetime.now()}] Auto forecast dimulai...")
|
| 48 |
+
try:
|
| 49 |
+
komoditas_list = get_all_komoditas()
|
| 50 |
+
for _, row in komoditas_list.iterrows():
|
| 51 |
+
run_forecast_for_komoditas(int(row['id']), row['nama'])
|
| 52 |
+
forecast_log.append(f"[{datetime.now()}] Auto forecast selesai.")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
forecast_log.append(f"[{datetime.now()}] ERROR: {e}")
|
| 55 |
+
|
| 56 |
+
def run_forecast_for_komoditas(komoditas_id: int, komoditas_nama: str):
|
| 57 |
+
df = get_harga_harian(komoditas_id)
|
| 58 |
+
if df.empty or len(df) < 60:
|
| 59 |
+
forecast_log.append(f" ⚠️ {komoditas_nama}: data kurang")
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
# Kolom pertama non-date sebagai target, sisanya other
|
| 63 |
+
price_cols = [c for c in df.columns if c != 'date']
|
| 64 |
+
target_col = price_cols[0]
|
| 65 |
+
|
| 66 |
+
# Ambil pasar_id dari DB
|
| 67 |
+
pasar_df = get_pasar_list(komoditas_id)
|
| 68 |
+
pasar_id = int(pasar_df.iloc[0]['id']) if not pasar_df.empty else 1
|
| 69 |
+
|
| 70 |
+
def log_cb(msg): forecast_log.append(f" [{komoditas_nama}] {msg}")
|
| 71 |
+
|
| 72 |
+
result = run_pipeline(
|
| 73 |
+
komoditas_id=komoditas_id,
|
| 74 |
+
komoditas_nama=komoditas_nama,
|
| 75 |
+
df=df,
|
| 76 |
+
target_market=target_col,
|
| 77 |
+
pasar_id=pasar_id,
|
| 78 |
+
n_trials=int(os.getenv('OPTUNA_TRIALS', 30)),
|
| 79 |
+
log_cb=log_cb,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Simpan ke DB
|
| 83 |
+
meta = result['metadata']
|
| 84 |
+
labels = result['labels']
|
| 85 |
+
preds = result['predictions']
|
| 86 |
+
|
| 87 |
+
model_id = save_model_ml(meta)
|
| 88 |
+
save_hasil_prediksi(preds, komoditas_id, pasar_id, model_id, meta)
|
| 89 |
+
save_ringkasan_prediksi({
|
| 90 |
+
'harga_min' : labels['harga_min'],
|
| 91 |
+
'harga_max' : labels['harga_max'],
|
| 92 |
+
'tren' : labels['tren'],
|
| 93 |
+
'confidence_level': labels['confidence_level'],
|
| 94 |
+
'status_analisis' : labels['status_analisis']['judul'],
|
| 95 |
+
'deskripsi_status': labels['status_analisis']['deskripsi'],
|
| 96 |
+
'tanggal_mulai' : preds[0]['tanggal'],
|
| 97 |
+
'tanggal_akhir' : preds[-1]['tanggal'],
|
| 98 |
+
}, komoditas_id, model_id)
|
| 99 |
+
save_insight_prediksi(labels['insights'], komoditas_id, model_id)
|
| 100 |
+
cv = meta.get('data_quality', {}).get('cv', 0)
|
| 101 |
+
update_komoditas_volatilitas(komoditas_id, 1 if cv >= 5 else 0, cv)
|
| 102 |
+
|
| 103 |
+
def start_scheduler():
|
| 104 |
+
hour = int(os.getenv('AUTO_FORECAST_HOUR', 1))
|
| 105 |
+
minute = int(os.getenv('AUTO_FORECAST_MINUTE', 0))
|
| 106 |
+
if scheduler.get_jobs():
|
| 107 |
+
scheduler.remove_all_jobs()
|
| 108 |
+
if os.getenv('AUTO_FORECAST_ENABLED', 'false').lower() == 'true':
|
| 109 |
+
scheduler.add_job(auto_forecast_job, 'cron', hour=hour, minute=minute)
|
| 110 |
+
if not scheduler.running:
|
| 111 |
+
scheduler.start()
|
| 112 |
+
|
| 113 |
+
start_scheduler()
|
| 114 |
+
|
| 115 |
+
# ── Models / Pydantic ─────────────────────────────────────────────────────────
|
| 116 |
+
class ForecastRequest(BaseModel):
|
| 117 |
+
komoditas_id: int
|
| 118 |
+
|
| 119 |
+
class InsightUpdate(BaseModel):
|
| 120 |
+
konten : str
|
| 121 |
+
tipe : str
|
| 122 |
+
ikon : str
|
| 123 |
+
urutan : int
|
| 124 |
+
|
| 125 |
+
class RingkasanUpdate(BaseModel):
|
| 126 |
+
status_analisis : Optional[str] = None
|
| 127 |
+
deskripsi_status : Optional[str] = None
|
| 128 |
+
tren : Optional[str] = None
|
| 129 |
+
|
| 130 |
+
class ModelUpdate(BaseModel):
|
| 131 |
+
deskripsi : Optional[str] = None
|
| 132 |
+
catatan_validasi: Optional[str] = None
|
| 133 |
+
|
| 134 |
+
class EnvUpdate(BaseModel):
|
| 135 |
+
key : str
|
| 136 |
+
value: str
|
| 137 |
+
|
| 138 |
+
class SchedulerConfig(BaseModel):
|
| 139 |
+
enabled: bool
|
| 140 |
+
hour : int = 1
|
| 141 |
+
minute : int = 0
|
| 142 |
+
|
| 143 |
+
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 144 |
+
|
| 145 |
+
@app.get("/health")
|
| 146 |
+
def health():
|
| 147 |
+
return {"status": "ok", "time": datetime.now().isoformat()}
|
| 148 |
+
|
| 149 |
+
# AUTH
|
| 150 |
+
@app.post("/auth/login")
|
| 151 |
+
def login(body: dict):
|
| 152 |
+
if body.get('password') != os.getenv('DASHBOARD_PASSWORD', 'Bandulan112'):
|
| 153 |
+
raise HTTPException(status_code=401, detail="Password salah")
|
| 154 |
+
return {"token": API_KEY}
|
| 155 |
+
|
| 156 |
+
# KOMODITAS
|
| 157 |
+
@app.get("/komoditas", dependencies=[Depends(verify_token)])
|
| 158 |
+
def list_komoditas():
|
| 159 |
+
df = get_all_komoditas()
|
| 160 |
+
return df.to_dict('records')
|
| 161 |
+
|
| 162 |
+
# PREDIKSI DATA (untuk dashboard frontend)
|
| 163 |
+
@app.get("/prediksi/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 164 |
+
def get_prediksi(komoditas_id: int):
|
| 165 |
+
return get_prediksi_data(komoditas_id)
|
| 166 |
+
|
| 167 |
+
# MANUAL FORECAST (trigger dari frontend)
|
| 168 |
+
@app.post("/forecast/run", dependencies=[Depends(verify_token)])
|
| 169 |
+
def run_forecast_manual(req: ForecastRequest, bg: BackgroundTasks):
|
| 170 |
+
komoditas_list = get_all_komoditas()
|
| 171 |
+
row = komoditas_list[komoditas_list['id'] == req.komoditas_id]
|
| 172 |
+
if row.empty:
|
| 173 |
+
raise HTTPException(404, "Komoditas tidak ditemukan")
|
| 174 |
+
nama = row.iloc[0]['nama']
|
| 175 |
+
bg.add_task(run_forecast_for_komoditas, req.komoditas_id, nama)
|
| 176 |
+
return {"message": f"Forecast untuk {nama} dimulai di background."}
|
| 177 |
+
|
| 178 |
+
@app.post("/forecast/run-all", dependencies=[Depends(verify_token)])
|
| 179 |
+
def run_forecast_all(bg: BackgroundTasks):
|
| 180 |
+
bg.add_task(auto_forecast_job)
|
| 181 |
+
return {"message": "Forecast semua komoditas dimulai."}
|
| 182 |
+
|
| 183 |
+
# LOG
|
| 184 |
+
@app.get("/forecast/log", dependencies=[Depends(verify_token)])
|
| 185 |
+
def get_log():
|
| 186 |
+
return {"log": forecast_log[-100:]} # 100 baris terakhir
|
| 187 |
+
|
| 188 |
+
# SCHEDULER
|
| 189 |
+
@app.get("/scheduler/status", dependencies=[Depends(verify_token)])
|
| 190 |
+
def scheduler_status():
|
| 191 |
+
return {
|
| 192 |
+
"enabled": os.getenv('AUTO_FORECAST_ENABLED', 'false'),
|
| 193 |
+
"hour" : os.getenv('AUTO_FORECAST_HOUR', '1'),
|
| 194 |
+
"minute" : os.getenv('AUTO_FORECAST_MINUTE', '0'),
|
| 195 |
+
"running": scheduler.running,
|
| 196 |
+
"jobs" : [str(j) for j in scheduler.get_jobs()],
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
@app.post("/scheduler/config", dependencies=[Depends(verify_token)])
|
| 200 |
+
def update_scheduler(cfg: SchedulerConfig):
|
| 201 |
+
env_file = '.env'
|
| 202 |
+
set_key(env_file, 'AUTO_FORECAST_ENABLED', str(cfg.enabled).lower())
|
| 203 |
+
set_key(env_file, 'AUTO_FORECAST_HOUR', str(cfg.hour))
|
| 204 |
+
set_key(env_file, 'AUTO_FORECAST_MINUTE', str(cfg.minute))
|
| 205 |
+
os.environ['AUTO_FORECAST_ENABLED'] = str(cfg.enabled).lower()
|
| 206 |
+
os.environ['AUTO_FORECAST_HOUR'] = str(cfg.hour)
|
| 207 |
+
os.environ['AUTO_FORECAST_MINUTE'] = str(cfg.minute)
|
| 208 |
+
start_scheduler()
|
| 209 |
+
return {"message": "Scheduler diperbarui."}
|
| 210 |
+
|
| 211 |
+
# ENV EDITOR
|
| 212 |
+
@app.get("/env", dependencies=[Depends(verify_token)])
|
| 213 |
+
def get_env():
|
| 214 |
+
"""Baca .env untuk ditampilkan di dashboard (tanpa nilai sensitif diekspos mentah)."""
|
| 215 |
+
allowed = [
|
| 216 |
+
'DB_HOST','DB_PORT','DB_NAME','DB_USER',
|
| 217 |
+
'AUTO_FORECAST_ENABLED','AUTO_FORECAST_HOUR','AUTO_FORECAST_MINUTE',
|
| 218 |
+
'OPTUNA_TRIALS','MODEL_DIR',
|
| 219 |
+
]
|
| 220 |
+
return {k: os.getenv(k, '') for k in allowed}
|
| 221 |
+
|
| 222 |
+
@app.post("/env", dependencies=[Depends(verify_token)])
|
| 223 |
+
def update_env(body: EnvUpdate):
|
| 224 |
+
allowed = [
|
| 225 |
+
'DB_HOST','DB_PORT','DB_NAME','DB_USER','DB_PASSWORD',
|
| 226 |
+
'AUTO_FORECAST_ENABLED','AUTO_FORECAST_HOUR','AUTO_FORECAST_MINUTE',
|
| 227 |
+
'OPTUNA_TRIALS','MODEL_DIR','API_SECRET_KEY',
|
| 228 |
+
]
|
| 229 |
+
if body.key not in allowed:
|
| 230 |
+
raise HTTPException(400, f"Key '{body.key}' tidak diizinkan untuk diubah.")
|
| 231 |
+
set_key('.env', body.key, body.value)
|
| 232 |
+
os.environ[body.key] = body.value
|
| 233 |
+
return {"message": f"{body.key} berhasil diperbarui."}
|
| 234 |
+
|
| 235 |
+
# CRUD INSIGHT
|
| 236 |
+
@app.get("/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 237 |
+
def get_insights(komoditas_id: int):
|
| 238 |
+
engine = get_engine()
|
| 239 |
+
rows = engine.execute(
|
| 240 |
+
f"SELECT * FROM insight_prediksi WHERE komoditas_id={komoditas_id} ORDER BY urutan"
|
| 241 |
+
).fetchall()
|
| 242 |
+
return [dict(r) for r in rows]
|
| 243 |
+
|
| 244 |
+
@app.put("/insight/{insight_id}", dependencies=[Depends(verify_token)])
|
| 245 |
+
def update_insight(insight_id: int, body: InsightUpdate):
|
| 246 |
+
engine = get_engine()
|
| 247 |
+
with engine.begin() as conn:
|
| 248 |
+
conn.execute(text("""
|
| 249 |
+
UPDATE insight_prediksi
|
| 250 |
+
SET konten=:konten, tipe=:tipe, ikon=:ikon, urutan=:urutan, updated_at=NOW()
|
| 251 |
+
WHERE id=:id
|
| 252 |
+
"""), {**body.dict(), 'id': insight_id})
|
| 253 |
+
return {"message": "Insight diperbarui."}
|
| 254 |
+
|
| 255 |
+
@app.post("/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 256 |
+
def add_insight(komoditas_id: int, body: InsightUpdate):
|
| 257 |
+
engine = get_engine()
|
| 258 |
+
with engine.begin() as conn:
|
| 259 |
+
conn.execute(text("""
|
| 260 |
+
INSERT INTO insight_prediksi (komoditas_id, konten, tipe, ikon, urutan, is_active, created_at, updated_at)
|
| 261 |
+
VALUES (:kid, :konten, :tipe, :ikon, :urutan, 1, NOW(), NOW())
|
| 262 |
+
"""), {**body.dict(), 'kid': komoditas_id})
|
| 263 |
+
return {"message": "Insight ditambahkan."}
|
| 264 |
+
|
| 265 |
+
@app.delete("/insight/{insight_id}", dependencies=[Depends(verify_token)])
|
| 266 |
+
def delete_insight(insight_id: int):
|
| 267 |
+
engine = get_engine()
|
| 268 |
+
with engine.begin() as conn:
|
| 269 |
+
conn.execute(text("DELETE FROM insight_prediksi WHERE id=:id"), {'id': insight_id})
|
| 270 |
+
return {"message": "Insight dihapus."}
|
| 271 |
+
|
| 272 |
+
# CRUD RINGKASAN
|
| 273 |
+
@app.put("/ringkasan/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 274 |
+
def update_ringkasan(komoditas_id: int, body: RingkasanUpdate):
|
| 275 |
+
engine = get_engine()
|
| 276 |
+
updates = {k: v for k, v in body.dict().items() if v is not None}
|
| 277 |
+
if not updates:
|
| 278 |
+
raise HTTPException(400, "Tidak ada field yang diupdate.")
|
| 279 |
+
set_clause = ', '.join([f"{k}=:{k}" for k in updates])
|
| 280 |
+
with engine.begin() as conn:
|
| 281 |
+
conn.execute(
|
| 282 |
+
text(f"UPDATE ringkasan_prediksi SET {set_clause} WHERE komoditas_id=:kid ORDER BY created_at DESC LIMIT 1"),
|
| 283 |
+
{**updates, 'kid': komoditas_id}
|
| 284 |
+
)
|
| 285 |
+
return {"message": "Ringkasan diperbarui."}
|
| 286 |
+
|
| 287 |
+
# CRUD MODEL ML
|
| 288 |
+
@app.put("/model-ml/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 289 |
+
def update_model_ml(komoditas_id: int, body: ModelUpdate):
|
| 290 |
+
engine = get_engine()
|
| 291 |
+
updates = {k: v for k, v in body.dict().items() if v is not None}
|
| 292 |
+
if not updates:
|
| 293 |
+
raise HTTPException(400, "Tidak ada field yang diupdate.")
|
| 294 |
+
set_clause = ', '.join([f"{k}=:{k}" for k in updates])
|
| 295 |
+
with engine.begin() as conn:
|
| 296 |
+
conn.execute(
|
| 297 |
+
text(f"UPDATE model_ml SET {set_clause}, updated_at=NOW() WHERE komoditas_id=:kid AND is_active=1"),
|
| 298 |
+
{**updates, 'kid': komoditas_id}
|
| 299 |
+
)
|
| 300 |
+
return {"message": "Model ML diperbarui."}
|
| 301 |
+
|
| 302 |
+
# MOUNT FRONTEND STATIS
|
| 303 |
+
# Mount static files as the very last route so it catches all non-API paths
|
| 304 |
+
import os
|
| 305 |
+
if os.path.isdir("static"):
|
| 306 |
+
app.mount("/", StaticFiles(directory="static", html=True), name="frontend")
|
backend/ml_pipeline.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ml_pipeline.py — Full ML pipeline: cleaning → training → forecasting → labeling
|
| 2 |
+
import os, json, pickle, warnings
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
from datetime import date
|
| 6 |
+
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
| 7 |
+
from sklearn.model_selection import TimeSeriesSplit
|
| 8 |
+
import lightgbm as lgb
|
| 9 |
+
import xgboost as xgb
|
| 10 |
+
from prophet import Prophet
|
| 11 |
+
from statsmodels.tsa.arima.model import ARIMA
|
| 12 |
+
import optuna
|
| 13 |
+
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
| 14 |
+
warnings.filterwarnings('ignore')
|
| 15 |
+
|
| 16 |
+
MODEL_DIR = os.getenv('MODEL_DIR', 'models')
|
| 17 |
+
os.makedirs(MODEL_DIR, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 20 |
+
# 1. DATA QUALITY & CLEANING
|
| 21 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 22 |
+
|
| 23 |
+
def analyze_data_quality(df, col):
|
| 24 |
+
total, missing = len(df), df[col].isna().sum()
|
| 25 |
+
gap_lengths, cur = [], 0
|
| 26 |
+
for v in df[col].isna():
|
| 27 |
+
if v: cur += 1
|
| 28 |
+
else:
|
| 29 |
+
if cur > 0: gap_lengths.append(cur)
|
| 30 |
+
cur = 0
|
| 31 |
+
max_gap = max(gap_lengths) if gap_lengths else 0
|
| 32 |
+
outliers = (df[col].pct_change().abs() > 0.5).sum()
|
| 33 |
+
mean, std = df[col].mean(), df[col].std()
|
| 34 |
+
cv = (std / mean * 100) if mean > 0 else 0
|
| 35 |
+
return {
|
| 36 |
+
'total_records': total,
|
| 37 |
+
'missing' : int(missing),
|
| 38 |
+
'missing_pct' : round(missing / total * 100, 2),
|
| 39 |
+
'max_gap_days' : max_gap,
|
| 40 |
+
'outliers' : int(outliers),
|
| 41 |
+
'cv' : round(cv, 2),
|
| 42 |
+
'volatility' : 'rendah' if cv < 2 else ('sedang' if cv < 5 else 'tinggi'),
|
| 43 |
+
'mean_price' : round(mean, 2),
|
| 44 |
+
'std_price' : round(std, 2),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
def clean_price_data(df, col, quality_info):
|
| 48 |
+
df = df.copy()
|
| 49 |
+
df[f'{col}_was_missing'] = df[col].isna().astype(int)
|
| 50 |
+
temp = df[col].copy()
|
| 51 |
+
mask = temp.isna()
|
| 52 |
+
gap_id = (mask != mask.shift()).cumsum()
|
| 53 |
+
gap_sizes = mask.groupby(gap_id).transform('sum')
|
| 54 |
+
|
| 55 |
+
temp = temp.fillna(method='ffill', limit=3)
|
| 56 |
+
med_mask = mask & (gap_sizes > 3) & (gap_sizes <= 7)
|
| 57 |
+
ti = temp.interpolate(method='linear', limit=7)
|
| 58 |
+
temp[med_mask] = ti[med_mask]
|
| 59 |
+
|
| 60 |
+
long_mask = mask & (gap_sizes > 7)
|
| 61 |
+
if long_mask.any():
|
| 62 |
+
rolling_med = temp.rolling(30, min_periods=3, center=True).median()
|
| 63 |
+
ti2 = temp.interpolate(method='linear')
|
| 64 |
+
temp[long_mask] = ti2.clip(lower=rolling_med*0.85, upper=rolling_med*1.15)[long_mask]
|
| 65 |
+
|
| 66 |
+
df[col] = temp
|
| 67 |
+
outlier_mask = df[col].pct_change().abs() > 0.4
|
| 68 |
+
if outlier_mask.any():
|
| 69 |
+
df.loc[outlier_mask, col] = np.nan
|
| 70 |
+
df[col] = df[col].interpolate(method='linear', limit=5)
|
| 71 |
+
return df
|
| 72 |
+
|
| 73 |
+
def recommend_models(quality_info):
|
| 74 |
+
models = ['lightgbm', 'xgboost']
|
| 75 |
+
if quality_info['missing_pct'] < 30 and quality_info['max_gap_days'] < 14:
|
| 76 |
+
models.append('prophet')
|
| 77 |
+
if quality_info['cv'] < 5 and quality_info['missing_pct'] < 20:
|
| 78 |
+
models.append('arima')
|
| 79 |
+
return models
|
| 80 |
+
|
| 81 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 82 |
+
# 2. FEATURE ENGINEERING
|
| 83 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 84 |
+
|
| 85 |
+
def create_features(df, target_col, other_cols=None):
|
| 86 |
+
df = df.copy()
|
| 87 |
+
df['year'] = df['date'].dt.year
|
| 88 |
+
df['month'] = df['date'].dt.month
|
| 89 |
+
df['day'] = df['date'].dt.day
|
| 90 |
+
df['day_of_week'] = df['date'].dt.dayofweek
|
| 91 |
+
df['day_of_year'] = df['date'].dt.dayofyear
|
| 92 |
+
df['week_of_year'] = df['date'].dt.isocalendar().week.astype(int)
|
| 93 |
+
df['quarter'] = df['date'].dt.quarter
|
| 94 |
+
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
|
| 95 |
+
df['is_month_start']= df['date'].dt.is_month_start.astype(int)
|
| 96 |
+
df['is_month_end'] = df['date'].dt.is_month_end.astype(int)
|
| 97 |
+
for lag in [1, 2, 3, 7, 14, 30]:
|
| 98 |
+
df[f'price_lag_{lag}'] = df[target_col].shift(lag)
|
| 99 |
+
for w in [7, 14, 30]:
|
| 100 |
+
df[f'price_rolling_mean_{w}'] = df[target_col].rolling(w).mean()
|
| 101 |
+
df[f'price_rolling_std_{w}'] = df[target_col].rolling(w).std()
|
| 102 |
+
df[f'price_rolling_min_{w}'] = df[target_col].rolling(w).min()
|
| 103 |
+
df[f'price_rolling_max_{w}'] = df[target_col].rolling(w).max()
|
| 104 |
+
df['price_diff_1'] = df[target_col].diff(1)
|
| 105 |
+
df['price_diff_7'] = df[target_col].diff(7)
|
| 106 |
+
df['price_pct_change_1'] = df[target_col].pct_change(1) * 100
|
| 107 |
+
df['price_pct_change_7'] = df[target_col].pct_change(7) * 100
|
| 108 |
+
df['sma_7'] = df[target_col].rolling(7).mean()
|
| 109 |
+
df['sma_30'] = df[target_col].rolling(30).mean()
|
| 110 |
+
df['sma_diff'] = df['sma_7'] - df['sma_30']
|
| 111 |
+
df['volatility_7'] = df[target_col].rolling(7).std()
|
| 112 |
+
df['volatility_30'] = df[target_col].rolling(30).std()
|
| 113 |
+
if other_cols:
|
| 114 |
+
for i, oc in enumerate(other_cols):
|
| 115 |
+
if oc in df.columns:
|
| 116 |
+
df[f'other_price_{i}'] = df[oc]
|
| 117 |
+
df[f'price_spread_{i}'] = df[target_col] - df[oc]
|
| 118 |
+
df[f'price_ratio_{i}'] = df[target_col] / (df[oc] + 1e-6)
|
| 119 |
+
return df
|
| 120 |
+
|
| 121 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 122 |
+
# 3. TRAINING
|
| 123 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 124 |
+
|
| 125 |
+
def evaluate_model(y_true, y_pred, model_name=""):
|
| 126 |
+
rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
|
| 127 |
+
return {
|
| 128 |
+
'model' : model_name,
|
| 129 |
+
'rmse' : rmse,
|
| 130 |
+
'mae' : float(mean_absolute_error(y_true, y_pred)),
|
| 131 |
+
'r2' : float(r2_score(y_true, y_pred)),
|
| 132 |
+
'mape' : float(np.mean(np.abs((y_true - y_pred) / (np.abs(y_true)+1e-6))) * 100),
|
| 133 |
+
'median_ae': float(np.median(np.abs(y_true - y_pred))),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
def train_lightgbm(X_train, y_train, X_test, y_test):
|
| 137 |
+
m = lgb.LGBMRegressor(
|
| 138 |
+
objective='regression', metric='rmse', num_leaves=31,
|
| 139 |
+
max_depth=7, learning_rate=0.05, n_estimators=300,
|
| 140 |
+
min_child_samples=20, subsample=0.8, colsample_bytree=0.8,
|
| 141 |
+
reg_alpha=0.1, reg_lambda=0.1, verbose=-1,
|
| 142 |
+
random_state=42, force_col_wise=True,
|
| 143 |
+
)
|
| 144 |
+
m.fit(X_train, y_train, eval_set=[(X_test, y_test)],
|
| 145 |
+
eval_metric='rmse', callbacks=[lgb.early_stopping(50, verbose=False)])
|
| 146 |
+
return m, evaluate_model(y_test, m.predict(X_test), 'LightGBM')
|
| 147 |
+
|
| 148 |
+
def train_xgboost(X_train, y_train, X_test, y_test):
|
| 149 |
+
m = xgb.XGBRegressor(
|
| 150 |
+
objective='reg:squarederror', n_estimators=300, max_depth=6,
|
| 151 |
+
learning_rate=0.05, subsample=0.8, colsample_bytree=0.8,
|
| 152 |
+
reg_alpha=0.1, reg_lambda=0.1, random_state=42, verbosity=0,
|
| 153 |
+
)
|
| 154 |
+
m.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
|
| 155 |
+
return m, evaluate_model(y_test, m.predict(X_test), 'XGBoost')
|
| 156 |
+
|
| 157 |
+
def train_prophet(dates_train, y_train, dates_test, y_test):
|
| 158 |
+
df_train = pd.DataFrame({'ds': dates_train.values, 'y': y_train.values})
|
| 159 |
+
df_test = pd.DataFrame({'ds': dates_test.values})
|
| 160 |
+
m = Prophet(yearly_seasonality=True, weekly_seasonality=True,
|
| 161 |
+
daily_seasonality=False, changepoint_prior_scale=0.05)
|
| 162 |
+
m.fit(df_train)
|
| 163 |
+
fc = m.predict(df_test)
|
| 164 |
+
pred = np.clip(fc['yhat'].values, 0, None)
|
| 165 |
+
return m, evaluate_model(y_test.values, pred, 'Prophet')
|
| 166 |
+
|
| 167 |
+
def train_arima(y_train, y_test):
|
| 168 |
+
m = ARIMA(y_train.values, order=(5, 1, 0)).fit()
|
| 169 |
+
fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
|
| 170 |
+
return m, evaluate_model(y_test.values, fc, 'ARIMA')
|
| 171 |
+
|
| 172 |
+
def tune_with_optuna(model_name, X_train, y_train, n_trials=50):
|
| 173 |
+
def objective(trial):
|
| 174 |
+
if model_name == 'LightGBM':
|
| 175 |
+
params = {
|
| 176 |
+
'objective':'regression','metric':'rmse','verbose':-1,
|
| 177 |
+
'random_state':42,'force_col_wise':True,
|
| 178 |
+
'num_leaves' : trial.suggest_int('num_leaves', 20, 100),
|
| 179 |
+
'max_depth' : trial.suggest_int('max_depth', 4, 12),
|
| 180 |
+
'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.3),
|
| 181 |
+
'n_estimators' : trial.suggest_int('n_estimators', 100, 500),
|
| 182 |
+
'min_child_samples': trial.suggest_int('min_child_samples', 5, 50),
|
| 183 |
+
'subsample' : trial.suggest_float('subsample', 0.5, 1.0),
|
| 184 |
+
'colsample_bytree' : trial.suggest_float('colsample_bytree', 0.5, 1.0),
|
| 185 |
+
'reg_alpha' : trial.suggest_float('reg_alpha', 0.0, 1.0),
|
| 186 |
+
'reg_lambda' : trial.suggest_float('reg_lambda', 0.0, 1.0),
|
| 187 |
+
}
|
| 188 |
+
Cls = lgb.LGBMRegressor
|
| 189 |
+
else:
|
| 190 |
+
params = {
|
| 191 |
+
'objective':'reg:squarederror','random_state':42,'verbosity':0,
|
| 192 |
+
'n_estimators' : trial.suggest_int('n_estimators', 100, 500),
|
| 193 |
+
'max_depth' : trial.suggest_int('max_depth', 3, 10),
|
| 194 |
+
'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.3),
|
| 195 |
+
'subsample' : trial.suggest_float('subsample', 0.5, 1.0),
|
| 196 |
+
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
|
| 197 |
+
'reg_alpha' : trial.suggest_float('reg_alpha', 0.0, 1.0),
|
| 198 |
+
'reg_lambda' : trial.suggest_float('reg_lambda', 0.0, 1.0),
|
| 199 |
+
}
|
| 200 |
+
Cls = xgb.XGBRegressor
|
| 201 |
+
scores = []
|
| 202 |
+
for tr, val in TimeSeriesSplit(n_splits=5).split(X_train):
|
| 203 |
+
m = Cls(**params)
|
| 204 |
+
m.fit(X_train.iloc[tr], y_train.iloc[tr])
|
| 205 |
+
scores.append(np.sqrt(mean_squared_error(y_train.iloc[val], m.predict(X_train.iloc[val]))))
|
| 206 |
+
return np.mean(scores)
|
| 207 |
+
|
| 208 |
+
study = optuna.create_study(direction='minimize')
|
| 209 |
+
study.optimize(objective, n_trials=n_trials)
|
| 210 |
+
return study.best_params
|
| 211 |
+
|
| 212 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 213 |
+
# 4. FORECASTING
|
| 214 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 215 |
+
|
| 216 |
+
def forecast_tree(model, df_hist, target_col, other_cols, feature_cols, n_days=7):
|
| 217 |
+
df_temp = df_hist.copy()
|
| 218 |
+
preds = []
|
| 219 |
+
last_date = df_temp['date'].max()
|
| 220 |
+
for i in range(1, n_days + 1):
|
| 221 |
+
next_date = last_date + pd.Timedelta(days=i)
|
| 222 |
+
new_row = {'date': next_date, target_col: np.nan}
|
| 223 |
+
for oc in (other_cols or []):
|
| 224 |
+
if oc in df_temp.columns:
|
| 225 |
+
lo = df_temp[oc].dropna()
|
| 226 |
+
new_row[oc] = float(lo.iloc[-1]) if len(lo) else np.nan
|
| 227 |
+
df_temp = pd.concat([df_temp, pd.DataFrame([new_row])], ignore_index=True)
|
| 228 |
+
df_feat = create_features(df_temp, target_col, other_cols)
|
| 229 |
+
last_row = df_feat.iloc[[-1]].copy()
|
| 230 |
+
for col in feature_cols:
|
| 231 |
+
if col not in last_row.columns:
|
| 232 |
+
last_row[col] = 0.0
|
| 233 |
+
pred = max(0, float(model.predict(last_row[feature_cols])[0]))
|
| 234 |
+
preds.append({'tanggal': str(next_date.date()), 'harga_prediksi': round(pred, 2)})
|
| 235 |
+
df_temp.loc[df_temp['date'] == next_date, target_col] = pred
|
| 236 |
+
return preds
|
| 237 |
+
|
| 238 |
+
def forecast_prophet(model, last_date, n_days=7):
|
| 239 |
+
future = pd.DataFrame({'ds': pd.date_range(start=last_date + pd.Timedelta(days=1), periods=n_days)})
|
| 240 |
+
fc = model.predict(future)
|
| 241 |
+
return [{'tanggal': str(r.ds.date()), 'harga_prediksi': round(max(0, r.yhat), 2)}
|
| 242 |
+
for _, r in fc.iterrows()]
|
| 243 |
+
|
| 244 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 245 |
+
# 5. LABEL GENERATOR (untuk frontend)
|
| 246 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 247 |
+
|
| 248 |
+
def get_tren(preds):
|
| 249 |
+
pct = (preds[-1]['harga_prediksi'] - preds[0]['harga_prediksi']) / preds[0]['harga_prediksi'] * 100
|
| 250 |
+
if pct > 2: return 'naik_tajam', pct
|
| 251 |
+
elif pct > 0.5: return 'naik_terkendali', pct
|
| 252 |
+
elif pct < -2: return 'turun_tajam', pct
|
| 253 |
+
elif pct < -0.5: return 'turun_terkendali', pct
|
| 254 |
+
else: return 'stabil', pct
|
| 255 |
+
|
| 256 |
+
def get_labels(mape, rmse, mean_price, cv, preds, komoditas_nama):
|
| 257 |
+
harga_vals = [p['harga_prediksi'] for p in preds]
|
| 258 |
+
tren, pct = get_tren(preds)
|
| 259 |
+
confidence = round(max(0, min(100, 100 - mape * 2)), 2)
|
| 260 |
+
stabilitas = ('optimal' if mape < 2 else 'baik' if mape < 5
|
| 261 |
+
else 'cukup' if mape < 10 else 'perlu_retrain')
|
| 262 |
+
mape_label = ('Sangat Rendah' if mape < 5 else 'Rendah' if mape < 10
|
| 263 |
+
else 'Sedang' if mape < 15 else 'Tinggi' if mape < 25 else 'Sangat Tinggi')
|
| 264 |
+
rmse_pct = rmse / mean_price * 100
|
| 265 |
+
rmse_label = ('Presisi Sangat Tinggi' if rmse_pct < 1 else 'Presisi Tinggi' if rmse_pct < 2
|
| 266 |
+
else 'Presisi Sedang' if rmse_pct < 5 else 'Presisi Rendah')
|
| 267 |
+
cl_label = ('Sangat Tinggi' if confidence >= 90 else 'Tinggi' if confidence >= 80
|
| 268 |
+
else 'Sedang' if confidence >= 65 else 'Rendah')
|
| 269 |
+
vol_label = ('Rendah' if cv < 2 else 'Sedang' if cv < 5 else 'Tinggi')
|
| 270 |
+
|
| 271 |
+
if mape < 10:
|
| 272 |
+
status = {'judul': 'Akurasi Terverifikasi',
|
| 273 |
+
'deskripsi': f'Prediksi divalidasi, deviasi rata-rata di bawah {mape:.1f}%.'}
|
| 274 |
+
elif mape < 20:
|
| 275 |
+
status = {'judul': 'Akurasi Cukup',
|
| 276 |
+
'deskripsi': f'Deviasi rata-rata {mape:.1f}%. Gunakan sebagai referensi.'}
|
| 277 |
+
else:
|
| 278 |
+
status = {'judul': 'Perlu Perhatian',
|
| 279 |
+
'deskripsi': f'Deviasi {mape:.1f}%. Disarankan retraining.'}
|
| 280 |
+
|
| 281 |
+
tren_map = {
|
| 282 |
+
'stabil' : ('positif', 'check-circle', f'Harga {komoditas_nama} diprediksi stabil 7 hari ke depan.'),
|
| 283 |
+
'naik_terkendali' : ('negatif', 'trending-up', f'Harga {komoditas_nama} diprediksi naik {abs(pct):.1f}%.'),
|
| 284 |
+
'naik_tajam' : ('negatif', 'alert-triangle', f'Harga {komoditas_nama} diprediksi naik tajam {abs(pct):.1f}%.'),
|
| 285 |
+
'turun_terkendali': ('positif', 'trending-down', f'Harga {komoditas_nama} diprediksi turun {abs(pct):.1f}%.'),
|
| 286 |
+
'turun_tajam' : ('netral', 'alert-triangle', f'Harga {komoditas_nama} diprediksi turun tajam {abs(pct):.1f}%.'),
|
| 287 |
+
}
|
| 288 |
+
t1, ikon1, k1 = tren_map.get(tren, ('netral', 'info', f'Tren: {tren}'))
|
| 289 |
+
peak = max(preds, key=lambda x: x['harga_prediksi'])
|
| 290 |
+
trough = min(preds, key=lambda x: x['harga_prediksi'])
|
| 291 |
+
|
| 292 |
+
insights = [
|
| 293 |
+
{'tipe': t1, 'ikon': ikon1, 'konten': k1, 'urutan': 1},
|
| 294 |
+
{
|
| 295 |
+
'tipe' : 'positif' if cv < 2 else ('netral' if cv < 5 else 'negatif'),
|
| 296 |
+
'ikon' : 'check-circle' if cv < 2 else 'info',
|
| 297 |
+
'konten': ('Pasokan terpantau mencukupi.' if cv < 2
|
| 298 |
+
else f'Volatilitas sedang (CV={cv:.1f}%).' if cv < 5
|
| 299 |
+
else f'Harga sangat fluktuatif (CV={cv:.1f}%).'),
|
| 300 |
+
'urutan': 2,
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
'tipe' : 'netral',
|
| 304 |
+
'ikon' : 'calendar',
|
| 305 |
+
'konten': (f"Harga tertinggi Rp {peak['harga_prediksi']:,.0f}, "
|
| 306 |
+
f"terendah Rp {trough['harga_prediksi']:,.0f} dalam 7 hari."),
|
| 307 |
+
'urutan': 3,
|
| 308 |
+
},
|
| 309 |
+
]
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
'tren' : tren,
|
| 313 |
+
'pct_change' : round(pct, 2),
|
| 314 |
+
'confidence_level': confidence,
|
| 315 |
+
'confidence_label': cl_label,
|
| 316 |
+
'stabilitas' : stabilitas,
|
| 317 |
+
'mape_label' : mape_label,
|
| 318 |
+
'rmse_label' : rmse_label,
|
| 319 |
+
'volatilitas_label': vol_label,
|
| 320 |
+
'status_analisis' : status,
|
| 321 |
+
'insights' : insights,
|
| 322 |
+
'harga_min' : min(harga_vals),
|
| 323 |
+
'harga_max' : max(harga_vals),
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 327 |
+
# 6. MAIN PIPELINE — dipanggil oleh API
|
| 328 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 329 |
+
|
| 330 |
+
def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
|
| 331 |
+
target_market: str, pasar_id: int, n_trials: int = 50,
|
| 332 |
+
log_cb=None):
|
| 333 |
+
"""
|
| 334 |
+
Full pipeline: cleaning → training → tuning → forecasting → labeling → save.
|
| 335 |
+
log_cb: callback function(msg: str) untuk streaming log ke frontend.
|
| 336 |
+
"""
|
| 337 |
+
def log(msg):
|
| 338 |
+
if log_cb: log_cb(msg)
|
| 339 |
+
|
| 340 |
+
other_cols = [c for c in df.columns if c not in ['date', target_market]
|
| 341 |
+
and not c.endswith('_was_missing')]
|
| 342 |
+
|
| 343 |
+
# ── Cleaning ──
|
| 344 |
+
log("🧹 Cleaning data...")
|
| 345 |
+
all_cols = [target_market] + other_cols
|
| 346 |
+
quality_reports = {}
|
| 347 |
+
for col in all_cols:
|
| 348 |
+
if col in df.columns:
|
| 349 |
+
q = analyze_data_quality(df, col)
|
| 350 |
+
quality_reports[col] = q
|
| 351 |
+
df = clean_price_data(df, col, q)
|
| 352 |
+
|
| 353 |
+
models_to_run = recommend_models(quality_reports.get(target_market, {}))
|
| 354 |
+
log(f"🤖 Model yang akan dijalankan: {models_to_run}")
|
| 355 |
+
|
| 356 |
+
# ── Prepare dataset ──
|
| 357 |
+
exclude = ['date'] + [c for c in df.columns if c.endswith('_was_missing')]
|
| 358 |
+
df_feat = create_features(df, target_market, other_cols)
|
| 359 |
+
df_clean = df_feat.dropna(subset=[target_market]).reset_index(drop=True)
|
| 360 |
+
feature_cols = [c for c in df_clean.columns if c not in exclude + all_cols]
|
| 361 |
+
|
| 362 |
+
X = df_clean[feature_cols]
|
| 363 |
+
y = df_clean[target_market]
|
| 364 |
+
dates = df_clean['date']
|
| 365 |
+
split = int(len(df_clean) * 0.7)
|
| 366 |
+
X_train, X_test = X.iloc[:split], X.iloc[split:]
|
| 367 |
+
y_train, y_test = y.iloc[:split], y.iloc[split:]
|
| 368 |
+
mean_price = float(y.mean())
|
| 369 |
+
|
| 370 |
+
# ── Train ──
|
| 371 |
+
all_results = {}
|
| 372 |
+
lgb_model = xgb_model = prophet_model = None
|
| 373 |
+
|
| 374 |
+
if 'lightgbm' in models_to_run:
|
| 375 |
+
log("Training LightGBM...")
|
| 376 |
+
lgb_model, res = train_lightgbm(X_train, y_train, X_test, y_test)
|
| 377 |
+
all_results['LightGBM'] = {**res, 'obj': lgb_model, 'type': 'tree'}
|
| 378 |
+
|
| 379 |
+
if 'xgboost' in models_to_run:
|
| 380 |
+
log("Training XGBoost...")
|
| 381 |
+
xgb_model, res = train_xgboost(X_train, y_train, X_test, y_test)
|
| 382 |
+
all_results['XGBoost'] = {**res, 'obj': xgb_model, 'type': 'tree'}
|
| 383 |
+
|
| 384 |
+
if 'prophet' in models_to_run:
|
| 385 |
+
log("Training Prophet...")
|
| 386 |
+
try:
|
| 387 |
+
prophet_model, res = train_prophet(dates.iloc[:split], y_train, dates.iloc[split:], y_test)
|
| 388 |
+
all_results['Prophet'] = {**res, 'obj': prophet_model, 'type': 'prophet'}
|
| 389 |
+
except Exception as e:
|
| 390 |
+
log(f"⚠️ Prophet gagal: {e}")
|
| 391 |
+
|
| 392 |
+
if 'arima' in models_to_run:
|
| 393 |
+
log("Training ARIMA...")
|
| 394 |
+
try:
|
| 395 |
+
_, res = train_arima(y_train, y_test)
|
| 396 |
+
all_results['ARIMA'] = {**res, 'obj': None, 'type': 'arima'}
|
| 397 |
+
except Exception as e:
|
| 398 |
+
log(f"⚠️ ARIMA gagal: {e}")
|
| 399 |
+
|
| 400 |
+
best_name = min(all_results, key=lambda k: all_results[k]['rmse'])
|
| 401 |
+
best = all_results[best_name]
|
| 402 |
+
log(f"🏆 Best model: {best_name} (RMSE={best['rmse']:.2f})")
|
| 403 |
+
|
| 404 |
+
# ── Tuning ──
|
| 405 |
+
final_model = best['obj']
|
| 406 |
+
final_result = best
|
| 407 |
+
final_name = best_name
|
| 408 |
+
|
| 409 |
+
if best['type'] == 'tree':
|
| 410 |
+
log(f"🔧 Tuning {best_name} dengan Optuna ({n_trials} trials)...")
|
| 411 |
+
best_params = tune_with_optuna(best_name, X_train, y_train, n_trials)
|
| 412 |
+
if best_name == 'LightGBM':
|
| 413 |
+
tuned = lgb.LGBMRegressor(**best_params, objective='regression',
|
| 414 |
+
verbose=-1, random_state=42, force_col_wise=True)
|
| 415 |
+
else:
|
| 416 |
+
tuned = xgb.XGBRegressor(**best_params, objective='reg:squarederror',
|
| 417 |
+
random_state=42, verbosity=0)
|
| 418 |
+
tuned.fit(X_train, y_train)
|
| 419 |
+
tuned_res = evaluate_model(y_test, tuned.predict(X_test), f"{best_name}_Tuned")
|
| 420 |
+
if tuned_res['rmse'] < best['rmse']:
|
| 421 |
+
final_model = tuned
|
| 422 |
+
final_result = tuned_res
|
| 423 |
+
final_name = f"{best_name}_Tuned"
|
| 424 |
+
log(f"✅ Tuned lebih baik, improvement={(best['rmse']-tuned_res['rmse'])/best['rmse']*100:.1f}%")
|
| 425 |
+
|
| 426 |
+
# ── Forecast 7 hari ──
|
| 427 |
+
log("📅 Forecasting 7 hari...")
|
| 428 |
+
df_hist = df[['date', target_market] + [c for c in other_cols if c in df.columns]].copy()
|
| 429 |
+
if final_result.get('type') == 'prophet' or final_name == 'Prophet':
|
| 430 |
+
predictions = forecast_prophet(final_model, df_hist['date'].max())
|
| 431 |
+
else:
|
| 432 |
+
predictions = forecast_tree(final_model, df_hist, target_market, other_cols, feature_cols)
|
| 433 |
+
|
| 434 |
+
# ── Labels ──
|
| 435 |
+
cv = quality_reports.get(target_market, {}).get('cv', 0)
|
| 436 |
+
labels = get_labels(
|
| 437 |
+
final_result['mape'], final_result['rmse'],
|
| 438 |
+
mean_price, cv, predictions, komoditas_nama
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
# ── Simpan model ──
|
| 442 |
+
prefix = os.path.join(MODEL_DIR, f"model_{komoditas_id}_{pasar_id}")
|
| 443 |
+
model_path= f"{prefix}.pkl"
|
| 444 |
+
with open(model_path, 'wb') as f:
|
| 445 |
+
pickle.dump(final_model, f)
|
| 446 |
+
with open(f"{prefix}_features.json", 'w') as f:
|
| 447 |
+
json.dump(feature_cols, f)
|
| 448 |
+
|
| 449 |
+
metadata = {
|
| 450 |
+
'komoditas_id' : komoditas_id,
|
| 451 |
+
'komoditas_nama' : komoditas_nama,
|
| 452 |
+
'pasar_id' : pasar_id,
|
| 453 |
+
'target_market' : target_market,
|
| 454 |
+
'other_markets' : other_cols,
|
| 455 |
+
'nama_model' : final_name,
|
| 456 |
+
'versi' : '1.0',
|
| 457 |
+
'file_path' : model_path,
|
| 458 |
+
'mape' : round(final_result['mape'], 4),
|
| 459 |
+
'rmse' : round(final_result['rmse'], 4),
|
| 460 |
+
'mae' : round(final_result['mae'], 4),
|
| 461 |
+
'r2_score' : round(final_result['r2'], 4),
|
| 462 |
+
'confidence_level': labels['confidence_level'],
|
| 463 |
+
'stabilitas' : labels['stabilitas'],
|
| 464 |
+
'status_validasi' : 'terverifikasi',
|
| 465 |
+
'catatan_validasi': labels['status_analisis']['deskripsi'],
|
| 466 |
+
'tanggal_training': date.today().isoformat(),
|
| 467 |
+
'tanggal_evaluasi': date.today().isoformat(),
|
| 468 |
+
'deskripsi' : f"Model {final_name} untuk prediksi harga {komoditas_nama}.",
|
| 469 |
+
'models_tried' : list(all_results.keys()),
|
| 470 |
+
'data_quality' : quality_reports.get(target_market, {}),
|
| 471 |
+
}
|
| 472 |
+
with open(f"{prefix}_metadata.json", 'w') as f:
|
| 473 |
+
json.dump(metadata, f, indent=2)
|
| 474 |
+
|
| 475 |
+
log(f"✅ Pipeline selesai! MAPE={final_result['mape']:.2f}%")
|
| 476 |
+
|
| 477 |
+
return {
|
| 478 |
+
'predictions' : predictions,
|
| 479 |
+
'labels' : labels,
|
| 480 |
+
'metadata' : metadata,
|
| 481 |
+
'model_comparison': {k: {'rmse': round(v['rmse'],2), 'mape': round(v['mape'],4)}
|
| 482 |
+
for k, v in all_results.items()},
|
| 483 |
+
}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.29.0
|
| 3 |
+
pandas==2.2.2
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
scikit-learn==1.4.2
|
| 6 |
+
lightgbm==4.3.0
|
| 7 |
+
xgboost==2.0.3
|
| 8 |
+
prophet==1.1.5
|
| 9 |
+
statsmodels==0.14.2
|
| 10 |
+
optuna==3.6.1
|
| 11 |
+
pymysql==1.1.1
|
| 12 |
+
sqlalchemy==2.0.30
|
| 13 |
+
python-dotenv==1.0.1
|
| 14 |
+
apscheduler==3.10.4
|
| 15 |
+
python-multipart==0.0.9
|
frontend/app/dashboard/layout.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/dashboard/layout.tsx
|
| 2 |
+
'use client'
|
| 3 |
+
import { useEffect } from 'react'
|
| 4 |
+
import { useRouter } from 'next/navigation'
|
| 5 |
+
import Link from 'next/link'
|
| 6 |
+
import { BarChart2, Settings, Terminal, Home, LogOut } from 'lucide-react'
|
| 7 |
+
|
| 8 |
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
| 9 |
+
const router = useRouter()
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
if (!localStorage.getItem('sikomo_token')) router.push('/')
|
| 13 |
+
}, [])
|
| 14 |
+
|
| 15 |
+
function logout() {
|
| 16 |
+
localStorage.removeItem('sikomo_token')
|
| 17 |
+
router.push('/')
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const links = [
|
| 21 |
+
{ href: '/dashboard', icon: Home, label: 'Overview' },
|
| 22 |
+
{ href: '/dashboard/prediksi', icon: BarChart2, label: 'Prediksi' },
|
| 23 |
+
{ href: '/dashboard/forecast', icon: Terminal, label: 'Forecast' },
|
| 24 |
+
{ href: '/dashboard/settings', icon: Settings, label: 'Pengaturan' },
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="flex min-h-screen bg-gray-950 text-white">
|
| 29 |
+
{/* Sidebar */}
|
| 30 |
+
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col py-6 px-4 gap-2">
|
| 31 |
+
<div className="text-xl font-bold text-white mb-6 px-2">SIKOMO</div>
|
| 32 |
+
{links.map(l => (
|
| 33 |
+
<Link key={l.href} href={l.href}
|
| 34 |
+
className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-white transition text-sm">
|
| 35 |
+
<l.icon size={16}/> {l.label}
|
| 36 |
+
</Link>
|
| 37 |
+
))}
|
| 38 |
+
<div className="mt-auto">
|
| 39 |
+
<button onClick={logout}
|
| 40 |
+
className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-500 hover:text-red-400 transition text-sm w-full">
|
| 41 |
+
<LogOut size={16}/> Logout
|
| 42 |
+
</button>
|
| 43 |
+
</div>
|
| 44 |
+
</aside>
|
| 45 |
+
<main className="flex-1 p-8 overflow-auto">{children}</main>
|
| 46 |
+
</div>
|
| 47 |
+
)
|
| 48 |
+
}
|
frontend/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/dashboard/page.tsx
|
| 2 |
+
'use client'
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { api } from '@/lib/api'
|
| 5 |
+
|
| 6 |
+
export default function OverviewPage() {
|
| 7 |
+
const [komoditas, setKomoditas] = useState<any[]>([])
|
| 8 |
+
const [scheduler, setScheduler] = useState<any>(null)
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
api.getKomoditas().then(setKomoditas)
|
| 12 |
+
api.getScheduler().then(setScheduler)
|
| 13 |
+
}, [])
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div>
|
| 17 |
+
<h1 className="text-2xl font-bold mb-6">Overview</h1>
|
| 18 |
+
|
| 19 |
+
{/* Stats */}
|
| 20 |
+
<div className="grid grid-cols-3 gap-4 mb-8">
|
| 21 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 22 |
+
<p className="text-gray-400 text-sm">Total Komoditas</p>
|
| 23 |
+
<p className="text-3xl font-bold mt-1">{komoditas.length}</p>
|
| 24 |
+
</div>
|
| 25 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 26 |
+
<p className="text-gray-400 text-sm">Status Scheduler</p>
|
| 27 |
+
<p className={`text-xl font-bold mt-1 ${scheduler?.enabled === 'true' ? 'text-green-400' : 'text-gray-500'}`}>
|
| 28 |
+
{scheduler?.enabled === 'true' ? '🟢 Aktif' : '⚫ Nonaktif'}
|
| 29 |
+
</p>
|
| 30 |
+
</div>
|
| 31 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 32 |
+
<p className="text-gray-400 text-sm">Jadwal Forecast</p>
|
| 33 |
+
<p className="text-xl font-bold mt-1">
|
| 34 |
+
{scheduler ? `${String(scheduler.hour).padStart(2,'0')}:${String(scheduler.minute).padStart(2,'0')}` : '--:--'}
|
| 35 |
+
</p>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* Daftar Komoditas */}
|
| 40 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 41 |
+
<h2 className="font-semibold mb-4">Daftar Komoditas</h2>
|
| 42 |
+
<table className="w-full text-sm">
|
| 43 |
+
<thead>
|
| 44 |
+
<tr className="text-gray-400 border-b border-gray-800">
|
| 45 |
+
<th className="text-left py-2">ID</th>
|
| 46 |
+
<th className="text-left py-2">Nama</th>
|
| 47 |
+
<th className="text-left py-2">Volatilitas</th>
|
| 48 |
+
<th className="text-left py-2">Skor</th>
|
| 49 |
+
</tr>
|
| 50 |
+
</thead>
|
| 51 |
+
<tbody>
|
| 52 |
+
{komoditas.map((k: any) => (
|
| 53 |
+
<tr key={k.id} className="border-b border-gray-800 hover:bg-gray-800/50">
|
| 54 |
+
<td className="py-2 text-gray-400">{k.id}</td>
|
| 55 |
+
<td className="py-2 font-medium">{k.nama}</td>
|
| 56 |
+
<td className="py-2">
|
| 57 |
+
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
| 58 |
+
k.volatile ? 'bg-red-900 text-red-300' : 'bg-green-900 text-green-300'
|
| 59 |
+
}`}>{k.volatile ? 'Tinggi' : 'Rendah'}</span>
|
| 60 |
+
</td>
|
| 61 |
+
<td className="py-2 text-gray-300">{k.volatilitas_skor?.toFixed(2)}</td>
|
| 62 |
+
</tr>
|
| 63 |
+
))}
|
| 64 |
+
</tbody>
|
| 65 |
+
</table>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
)
|
| 69 |
+
}
|
frontend/app/forecast/page.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/dashboard/forecast/page.tsx
|
| 2 |
+
'use client'
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { api } from '@/lib/api'
|
| 5 |
+
import { Play, RefreshCw } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
export default function ForecastPage() {
|
| 8 |
+
const [komoditas, setKomoditas] = useState<any[]>([])
|
| 9 |
+
const [scheduler, setScheduler] = useState<any>(null)
|
| 10 |
+
const [log, setLog] = useState<string[]>([])
|
| 11 |
+
const [loading, setLoading] = useState(false)
|
| 12 |
+
const [hour, setHour] = useState('1')
|
| 13 |
+
const [minute, setMinute] = useState('0')
|
| 14 |
+
const [autoEnabled, setAuto] = useState(false)
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
api.getKomoditas().then(setKomoditas)
|
| 18 |
+
api.getScheduler().then(s => {
|
| 19 |
+
setScheduler(s)
|
| 20 |
+
setHour(s.hour)
|
| 21 |
+
setMinute(s.minute)
|
| 22 |
+
setAuto(s.enabled === 'true')
|
| 23 |
+
})
|
| 24 |
+
fetchLog()
|
| 25 |
+
}, [])
|
| 26 |
+
|
| 27 |
+
async function fetchLog() {
|
| 28 |
+
const res = await api.getLog()
|
| 29 |
+
setLog(res.log)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async function runOne(id: number) {
|
| 33 |
+
setLoading(true)
|
| 34 |
+
await api.runForecast(id)
|
| 35 |
+
setTimeout(fetchLog, 2000)
|
| 36 |
+
setLoading(false)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
async function runAll() {
|
| 40 |
+
setLoading(true)
|
| 41 |
+
await api.runForecastAll()
|
| 42 |
+
setTimeout(fetchLog, 3000)
|
| 43 |
+
setLoading(false)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
async function saveScheduler() {
|
| 47 |
+
await api.updateScheduler({ enabled: autoEnabled, hour: parseInt(hour), minute: parseInt(minute) })
|
| 48 |
+
const s = await api.getScheduler()
|
| 49 |
+
setScheduler(s)
|
| 50 |
+
alert('Scheduler diperbarui!')
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="space-y-6">
|
| 55 |
+
<h1 className="text-2xl font-bold">Forecast Management</h1>
|
| 56 |
+
|
| 57 |
+
{/* Scheduler */}
|
| 58 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 59 |
+
<h2 className="font-semibold mb-4">⏰ Auto Forecast Scheduler</h2>
|
| 60 |
+
<div className="flex items-center gap-4 flex-wrap">
|
| 61 |
+
<label className="flex items-center gap-2 text-sm">
|
| 62 |
+
<input type="checkbox" checked={autoEnabled} onChange={e => setAuto(e.target.checked)}
|
| 63 |
+
className="w-4 h-4 accent-blue-500"/>
|
| 64 |
+
Aktifkan auto forecast harian
|
| 65 |
+
</label>
|
| 66 |
+
<div className="flex items-center gap-2">
|
| 67 |
+
<span className="text-sm text-gray-400">Jam:</span>
|
| 68 |
+
<input type="number" min="0" max="23" value={hour} onChange={e => setHour(e.target.value)}
|
| 69 |
+
className="w-16 px-2 py-1 rounded bg-gray-800 text-white text-sm border border-gray-700"/>
|
| 70 |
+
<span className="text-gray-400">:</span>
|
| 71 |
+
<input type="number" min="0" max="59" value={minute} onChange={e => setMinute(e.target.value)}
|
| 72 |
+
className="w-16 px-2 py-1 rounded bg-gray-800 text-white text-sm border border-gray-700"/>
|
| 73 |
+
</div>
|
| 74 |
+
<button onClick={saveScheduler}
|
| 75 |
+
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm font-medium">
|
| 76 |
+
Simpan
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
<p className="text-gray-500 text-xs mt-3">
|
| 80 |
+
Status: {scheduler?.running ? '🟢 Running' : '⚫ Stopped'} | Jobs: {scheduler?.jobs?.length || 0}
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Manual Trigger */}
|
| 85 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 86 |
+
<div className="flex items-center justify-between mb-4">
|
| 87 |
+
<h2 className="font-semibold">🚀 Manual Forecast</h2>
|
| 88 |
+
<button onClick={runAll} disabled={loading}
|
| 89 |
+
className="flex items-center gap-2 px-4 py-1.5 bg-green-700 hover:bg-green-600 rounded-lg text-sm disabled:opacity-50">
|
| 90 |
+
<Play size={14}/> Run Semua
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="grid grid-cols-2 gap-3">
|
| 94 |
+
{komoditas.map((k: any) => (
|
| 95 |
+
<div key={k.id} className="flex items-center justify-between bg-gray-800 rounded-lg px-4 py-3">
|
| 96 |
+
<span className="text-sm font-medium">{k.nama}</span>
|
| 97 |
+
<button onClick={() => runOne(k.id)} disabled={loading}
|
| 98 |
+
className="flex items-center gap-1 px-3 py-1 bg-blue-700 hover:bg-blue-600 rounded text-xs disabled:opacity-50">
|
| 99 |
+
<Play size={12}/> Run
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Log */}
|
| 107 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 108 |
+
<div className="flex items-center justify-between mb-3">
|
| 109 |
+
<h2 className="font-semibold">📋 Log</h2>
|
| 110 |
+
<button onClick={fetchLog} className="text-gray-400 hover:text-white">
|
| 111 |
+
<RefreshCw size={16}/>
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
<div className="bg-black rounded-lg p-4 h-64 overflow-y-auto font-mono text-xs text-green-400">
|
| 115 |
+
{log.length === 0
|
| 116 |
+
? <span className="text-gray-600">Belum ada log.</span>
|
| 117 |
+
: log.map((l, i) => <div key={i}>{l}</div>)
|
| 118 |
+
}
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
)
|
| 123 |
+
}
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
body {
|
| 6 |
+
background-color: #111827; /* gray-950 */
|
| 7 |
+
color: #f3f4f6; /* gray-100 */
|
| 8 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const inter = Inter({ subsets: ["latin"] });
|
| 6 |
+
|
| 7 |
+
export const metadata: Metadata = {
|
| 8 |
+
title: "SIKOMO ML Dashboard",
|
| 9 |
+
description: "Dashboard Admin Prediksi Komoditas SIKOMO",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export default function RootLayout({
|
| 13 |
+
children,
|
| 14 |
+
}: Readonly<{
|
| 15 |
+
children: React.ReactNode;
|
| 16 |
+
}>) {
|
| 17 |
+
return (
|
| 18 |
+
<html lang="id">
|
| 19 |
+
<body className={inter.className}>{children}</body>
|
| 20 |
+
</html>
|
| 21 |
+
);
|
| 22 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/page.tsx
|
| 2 |
+
'use client'
|
| 3 |
+
import { useState } from 'react'
|
| 4 |
+
import { useRouter } from 'next/navigation'
|
| 5 |
+
import { api } from '@/lib/api'
|
| 6 |
+
|
| 7 |
+
export default function LoginPage() {
|
| 8 |
+
const [pw, setPw] = useState('')
|
| 9 |
+
const [err, setErr] = useState('')
|
| 10 |
+
const [load, setLoad] = useState(false)
|
| 11 |
+
const router = useRouter()
|
| 12 |
+
|
| 13 |
+
async function handleLogin() {
|
| 14 |
+
setLoad(true); setErr('')
|
| 15 |
+
try {
|
| 16 |
+
const res = await api.login(pw)
|
| 17 |
+
localStorage.setItem('sikomo_token', res.token)
|
| 18 |
+
router.push('/dashboard')
|
| 19 |
+
} catch {
|
| 20 |
+
setErr('Password salah.')
|
| 21 |
+
} finally { setLoad(false) }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
| 26 |
+
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 w-full max-w-sm shadow-2xl">
|
| 27 |
+
<h1 className="text-2xl font-bold text-white mb-1">SIKOMO</h1>
|
| 28 |
+
<p className="text-gray-400 text-sm mb-6">Dashboard Admin Prediksi Komoditas</p>
|
| 29 |
+
<label className="text-gray-300 text-sm">Password</label>
|
| 30 |
+
<input
|
| 31 |
+
type="password"
|
| 32 |
+
value={pw}
|
| 33 |
+
onChange={e => setPw(e.target.value)}
|
| 34 |
+
onKeyDown={e => e.key === 'Enter' && handleLogin()}
|
| 35 |
+
className="w-full mt-1 mb-4 px-4 py-2 rounded-lg bg-gray-800 text-white border border-gray-700 focus:outline-none focus:border-blue-500"
|
| 36 |
+
placeholder="••••••••••"
|
| 37 |
+
/>
|
| 38 |
+
{err && <p className="text-red-400 text-sm mb-3">{err}</p>}
|
| 39 |
+
<button
|
| 40 |
+
onClick={handleLogin}
|
| 41 |
+
disabled={load}
|
| 42 |
+
className="w-full py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold transition disabled:opacity-50"
|
| 43 |
+
>
|
| 44 |
+
{load ? 'Masuk...' : 'Masuk'}
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
)
|
| 49 |
+
}
|
frontend/app/prediksi/page.tsx
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/dashboard/prediksi/page.tsx
|
| 2 |
+
'use client'
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { api } from '@/lib/api'
|
| 5 |
+
import { Pencil, Trash2, Plus, Check, X } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
export default function PrediksiPage() {
|
| 8 |
+
const [komoditas, setKomoditas] = useState<any[]>([])
|
| 9 |
+
const [selected, setSelected] = useState<number | null>(null)
|
| 10 |
+
const [data, setData] = useState<any>(null)
|
| 11 |
+
const [editInsight, setEditInsight] = useState<any>(null)
|
| 12 |
+
const [newInsight, setNewInsight] = useState(false)
|
| 13 |
+
const [form, setForm] = useState({ konten:'', tipe:'positif', ikon:'check-circle', urutan:1 })
|
| 14 |
+
const [editRingkasan, setEditRingkasan] = useState(false)
|
| 15 |
+
const [ringForm, setRingForm] = useState({ status_analisis:'', deskripsi_status:'' })
|
| 16 |
+
const [editModel, setEditModel] = useState(false)
|
| 17 |
+
const [modelForm, setModelForm] = useState({ deskripsi:'', catatan_validasi:'' })
|
| 18 |
+
|
| 19 |
+
useEffect(() => { api.getKomoditas().then(setKomoditas) }, [])
|
| 20 |
+
|
| 21 |
+
async function selectKomoditas(id: number) {
|
| 22 |
+
setSelected(id)
|
| 23 |
+
const d = await api.getPrediksi(id)
|
| 24 |
+
setData(d)
|
| 25 |
+
setRingForm({
|
| 26 |
+
status_analisis : d.ringkasan?.status_analisis || '',
|
| 27 |
+
deskripsi_status: d.ringkasan?.deskripsi_status || '',
|
| 28 |
+
})
|
| 29 |
+
setModelForm({
|
| 30 |
+
deskripsi : d.model?.deskripsi || '',
|
| 31 |
+
catatan_validasi: d.model?.catatan_validasi || '',
|
| 32 |
+
})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async function saveInsightEdit() {
|
| 36 |
+
await api.updateInsight(editInsight.id, form)
|
| 37 |
+
setEditInsight(null)
|
| 38 |
+
selectKomoditas(selected!)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function saveNewInsight() {
|
| 42 |
+
await api.addInsight(selected!, form)
|
| 43 |
+
setNewInsight(false)
|
| 44 |
+
setForm({ konten:'', tipe:'positif', ikon:'check-circle', urutan:1 })
|
| 45 |
+
selectKomoditas(selected!)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function deleteInsight(id: number) {
|
| 49 |
+
if (!confirm('Hapus insight ini?')) return
|
| 50 |
+
await api.deleteInsight(id)
|
| 51 |
+
selectKomoditas(selected!)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function saveRingkasan() {
|
| 55 |
+
await api.updateRingkasan(selected!, ringForm)
|
| 56 |
+
setEditRingkasan(false)
|
| 57 |
+
selectKomoditas(selected!)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
async function saveModel() {
|
| 61 |
+
await api.updateModelMl(selected!, modelForm)
|
| 62 |
+
setEditModel(false)
|
| 63 |
+
selectKomoditas(selected!)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const tipeColor: Record<string, string> = {
|
| 67 |
+
positif: 'bg-green-900 text-green-300',
|
| 68 |
+
negatif: 'bg-red-900 text-red-300',
|
| 69 |
+
netral : 'bg-gray-700 text-gray-300',
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="space-y-6">
|
| 74 |
+
<h1 className="text-2xl font-bold">Data Prediksi</h1>
|
| 75 |
+
|
| 76 |
+
{/* Pilih komoditas */}
|
| 77 |
+
<div className="flex gap-2 flex-wrap">
|
| 78 |
+
{komoditas.map((k: any) => (
|
| 79 |
+
<button key={k.id} onClick={() => selectKomoditas(k.id)}
|
| 80 |
+
className={`px-4 py-1.5 rounded-full text-sm font-medium border transition ${
|
| 81 |
+
selected === k.id
|
| 82 |
+
? 'bg-blue-600 border-blue-500 text-white'
|
| 83 |
+
: 'border-gray-700 text-gray-400 hover:border-gray-500'
|
| 84 |
+
}`}>
|
| 85 |
+
{k.nama}
|
| 86 |
+
</button>
|
| 87 |
+
))}
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{data && (
|
| 91 |
+
<div className="grid grid-cols-2 gap-6">
|
| 92 |
+
|
| 93 |
+
{/* Ringkasan Prediksi */}
|
| 94 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 95 |
+
<div className="flex justify-between items-center mb-3">
|
| 96 |
+
<h2 className="font-semibold">Ringkasan Prediksi</h2>
|
| 97 |
+
<button onClick={() => setEditRingkasan(!editRingkasan)}
|
| 98 |
+
className="text-gray-400 hover:text-white"><Pencil size={15}/></button>
|
| 99 |
+
</div>
|
| 100 |
+
{!editRingkasan ? (
|
| 101 |
+
<div className="space-y-2 text-sm">
|
| 102 |
+
<div className="flex justify-between">
|
| 103 |
+
<span className="text-gray-400">Rentang Harga</span>
|
| 104 |
+
<span>Rp {data.ringkasan?.harga_min?.toLocaleString()} – Rp {data.ringkasan?.harga_max?.toLocaleString()}</span>
|
| 105 |
+
</div>
|
| 106 |
+
<div className="flex justify-between">
|
| 107 |
+
<span className="text-gray-400">Tren</span>
|
| 108 |
+
<span>{data.ringkasan?.tren}</span>
|
| 109 |
+
</div>
|
| 110 |
+
<div className="flex justify-between">
|
| 111 |
+
<span className="text-gray-400">Status</span>
|
| 112 |
+
<span>{data.ringkasan?.status_analisis}</span>
|
| 113 |
+
</div>
|
| 114 |
+
<p className="text-gray-500 text-xs mt-2">{data.ringkasan?.deskripsi_status}</p>
|
| 115 |
+
</div>
|
| 116 |
+
) : (
|
| 117 |
+
<div className="space-y-3">
|
| 118 |
+
<div>
|
| 119 |
+
<label className="text-xs text-gray-400">Status Analisis</label>
|
| 120 |
+
<input value={ringForm.status_analisis} onChange={e => setRingForm({...ringForm, status_analisis: e.target.value})}
|
| 121 |
+
className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
|
| 122 |
+
</div>
|
| 123 |
+
<div>
|
| 124 |
+
<label className="text-xs text-gray-400">Deskripsi Status</label>
|
| 125 |
+
<textarea value={ringForm.deskripsi_status} onChange={e => setRingForm({...ringForm, deskripsi_status: e.target.value})}
|
| 126 |
+
rows={3} className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
|
| 127 |
+
</div>
|
| 128 |
+
<div className="flex gap-2">
|
| 129 |
+
<button onClick={saveRingkasan} className="flex items-center gap-1 px-3 py-1 bg-blue-600 rounded text-xs"><Check size={12}/>Simpan</button>
|
| 130 |
+
<button onClick={() => setEditRingkasan(false)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Model ML */}
|
| 137 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 138 |
+
<div className="flex justify-between items-center mb-3">
|
| 139 |
+
<h2 className="font-semibold">Model & Metodologi</h2>
|
| 140 |
+
<button onClick={() => setEditModel(!editModel)} className="text-gray-400 hover:text-white"><Pencil size={15}/></button>
|
| 141 |
+
</div>
|
| 142 |
+
{!editModel ? (
|
| 143 |
+
<div className="space-y-2 text-sm">
|
| 144 |
+
<div className="flex justify-between"><span className="text-gray-400">Model</span><span>{data.model?.nama_model}</span></div>
|
| 145 |
+
<div className="flex justify-between"><span className="text-gray-400">MAPE</span><span>{data.model?.mape}%</span></div>
|
| 146 |
+
<div className="flex justify-between"><span className="text-gray-400">RMSE</span><span>{data.model?.rmse}</span></div>
|
| 147 |
+
<div className="flex justify-between"><span className="text-gray-400">Stabilitas</span>
|
| 148 |
+
<span className="uppercase text-xs font-bold text-purple-300">{data.model?.stabilitas}</span>
|
| 149 |
+
</div>
|
| 150 |
+
<p className="text-gray-500 text-xs mt-2">{data.model?.deskripsi}</p>
|
| 151 |
+
</div>
|
| 152 |
+
) : (
|
| 153 |
+
<div className="space-y-3">
|
| 154 |
+
<div>
|
| 155 |
+
<label className="text-xs text-gray-400">Deskripsi Model</label>
|
| 156 |
+
<textarea value={modelForm.deskripsi} onChange={e => setModelForm({...modelForm, deskripsi: e.target.value})}
|
| 157 |
+
rows={3} className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
|
| 158 |
+
</div>
|
| 159 |
+
<div>
|
| 160 |
+
<label className="text-xs text-gray-400">Catatan Validasi</label>
|
| 161 |
+
<textarea value={modelForm.catatan_validasi} onChange={e => setModelForm({...modelForm, catatan_validasi: e.target.value})}
|
| 162 |
+
rows={2} className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
|
| 163 |
+
</div>
|
| 164 |
+
<div className="flex gap-2">
|
| 165 |
+
<button onClick={saveModel} className="flex items-center gap-1 px-3 py-1 bg-blue-600 rounded text-xs"><Check size={12}/>Simpan</button>
|
| 166 |
+
<button onClick={() => setEditModel(false)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* Insight Editor */}
|
| 173 |
+
<div className="col-span-2 bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 174 |
+
<div className="flex justify-between items-center mb-4">
|
| 175 |
+
<h2 className="font-semibold">Insight Prediktif</h2>
|
| 176 |
+
<button onClick={() => setNewInsight(true)} className="flex items-center gap-1 px-3 py-1.5 bg-green-700 hover:bg-green-600 rounded text-xs">
|
| 177 |
+
<Plus size={13}/> Tambah
|
| 178 |
+
</button>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="space-y-3">
|
| 181 |
+
{(data.insights || []).map((ins: any) => (
|
| 182 |
+
<div key={ins.id}>
|
| 183 |
+
{editInsight?.id === ins.id ? (
|
| 184 |
+
<div className="bg-gray-800 rounded-lg p-3 space-y-2">
|
| 185 |
+
<textarea value={form.konten} onChange={e => setForm({...form, konten: e.target.value})}
|
| 186 |
+
rows={2} className="w-full px-3 py-1.5 rounded bg-gray-700 text-sm text-white border border-gray-600"/>
|
| 187 |
+
<div className="flex gap-2">
|
| 188 |
+
<select value={form.tipe} onChange={e => setForm({...form, tipe: e.target.value})}
|
| 189 |
+
className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600">
|
| 190 |
+
<option>positif</option><option>negatif</option><option>netral</option>
|
| 191 |
+
</select>
|
| 192 |
+
<input value={form.ikon} onChange={e => setForm({...form, ikon: e.target.value})}
|
| 193 |
+
placeholder="ikon (lucide name)" className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600 flex-1"/>
|
| 194 |
+
<input type="number" value={form.urutan} onChange={e => setForm({...form, urutan: parseInt(e.target.value)})}
|
| 195 |
+
className="w-16 px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600"/>
|
| 196 |
+
</div>
|
| 197 |
+
<div className="flex gap-2">
|
| 198 |
+
<button onClick={saveInsightEdit} className="flex items-center gap-1 px-3 py-1 bg-blue-600 rounded text-xs"><Check size={12}/>Simpan</button>
|
| 199 |
+
<button onClick={() => setEditInsight(null)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
) : (
|
| 203 |
+
<div className="flex items-start justify-between bg-gray-800 rounded-lg px-4 py-3">
|
| 204 |
+
<div className="flex items-start gap-3">
|
| 205 |
+
<span className={`text-xs px-2 py-0.5 rounded-full mt-0.5 ${tipeColor[ins.tipe] || tipeColor.netral}`}>{ins.tipe}</span>
|
| 206 |
+
<p className="text-sm">{ins.konten}</p>
|
| 207 |
+
</div>
|
| 208 |
+
<div className="flex gap-2 ml-4 shrink-0">
|
| 209 |
+
<button onClick={() => { setEditInsight(ins); setForm({ konten: ins.konten, tipe: ins.tipe, ikon: ins.ikon, urutan: ins.urutan }) }}
|
| 210 |
+
className="text-gray-400 hover:text-white"><Pencil size={14}/></button>
|
| 211 |
+
<button onClick={() => deleteInsight(ins.id)} className="text-gray-400 hover:text-red-400"><Trash2 size={14}/></button>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
</div>
|
| 216 |
+
))}
|
| 217 |
+
|
| 218 |
+
{/* Form tambah baru */}
|
| 219 |
+
{newInsight && (
|
| 220 |
+
<div className="bg-gray-800 rounded-lg p-3 space-y-2 border border-green-800">
|
| 221 |
+
<textarea value={form.konten} onChange={e => setForm({...form, konten: e.target.value})}
|
| 222 |
+
rows={2} placeholder="Isi insight..." className="w-full px-3 py-1.5 rounded bg-gray-700 text-sm text-white border border-gray-600"/>
|
| 223 |
+
<div className="flex gap-2">
|
| 224 |
+
<select value={form.tipe} onChange={e => setForm({...form, tipe: e.target.value})}
|
| 225 |
+
className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600">
|
| 226 |
+
<option>positif</option><option>negatif</option><option>netral</option>
|
| 227 |
+
</select>
|
| 228 |
+
<input value={form.ikon} onChange={e => setForm({...form, ikon: e.target.value})}
|
| 229 |
+
placeholder="ikon" className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600 flex-1"/>
|
| 230 |
+
<input type="number" value={form.urutan} onChange={e => setForm({...form, urutan: parseInt(e.target.value)})}
|
| 231 |
+
className="w-16 px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600"/>
|
| 232 |
+
</div>
|
| 233 |
+
<div className="flex gap-2">
|
| 234 |
+
<button onClick={saveNewInsight} className="flex items-center gap-1 px-3 py-1 bg-green-700 rounded text-xs"><Check size={12}/>Tambah</button>
|
| 235 |
+
<button onClick={() => setNewInsight(false)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
{/* Hasil Prediksi 7 Hari (read-only) */}
|
| 243 |
+
<div className="col-span-2 bg-gray-900 border border-gray-800 rounded-xl p-5">
|
| 244 |
+
<h2 className="font-semibold mb-4">Hasil Prediksi 7 Hari <span className="text-gray-500 text-xs font-normal">(read-only)</span></h2>
|
| 245 |
+
<div className="overflow-x-auto">
|
| 246 |
+
<table className="w-full text-sm">
|
| 247 |
+
<thead><tr className="text-gray-400 border-b border-gray-800">
|
| 248 |
+
<th className="text-left py-2">Tanggal</th>
|
| 249 |
+
<th className="text-right py-2">Harga Prediksi</th>
|
| 250 |
+
<th className="text-right py-2">Confidence</th>
|
| 251 |
+
</tr></thead>
|
| 252 |
+
<tbody>
|
| 253 |
+
{(data.prediksi_7hari || []).map((p: any, i: number) => (
|
| 254 |
+
<tr key={i} className="border-b border-gray-800">
|
| 255 |
+
<td className="py-2">{p.tanggal_target}</td>
|
| 256 |
+
<td className="py-2 text-right font-mono">Rp {Number(p.harga_prediksi).toLocaleString()}</td>
|
| 257 |
+
<td className="py-2 text-right text-gray-400">{p.confidence_level}%</td>
|
| 258 |
+
</tr>
|
| 259 |
+
))}
|
| 260 |
+
</tbody>
|
| 261 |
+
</table>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
)
|
| 268 |
+
}
|
frontend/app/settings/page.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/dashboard/settings/page.tsx
|
| 2 |
+
'use client'
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { api } from '@/lib/api'
|
| 5 |
+
import { Save } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
const ENV_LABELS: Record<string, string> = {
|
| 8 |
+
DB_HOST : 'Database Host',
|
| 9 |
+
DB_PORT : 'Database Port',
|
| 10 |
+
DB_NAME : 'Database Name',
|
| 11 |
+
DB_USER : 'Database User',
|
| 12 |
+
DB_PASSWORD : 'Database Password',
|
| 13 |
+
AUTO_FORECAST_ENABLED : 'Auto Forecast Aktif',
|
| 14 |
+
AUTO_FORECAST_HOUR : 'Jam Forecast (0-23)',
|
| 15 |
+
AUTO_FORECAST_MINUTE : 'Menit Forecast (0-59)',
|
| 16 |
+
OPTUNA_TRIALS : 'Jumlah Optuna Trials',
|
| 17 |
+
MODEL_DIR : 'Folder Model',
|
| 18 |
+
API_SECRET_KEY : 'API Secret Key',
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function SettingsPage() {
|
| 22 |
+
const [envData, setEnvData] = useState<Record<string, string>>({})
|
| 23 |
+
const [saved, setSaved] = useState<string | null>(null)
|
| 24 |
+
|
| 25 |
+
useEffect(() => { api.getEnv().then(setEnvData) }, [])
|
| 26 |
+
|
| 27 |
+
async function save(key: string) {
|
| 28 |
+
await api.updateEnv(key, envData[key] || '')
|
| 29 |
+
setSaved(key)
|
| 30 |
+
setTimeout(() => setSaved(null), 2000)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div className="max-w-2xl space-y-6">
|
| 35 |
+
<h1 className="text-2xl font-bold">Pengaturan</h1>
|
| 36 |
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6 space-y-4">
|
| 37 |
+
<p className="text-gray-400 text-sm">Edit konfigurasi environment HuggingFace Space. Perubahan langsung berlaku.</p>
|
| 38 |
+
{Object.entries(ENV_LABELS).map(([key, label]) => (
|
| 39 |
+
<div key={key} className="flex items-center gap-3">
|
| 40 |
+
<div className="flex-1">
|
| 41 |
+
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
| 42 |
+
<input
|
| 43 |
+
type={key.includes('PASSWORD') || key.includes('SECRET') ? 'password' : 'text'}
|
| 44 |
+
value={envData[key] || ''}
|
| 45 |
+
onChange={e => setEnvData({...envData, [key]: e.target.value})}
|
| 46 |
+
className="w-full px-3 py-2 rounded-lg bg-gray-800 text-white text-sm border border-gray-700 focus:outline-none focus:border-blue-500"
|
| 47 |
+
/>
|
| 48 |
+
</div>
|
| 49 |
+
<button onClick={() => save(key)}
|
| 50 |
+
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-xs font-medium mt-5 transition ${
|
| 51 |
+
saved === key ? 'bg-green-700 text-white' : 'bg-gray-700 hover:bg-gray-600 text-gray-300'
|
| 52 |
+
}`}>
|
| 53 |
+
<Save size={13}/> {saved === key ? 'Tersimpan' : 'Simpan'}
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
))}
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
)
|
| 60 |
+
}
|
frontend/lib/api.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// lib/api.ts — semua pemanggilan ke HuggingFace API
|
| 2 |
+
const BASE = process.env.NEXT_PUBLIC_API_URL || ''
|
| 3 |
+
|
| 4 |
+
function getToken() {
|
| 5 |
+
if (typeof window !== 'undefined') return localStorage.getItem('sikomo_token') || ''
|
| 6 |
+
return ''
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
async function req(path: string, opts: RequestInit = {}) {
|
| 10 |
+
const res = await fetch(`${BASE}${path}`, {
|
| 11 |
+
...opts,
|
| 12 |
+
headers: {
|
| 13 |
+
'Content-Type': 'application/json',
|
| 14 |
+
'Authorization': `Bearer ${getToken()}`,
|
| 15 |
+
...opts.headers,
|
| 16 |
+
},
|
| 17 |
+
})
|
| 18 |
+
if (!res.ok) {
|
| 19 |
+
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
| 20 |
+
throw new Error(err.detail || 'Request gagal')
|
| 21 |
+
}
|
| 22 |
+
return res.json()
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export const api = {
|
| 26 |
+
login : (password: string) => req('/auth/login', { method:'POST', body: JSON.stringify({ password }) }),
|
| 27 |
+
getKomoditas : () => req('/komoditas'),
|
| 28 |
+
getPrediksi : (id: number) => req(`/prediksi/${id}`),
|
| 29 |
+
runForecast : (komoditas_id: number) => req('/forecast/run', { method:'POST', body: JSON.stringify({ komoditas_id }) }),
|
| 30 |
+
runForecastAll : () => req('/forecast/run-all', { method:'POST' }),
|
| 31 |
+
getLog : () => req('/forecast/log'),
|
| 32 |
+
getScheduler : () => req('/scheduler/status'),
|
| 33 |
+
updateScheduler : (body: object) => req('/scheduler/config', { method:'POST', body: JSON.stringify(body) }),
|
| 34 |
+
getEnv : () => req('/env'),
|
| 35 |
+
updateEnv : (key: string, value: string) => req('/env', { method:'POST', body: JSON.stringify({ key, value }) }),
|
| 36 |
+
getInsights : (id: number) => req(`/insight/${id}`),
|
| 37 |
+
updateInsight : (id: number, body: object) => req(`/insight/${id}`, { method:'PUT', body: JSON.stringify(body) }),
|
| 38 |
+
addInsight : (id: number, body: object) => req(`/insight/${id}`, { method:'POST', body: JSON.stringify(body) }),
|
| 39 |
+
deleteInsight : (id: number) => req(`/insight/${id}`, { method:'DELETE' }),
|
| 40 |
+
updateRingkasan : (id: number, body: object) => req(`/ringkasan/${id}`, { method:'PUT', body: JSON.stringify(body) }),
|
| 41 |
+
updateModelMl : (id: number, body: object) => req(`/model-ml/${id}`, { method:'PUT', body: JSON.stringify(body) }),
|
| 42 |
+
}
|
frontend/next.config.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
output: 'export',
|
| 4 |
+
trailingSlash: true,
|
| 5 |
+
env: {
|
| 6 |
+
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
| 7 |
+
},
|
| 8 |
+
}
|
| 9 |
+
module.exports = nextConfig
|
frontend/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sikomo-hf-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "14.2.3",
|
| 13 |
+
"react": "^18",
|
| 14 |
+
"react-dom": "^18",
|
| 15 |
+
"lucide-react": "^0.378.0"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@types/node": "^20",
|
| 19 |
+
"@types/react": "^18",
|
| 20 |
+
"@types/react-dom": "^18",
|
| 21 |
+
"postcss": "^8",
|
| 22 |
+
"tailwindcss": "^3.4.1",
|
| 23 |
+
"typescript": "^5"
|
| 24 |
+
}
|
| 25 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/tailwind.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
content: [
|
| 5 |
+
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
| 6 |
+
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
| 7 |
+
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
| 8 |
+
],
|
| 9 |
+
theme: {
|
| 10 |
+
extend: {},
|
| 11 |
+
},
|
| 12 |
+
plugins: [],
|
| 13 |
+
};
|
| 14 |
+
export default config;
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 4 |
+
"allowJs": true,
|
| 5 |
+
"skipLibCheck": true,
|
| 6 |
+
"strict": true,
|
| 7 |
+
"noEmit": true,
|
| 8 |
+
"esModuleInterop": true,
|
| 9 |
+
"module": "esnext",
|
| 10 |
+
"moduleResolution": "bundler",
|
| 11 |
+
"resolveJsonModule": true,
|
| 12 |
+
"isolatedModules": true,
|
| 13 |
+
"jsx": "preserve",
|
| 14 |
+
"incremental": true,
|
| 15 |
+
"plugins": [
|
| 16 |
+
{
|
| 17 |
+
"name": "next"
|
| 18 |
+
}
|
| 19 |
+
],
|
| 20 |
+
"paths": {
|
| 21 |
+
"@/*": ["./*"]
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
| 25 |
+
"exclude": ["node_modules"]
|
| 26 |
+
}
|