Spaces:
Sleeping
Sleeping
anoderb commited on
Commit ·
bdef324
1
Parent(s): a75a367
Modernisasi Forecast Dashboard: Glassmorphism UI, SARIMA Model, and Backend Refactor
Browse files- backend/config.py +66 -0
- backend/database.py +56 -5
- backend/logger.py +61 -0
- backend/main.py +238 -158
- backend/ml_pipeline.py +97 -21
- backend/requirements.txt +3 -1
- backend/scheduler.py +161 -0
- frontend/app/dashboard/forecast/page.tsx +253 -91
- frontend/app/dashboard/layout.tsx +120 -24
- frontend/app/dashboard/page.tsx +116 -45
- frontend/app/dashboard/prediksi/page.tsx +137 -161
- frontend/app/dashboard/settings/page.tsx +80 -43
- frontend/app/globals.css +49 -2
- frontend/app/page.tsx +61 -26
- frontend/lib/api.ts +35 -17
backend/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# config.py — Centralized configuration using pydantic-settings
|
| 2 |
+
# .env hanya menyimpan DB credentials & secrets
|
| 3 |
+
# Runtime config bisa diubah via API tanpa restart
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pydantic_settings import BaseSettings
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
"""Database & secrets — loaded from .env file."""
|
| 12 |
+
DB_HOST: str = "localhost"
|
| 13 |
+
DB_PORT: int = 3306
|
| 14 |
+
DB_NAME: str = "sikomo_db"
|
| 15 |
+
DB_USER: str = "root"
|
| 16 |
+
DB_PASSWORD: str = ""
|
| 17 |
+
API_SECRET_KEY: str = "default-key"
|
| 18 |
+
DASHBOARD_PASSWORD: str = "Bandulan112"
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def DATABASE_URL(self) -> str:
|
| 22 |
+
return (
|
| 23 |
+
f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}"
|
| 24 |
+
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
class Config:
|
| 28 |
+
env_file = ".env"
|
| 29 |
+
extra = "ignore"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@lru_cache()
|
| 33 |
+
def get_settings() -> Settings:
|
| 34 |
+
return Settings()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class RuntimeConfig:
|
| 38 |
+
"""Runtime configuration — can be changed via dashboard API without restart."""
|
| 39 |
+
|
| 40 |
+
def __init__(self):
|
| 41 |
+
self.optuna_trials: int = int(os.getenv("OPTUNA_TRIALS", "30"))
|
| 42 |
+
self.model_dir: str = os.getenv("MODEL_DIR", "models")
|
| 43 |
+
self.forecast_days: int = int(os.getenv("FORECAST_DAYS", "7"))
|
| 44 |
+
self.default_schedules: str = os.getenv("DEFAULT_SCHEDULES", "0 1 * * *")
|
| 45 |
+
|
| 46 |
+
def to_dict(self) -> dict:
|
| 47 |
+
return {
|
| 48 |
+
"optuna_trials": self.optuna_trials,
|
| 49 |
+
"model_dir": self.model_dir,
|
| 50 |
+
"forecast_days": self.forecast_days,
|
| 51 |
+
"default_schedules": self.default_schedules,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
def update(self, key: str, value: str):
|
| 55 |
+
allowed = {
|
| 56 |
+
"optuna_trials": lambda v: setattr(self, "optuna_trials", int(v)),
|
| 57 |
+
"model_dir": lambda v: setattr(self, "model_dir", v),
|
| 58 |
+
"forecast_days": lambda v: setattr(self, "forecast_days", int(v)),
|
| 59 |
+
"default_schedules": lambda v: setattr(self, "default_schedules", v),
|
| 60 |
+
}
|
| 61 |
+
if key not in allowed:
|
| 62 |
+
raise ValueError(f"Key '{key}' tidak diizinkan untuk diubah.")
|
| 63 |
+
allowed[key](value)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
runtime_config = RuntimeConfig()
|
backend/database.py
CHANGED
|
@@ -195,6 +195,44 @@ def update_komoditas_volatilitas(komoditas_id: int, volatile: int, skor: float):
|
|
| 195 |
WHERE id=:kid
|
| 196 |
"""), {'v': volatile, 's': skor, 'kid': komoditas_id})
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
# ── READ untuk API response ───────────────────────────────────────────────────
|
| 199 |
|
| 200 |
def get_prediksi_data(komoditas_id: int):
|
|
@@ -218,12 +256,24 @@ def get_prediksi_data(komoditas_id: int):
|
|
| 218 |
""", engine).to_dict('records')
|
| 219 |
harga_terkini = harga_terkini[0] if harga_terkini else None
|
| 220 |
|
|
|
|
| 221 |
model = pd.read_sql(f"""
|
| 222 |
SELECT * FROM model_ml WHERE komoditas_id={komoditas_id} AND is_active=1
|
| 223 |
ORDER BY updated_at DESC LIMIT 1
|
| 224 |
""", engine).to_dict('records')
|
| 225 |
model = model[0] if model else None
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
ringkasan = pd.read_sql(f"""
|
| 228 |
SELECT * FROM ringkasan_prediksi WHERE komoditas_id={komoditas_id}
|
| 229 |
ORDER BY created_at DESC LIMIT 1
|
|
@@ -244,10 +294,11 @@ def get_prediksi_data(komoditas_id: int):
|
|
| 244 |
""", engine).to_dict('records')
|
| 245 |
|
| 246 |
return {
|
| 247 |
-
'komoditas'
|
| 248 |
-
'harga_terkini': harga_terkini,
|
| 249 |
-
'model'
|
| 250 |
-
'
|
|
|
|
| 251 |
'prediksi_7hari': hasil,
|
| 252 |
-
'insights'
|
| 253 |
}
|
|
|
|
| 195 |
WHERE id=:kid
|
| 196 |
"""), {'v': volatile, 's': skor, 'kid': komoditas_id})
|
| 197 |
|
| 198 |
+
|
| 199 |
+
def save_all_model_results(all_results: dict, best_meta: dict, komoditas_id: int, pasar_id: int):
|
| 200 |
+
"""Save ALL model results to model_ml (not just best). Best gets is_active=1."""
|
| 201 |
+
engine = get_engine()
|
| 202 |
+
best_name = best_meta.get('nama_model', '')
|
| 203 |
+
with engine.begin() as conn:
|
| 204 |
+
# Deactivate old models for this komoditas
|
| 205 |
+
conn.execute(text(
|
| 206 |
+
"UPDATE model_ml SET is_active=0 WHERE komoditas_id=:kid"
|
| 207 |
+
), {'kid': komoditas_id})
|
| 208 |
+
# Insert each model result
|
| 209 |
+
for model_name, metrics in all_results.items():
|
| 210 |
+
is_best = 1 if model_name == best_name or f"{model_name}_Tuned" == best_name else 0
|
| 211 |
+
conn.execute(text("""
|
| 212 |
+
INSERT INTO model_ml (
|
| 213 |
+
komoditas_id, nama_model, versi, deskripsi, file_path,
|
| 214 |
+
mape, rmse, mae, r2_score, confidence_level,
|
| 215 |
+
stabilitas, status_validasi, catatan_validasi,
|
| 216 |
+
tanggal_training, tanggal_evaluasi, is_active,
|
| 217 |
+
created_at, updated_at
|
| 218 |
+
) VALUES (
|
| 219 |
+
:komoditas_id, :nama_model, '1.0', :deskripsi, '',
|
| 220 |
+
:mape, :rmse, :mae, :r2_score, 0,
|
| 221 |
+
'', 'otomatis', '',
|
| 222 |
+
CURDATE(), CURDATE(), :is_active, NOW(), NOW()
|
| 223 |
+
)
|
| 224 |
+
"""), {
|
| 225 |
+
'komoditas_id': komoditas_id,
|
| 226 |
+
'nama_model': model_name,
|
| 227 |
+
'deskripsi': f"Model {model_name} untuk komoditas ID {komoditas_id}",
|
| 228 |
+
'mape': round(metrics.get('mape', 0), 4),
|
| 229 |
+
'rmse': round(metrics.get('rmse', 0), 4),
|
| 230 |
+
'mae': round(metrics.get('mae', 0), 4),
|
| 231 |
+
'r2_score': round(metrics.get('r2', 0), 4),
|
| 232 |
+
'is_active': is_best,
|
| 233 |
+
})
|
| 234 |
+
|
| 235 |
+
|
| 236 |
# ── READ untuk API response ───────────────────────────────────────────────────
|
| 237 |
|
| 238 |
def get_prediksi_data(komoditas_id: int):
|
|
|
|
| 256 |
""", engine).to_dict('records')
|
| 257 |
harga_terkini = harga_terkini[0] if harga_terkini else None
|
| 258 |
|
| 259 |
+
# Best (active) model
|
| 260 |
model = pd.read_sql(f"""
|
| 261 |
SELECT * FROM model_ml WHERE komoditas_id={komoditas_id} AND is_active=1
|
| 262 |
ORDER BY updated_at DESC LIMIT 1
|
| 263 |
""", engine).to_dict('records')
|
| 264 |
model = model[0] if model else None
|
| 265 |
|
| 266 |
+
# ALL models for comparison (latest run)
|
| 267 |
+
all_models = pd.read_sql(f"""
|
| 268 |
+
SELECT nama_model, mape, rmse, mae, r2_score, is_active,
|
| 269 |
+
tanggal_training, created_at
|
| 270 |
+
FROM model_ml WHERE komoditas_id={komoditas_id}
|
| 271 |
+
AND DATE(created_at) = (
|
| 272 |
+
SELECT DATE(MAX(created_at)) FROM model_ml WHERE komoditas_id={komoditas_id}
|
| 273 |
+
)
|
| 274 |
+
ORDER BY rmse ASC
|
| 275 |
+
""", engine).to_dict('records')
|
| 276 |
+
|
| 277 |
ringkasan = pd.read_sql(f"""
|
| 278 |
SELECT * FROM ringkasan_prediksi WHERE komoditas_id={komoditas_id}
|
| 279 |
ORDER BY created_at DESC LIMIT 1
|
|
|
|
| 294 |
""", engine).to_dict('records')
|
| 295 |
|
| 296 |
return {
|
| 297 |
+
'komoditas' : komoditas,
|
| 298 |
+
'harga_terkini' : harga_terkini,
|
| 299 |
+
'model' : model,
|
| 300 |
+
'all_models' : all_models,
|
| 301 |
+
'ringkasan' : ringkasan,
|
| 302 |
'prediksi_7hari': hasil,
|
| 303 |
+
'insights' : insights,
|
| 304 |
}
|
backend/logger.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# logger.py — In-memory log handler for realtime log streaming
|
| 2 |
+
# Ported from Daily Scrapper architecture
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import List, Dict, Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MemoryLogHandler(logging.Handler):
|
| 10 |
+
"""Stores log entries in memory for API consumption (realtime streaming)."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, capacity: int = 500):
|
| 13 |
+
super().__init__()
|
| 14 |
+
self.capacity = capacity
|
| 15 |
+
self.logs: List[Dict] = []
|
| 16 |
+
|
| 17 |
+
def emit(self, record):
|
| 18 |
+
try:
|
| 19 |
+
msg = record.getMessage()
|
| 20 |
+
log_entry = {
|
| 21 |
+
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
| 22 |
+
"level": record.levelname,
|
| 23 |
+
"source": record.name.split(".")[-1],
|
| 24 |
+
"message": msg,
|
| 25 |
+
}
|
| 26 |
+
self.logs.append(log_entry)
|
| 27 |
+
if len(self.logs) > self.capacity:
|
| 28 |
+
self.logs.pop(0)
|
| 29 |
+
except Exception:
|
| 30 |
+
self.handleError(record)
|
| 31 |
+
|
| 32 |
+
def get_logs(
|
| 33 |
+
self, limit: int = 100, level: Optional[str] = None
|
| 34 |
+
) -> List[Dict]:
|
| 35 |
+
filtered = self.logs
|
| 36 |
+
if level and level.upper() != "ALL":
|
| 37 |
+
filtered = [l for l in filtered if l["level"] == level.upper()]
|
| 38 |
+
return filtered[-limit:]
|
| 39 |
+
|
| 40 |
+
def clear_logs(self):
|
| 41 |
+
self.logs.clear()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# Singleton handler shared across all modules
|
| 45 |
+
memory_handler = MemoryLogHandler()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_app_logger(name: str) -> logging.Logger:
|
| 49 |
+
"""Get a named logger that writes to both console and memory buffer."""
|
| 50 |
+
logger = logging.getLogger(f"app.{name}")
|
| 51 |
+
logger.setLevel(logging.INFO)
|
| 52 |
+
# Ensure memory handler is added only once
|
| 53 |
+
if not any(isinstance(h, MemoryLogHandler) for h in logger.handlers):
|
| 54 |
+
logger.addHandler(memory_handler)
|
| 55 |
+
# Also add console handler for local debugging
|
| 56 |
+
if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, MemoryLogHandler) for h in logger.handlers):
|
| 57 |
+
console = logging.StreamHandler()
|
| 58 |
+
console.setLevel(logging.INFO)
|
| 59 |
+
console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
| 60 |
+
logger.addHandler(console)
|
| 61 |
+
return logger
|
backend/main.py
CHANGED
|
@@ -1,26 +1,31 @@
|
|
| 1 |
-
# main.py — FastAPI:
|
| 2 |
-
|
|
|
|
|
|
|
| 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(
|
|
@@ -32,214 +37,291 @@ app.add_middleware(
|
|
| 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 !=
|
| 39 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 40 |
return creds.credentials
|
| 41 |
|
| 42 |
-
# ──
|
| 43 |
-
|
| 44 |
-
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
try:
|
| 49 |
komoditas_list = get_all_komoditas()
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
run_forecast_for_komoditas(int(row['id']), row['nama'])
|
| 52 |
-
|
| 53 |
except Exception as e:
|
| 54 |
-
|
| 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 |
-
|
| 63 |
-
|
| 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 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
class ForecastRequest(BaseModel):
|
| 117 |
komoditas_id: int
|
| 118 |
|
| 119 |
class InsightUpdate(BaseModel):
|
| 120 |
-
konten
|
| 121 |
-
tipe
|
| 122 |
-
ikon
|
| 123 |
-
urutan
|
| 124 |
|
| 125 |
class RingkasanUpdate(BaseModel):
|
| 126 |
-
status_analisis
|
| 127 |
-
deskripsi_status
|
| 128 |
-
tren
|
| 129 |
|
| 130 |
class ModelUpdate(BaseModel):
|
| 131 |
-
deskripsi
|
| 132 |
catatan_validasi: Optional[str] = None
|
| 133 |
|
| 134 |
-
class
|
| 135 |
-
key
|
| 136 |
value: str
|
| 137 |
|
| 138 |
-
class
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
|
| 143 |
-
#
|
|
|
|
|
|
|
| 144 |
|
| 145 |
@app.get("/health")
|
| 146 |
def health():
|
| 147 |
return {"status": "ok", "time": datetime.now().isoformat()}
|
| 148 |
|
| 149 |
-
#
|
| 150 |
@app.post("/auth/login")
|
| 151 |
def login(body: dict):
|
| 152 |
-
if body.get('password') !=
|
| 153 |
raise HTTPException(status_code=401, detail="Password salah")
|
| 154 |
-
return {"token":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
return {"message": "Forecast semua komoditas dimulai."}
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
| 187 |
|
| 188 |
-
#
|
| 189 |
-
@app.get("/
|
| 190 |
-
def
|
| 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("/
|
| 200 |
-
def
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
@app.get("/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 237 |
def get_insights(komoditas_id: int):
|
| 238 |
engine = get_engine()
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
| 243 |
|
| 244 |
@app.put("/insight/{insight_id}", dependencies=[Depends(verify_token)])
|
| 245 |
def update_insight(insight_id: int, body: InsightUpdate):
|
|
@@ -269,7 +351,7 @@ def delete_insight(insight_id: int):
|
|
| 269 |
conn.execute(text("DELETE FROM insight_prediksi WHERE id=:id"), {'id': insight_id})
|
| 270 |
return {"message": "Insight dihapus."}
|
| 271 |
|
| 272 |
-
# CRUD
|
| 273 |
@app.put("/ringkasan/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 274 |
def update_ringkasan(komoditas_id: int, body: RingkasanUpdate):
|
| 275 |
engine = get_engine()
|
|
@@ -284,7 +366,7 @@ def update_ringkasan(komoditas_id: int, body: RingkasanUpdate):
|
|
| 284 |
)
|
| 285 |
return {"message": "Ringkasan diperbarui."}
|
| 286 |
|
| 287 |
-
# CRUD
|
| 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()
|
|
@@ -299,8 +381,6 @@ def update_model_ml(komoditas_id: int, body: ModelUpdate):
|
|
| 299 |
)
|
| 300 |
return {"message": "Model ML diperbarui."}
|
| 301 |
|
| 302 |
-
#
|
| 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")
|
|
|
|
| 1 |
+
# main.py — FastAPI: Refactored with modular config, logger, scheduler
|
| 2 |
+
# Endpoints: forecast, CRUD, scheduler, logs, settings
|
| 3 |
+
import os
|
| 4 |
+
import threading
|
| 5 |
from datetime import datetime
|
| 6 |
from typing import Optional
|
| 7 |
+
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, Query
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
from config import get_settings, runtime_config
|
| 14 |
+
from logger import get_app_logger, memory_handler
|
| 15 |
+
from scheduler import forecast_scheduler
|
| 16 |
from database import (
|
| 17 |
get_all_komoditas, get_harga_harian, get_pasar_list,
|
| 18 |
get_prediksi_data, save_model_ml, save_hasil_prediksi,
|
| 19 |
save_ringkasan_prediksi, save_insight_prediksi,
|
| 20 |
+
update_komoditas_volatilitas, get_engine,
|
| 21 |
+
save_all_model_results,
|
| 22 |
)
|
| 23 |
from ml_pipeline import run_pipeline
|
| 24 |
from sqlalchemy import text
|
| 25 |
|
| 26 |
+
settings = get_settings()
|
| 27 |
+
logger = get_app_logger("main")
|
| 28 |
+
|
| 29 |
app = FastAPI(title="SIKOMO ML API")
|
| 30 |
|
| 31 |
app.add_middleware(
|
|
|
|
| 37 |
|
| 38 |
# ── Auth ──────────────────────────────────────────────────────────────────────
|
| 39 |
security = HTTPBearer()
|
|
|
|
| 40 |
|
| 41 |
def verify_token(creds: HTTPAuthorizationCredentials = Depends(security)):
|
| 42 |
+
if creds.credentials != settings.API_SECRET_KEY:
|
| 43 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 44 |
return creds.credentials
|
| 45 |
|
| 46 |
+
# ── State tracking ────────────────────────────────────────────────────────────
|
| 47 |
+
running_tasks: dict = {} # komoditas_id -> True/False
|
| 48 |
+
stop_event = threading.Event() # For aborting forecast
|
| 49 |
|
| 50 |
+
|
| 51 |
+
def run_forecast_for_komoditas(komoditas_id: int, komoditas_nama: str):
|
| 52 |
+
"""Run forecast for a single komoditas. Tracks running state."""
|
| 53 |
+
if running_tasks.get(komoditas_id):
|
| 54 |
+
logger.warning(f"⚠️ {komoditas_nama} sudah sedang berjalan, skip.")
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
running_tasks[komoditas_id] = True
|
| 58 |
+
try:
|
| 59 |
+
if stop_event.is_set():
|
| 60 |
+
logger.warning(f"⛔ Forecast dihentikan sebelum {komoditas_nama}.")
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
df = get_harga_harian(komoditas_id)
|
| 64 |
+
if df.empty or len(df) < 60:
|
| 65 |
+
logger.warning(f"⚠️ {komoditas_nama}: data kurang ({len(df)} baris)")
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
price_cols = [c for c in df.columns if c != 'date']
|
| 69 |
+
target_col = price_cols[0]
|
| 70 |
+
|
| 71 |
+
pasar_df = get_pasar_list(komoditas_id)
|
| 72 |
+
pasar_id = int(pasar_df.iloc[0]['id']) if not pasar_df.empty else 1
|
| 73 |
+
|
| 74 |
+
def log_cb(msg):
|
| 75 |
+
logger.info(f"[{komoditas_nama}] {msg}")
|
| 76 |
+
|
| 77 |
+
result = run_pipeline(
|
| 78 |
+
komoditas_id=komoditas_id,
|
| 79 |
+
komoditas_nama=komoditas_nama,
|
| 80 |
+
df=df,
|
| 81 |
+
target_market=target_col,
|
| 82 |
+
pasar_id=pasar_id,
|
| 83 |
+
n_trials=runtime_config.optuna_trials,
|
| 84 |
+
forecast_days=runtime_config.forecast_days,
|
| 85 |
+
log_cb=log_cb,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Save results to DB
|
| 89 |
+
meta = result['metadata']
|
| 90 |
+
labels = result['labels']
|
| 91 |
+
preds = result['predictions']
|
| 92 |
+
all_model_results = result.get('model_comparison', {})
|
| 93 |
+
|
| 94 |
+
# Save all model results (semua model, bukan hanya best)
|
| 95 |
+
save_all_model_results(all_model_results, meta, komoditas_id, pasar_id)
|
| 96 |
+
|
| 97 |
+
# Save best model specifically
|
| 98 |
+
model_id = save_model_ml(meta)
|
| 99 |
+
save_hasil_prediksi(preds, komoditas_id, pasar_id, model_id, meta)
|
| 100 |
+
save_ringkasan_prediksi({
|
| 101 |
+
'harga_min': labels['harga_min'],
|
| 102 |
+
'harga_max': labels['harga_max'],
|
| 103 |
+
'tren': labels['tren'],
|
| 104 |
+
'confidence_level': labels['confidence_level'],
|
| 105 |
+
'status_analisis': labels['status_analisis']['judul'],
|
| 106 |
+
'deskripsi_status': labels['status_analisis']['deskripsi'],
|
| 107 |
+
'tanggal_mulai': preds[0]['tanggal'],
|
| 108 |
+
'tanggal_akhir': preds[-1]['tanggal'],
|
| 109 |
+
}, komoditas_id, model_id)
|
| 110 |
+
save_insight_prediksi(labels['insights'], komoditas_id, model_id)
|
| 111 |
+
cv = meta.get('data_quality', {}).get('cv', 0)
|
| 112 |
+
update_komoditas_volatilitas(komoditas_id, 1 if cv >= 5 else 0, cv)
|
| 113 |
+
|
| 114 |
+
logger.info(f"✅ {komoditas_nama} selesai! Best: {meta['nama_model']} MAPE={meta['mape']:.2f}%")
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"❌ {komoditas_nama} gagal: {e}")
|
| 118 |
+
finally:
|
| 119 |
+
running_tasks[komoditas_id] = False
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def auto_forecast_all():
|
| 123 |
+
"""Run forecast for ALL komoditas (scheduled or manual run-all)."""
|
| 124 |
+
logger.info("🚀 Auto forecast semua komoditas dimulai...")
|
| 125 |
+
stop_event.clear()
|
| 126 |
try:
|
| 127 |
komoditas_list = get_all_komoditas()
|
| 128 |
+
total = len(komoditas_list)
|
| 129 |
+
for idx, (_, row) in enumerate(komoditas_list.iterrows()):
|
| 130 |
+
if stop_event.is_set():
|
| 131 |
+
logger.warning("⛔ Forecast dihentikan oleh user.")
|
| 132 |
+
break
|
| 133 |
+
logger.info(f"📦 [{idx+1}/{total}] Memproses {row['nama']}...")
|
| 134 |
run_forecast_for_komoditas(int(row['id']), row['nama'])
|
| 135 |
+
logger.info("🏁 Auto forecast selesai.")
|
| 136 |
except Exception as e:
|
| 137 |
+
logger.error(f"❌ Auto forecast error: {e}")
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
+
# Register forecast job with scheduler
|
| 141 |
+
forecast_scheduler.set_forecast_job(auto_forecast_all)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
|
| 144 |
+
# ── Startup ───────────────────────────────────────────────────────────────────
|
| 145 |
+
@app.on_event("startup")
|
| 146 |
+
async def startup_event():
|
| 147 |
+
logger.info("🎉 Memulai SIKOMO Forecast API Server...")
|
| 148 |
+
os.makedirs(runtime_config.model_dir, exist_ok=True)
|
| 149 |
+
forecast_scheduler.start_all(default_schedules=runtime_config.default_schedules)
|
| 150 |
+
logger.info("🚀 API Server siap menerima permintaan.")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@app.on_event("shutdown")
|
| 154 |
+
async def shutdown_event():
|
| 155 |
+
forecast_scheduler.stop_all()
|
| 156 |
+
logger.info("🛑 API Server dimatikan.")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── Pydantic Models ──────────────────────────────────────────────────────────
|
| 160 |
class ForecastRequest(BaseModel):
|
| 161 |
komoditas_id: int
|
| 162 |
|
| 163 |
class InsightUpdate(BaseModel):
|
| 164 |
+
konten: str
|
| 165 |
+
tipe: str
|
| 166 |
+
ikon: str
|
| 167 |
+
urutan: int
|
| 168 |
|
| 169 |
class RingkasanUpdate(BaseModel):
|
| 170 |
+
status_analisis: Optional[str] = None
|
| 171 |
+
deskripsi_status: Optional[str] = None
|
| 172 |
+
tren: Optional[str] = None
|
| 173 |
|
| 174 |
class ModelUpdate(BaseModel):
|
| 175 |
+
deskripsi: Optional[str] = None
|
| 176 |
catatan_validasi: Optional[str] = None
|
| 177 |
|
| 178 |
+
class RuntimeConfigUpdate(BaseModel):
|
| 179 |
+
key: str
|
| 180 |
value: str
|
| 181 |
|
| 182 |
+
class ScheduleAdd(BaseModel):
|
| 183 |
+
cron_expression: str
|
| 184 |
+
label: Optional[str] = ""
|
| 185 |
+
|
| 186 |
|
| 187 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 188 |
+
# ENDPOINTS
|
| 189 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 190 |
|
| 191 |
@app.get("/health")
|
| 192 |
def health():
|
| 193 |
return {"status": "ok", "time": datetime.now().isoformat()}
|
| 194 |
|
| 195 |
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
| 196 |
@app.post("/auth/login")
|
| 197 |
def login(body: dict):
|
| 198 |
+
if body.get('password') != settings.DASHBOARD_PASSWORD:
|
| 199 |
raise HTTPException(status_code=401, detail="Password salah")
|
| 200 |
+
return {"token": settings.API_SECRET_KEY}
|
| 201 |
+
|
| 202 |
+
# ── Status & Logs (realtime) ─────────────────────────────────────────────────
|
| 203 |
+
@app.get("/status", dependencies=[Depends(verify_token)])
|
| 204 |
+
def get_status():
|
| 205 |
+
"""Overall system status including scheduler and running tasks."""
|
| 206 |
+
sched = forecast_scheduler.get_status()
|
| 207 |
+
return {
|
| 208 |
+
**sched,
|
| 209 |
+
"running_tasks": {k: v for k, v in running_tasks.items() if v},
|
| 210 |
+
"any_forecast_running": any(running_tasks.values()),
|
| 211 |
+
}
|
| 212 |
|
| 213 |
+
@app.get("/logs", dependencies=[Depends(verify_token)])
|
| 214 |
+
def get_logs(limit: int = Query(200, le=500), level: Optional[str] = None):
|
| 215 |
+
"""Get structured logs with optional level filter."""
|
| 216 |
+
return memory_handler.get_logs(limit=limit, level=level)
|
| 217 |
+
|
| 218 |
+
# ── Komoditas ─────────────────────────────────────────────────────────────────
|
| 219 |
@app.get("/komoditas", dependencies=[Depends(verify_token)])
|
| 220 |
def list_komoditas():
|
| 221 |
df = get_all_komoditas()
|
| 222 |
return df.to_dict('records')
|
| 223 |
|
| 224 |
+
# ── Prediksi Data ─────────────────────────────────────────────────────────────
|
| 225 |
@app.get("/prediksi/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 226 |
def get_prediksi(komoditas_id: int):
|
| 227 |
return get_prediksi_data(komoditas_id)
|
| 228 |
|
| 229 |
+
# ── Manual Forecast ───────────────────────────────────────────────────────────
|
| 230 |
@app.post("/forecast/run", dependencies=[Depends(verify_token)])
|
| 231 |
def run_forecast_manual(req: ForecastRequest, bg: BackgroundTasks):
|
| 232 |
+
"""Run forecast for a SINGLE komoditas (not all)."""
|
| 233 |
+
if running_tasks.get(req.komoditas_id):
|
| 234 |
+
raise HTTPException(409, f"Komoditas ID {req.komoditas_id} sedang diproses.")
|
| 235 |
+
|
| 236 |
komoditas_list = get_all_komoditas()
|
| 237 |
row = komoditas_list[komoditas_list['id'] == req.komoditas_id]
|
| 238 |
if row.empty:
|
| 239 |
raise HTTPException(404, "Komoditas tidak ditemukan")
|
| 240 |
nama = row.iloc[0]['nama']
|
| 241 |
+
stop_event.clear()
|
| 242 |
bg.add_task(run_forecast_for_komoditas, req.komoditas_id, nama)
|
| 243 |
return {"message": f"Forecast untuk {nama} dimulai di background."}
|
| 244 |
|
| 245 |
@app.post("/forecast/run-all", dependencies=[Depends(verify_token)])
|
| 246 |
def run_forecast_all(bg: BackgroundTasks):
|
| 247 |
+
"""Run forecast for ALL komoditas."""
|
| 248 |
+
if any(running_tasks.values()):
|
| 249 |
+
raise HTTPException(409, "Ada forecast yang sedang berjalan.")
|
| 250 |
+
stop_event.clear()
|
| 251 |
+
bg.add_task(auto_forecast_all)
|
| 252 |
return {"message": "Forecast semua komoditas dimulai."}
|
| 253 |
|
| 254 |
+
@app.post("/forecast/stop", dependencies=[Depends(verify_token)])
|
| 255 |
+
def stop_forecast():
|
| 256 |
+
"""Abort running forecast."""
|
| 257 |
+
logger.warning("🛑 Menerima permintaan penghentian forecast...")
|
| 258 |
+
stop_event.set()
|
| 259 |
+
return {"success": True, "message": "Permintaan penghentian dikirim."}
|
| 260 |
|
| 261 |
+
# ── Scheduler (Multiple Schedules) ───────────────────────────────────────────
|
| 262 |
+
@app.get("/schedules", dependencies=[Depends(verify_token)])
|
| 263 |
+
def list_schedules():
|
| 264 |
+
return forecast_scheduler.list_schedules()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
+
@app.post("/schedules/add", dependencies=[Depends(verify_token)])
|
| 267 |
+
def add_schedule(schedule: ScheduleAdd):
|
| 268 |
+
try:
|
| 269 |
+
job_id = forecast_scheduler.add_schedule(
|
| 270 |
+
schedule.cron_expression, label=schedule.label
|
| 271 |
+
)
|
| 272 |
+
return {
|
| 273 |
+
"success": True,
|
| 274 |
+
"job_id": job_id,
|
| 275 |
+
"message": f"Jadwal berhasil ditambahkan: {schedule.cron_expression}",
|
| 276 |
+
}
|
| 277 |
+
except Exception as e:
|
| 278 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 279 |
+
|
| 280 |
+
@app.delete("/schedules/{job_id}/remove", dependencies=[Depends(verify_token)])
|
| 281 |
+
def remove_schedule(job_id: str):
|
| 282 |
+
try:
|
| 283 |
+
forecast_scheduler.remove_schedule(job_id)
|
| 284 |
+
return {"success": True, "message": "Jadwal berhasil dihapus."}
|
| 285 |
+
except KeyError:
|
| 286 |
+
raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
|
| 287 |
+
except Exception as e:
|
| 288 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 289 |
+
|
| 290 |
+
@app.post("/schedule/start", dependencies=[Depends(verify_token)])
|
| 291 |
+
def start_scheduler():
|
| 292 |
+
forecast_scheduler.start_all(default_schedules=runtime_config.default_schedules)
|
| 293 |
+
return {"success": True, "message": "Scheduler forecast diaktifkan."}
|
| 294 |
+
|
| 295 |
+
@app.post("/schedule/stop", dependencies=[Depends(verify_token)])
|
| 296 |
+
def stop_scheduler():
|
| 297 |
+
forecast_scheduler.stop_all()
|
| 298 |
+
return {"success": True, "message": "Scheduler forecast dimatikan."}
|
| 299 |
+
|
| 300 |
+
# ── Runtime Config ────────────────────────────────────────────────────────────
|
| 301 |
+
@app.get("/settings", dependencies=[Depends(verify_token)])
|
| 302 |
+
def get_runtime_settings():
|
| 303 |
+
"""Get runtime configuration (editable via dashboard)."""
|
| 304 |
+
return runtime_config.to_dict()
|
| 305 |
+
|
| 306 |
+
@app.post("/settings", dependencies=[Depends(verify_token)])
|
| 307 |
+
def update_runtime_settings(body: RuntimeConfigUpdate):
|
| 308 |
+
"""Update a single runtime config key."""
|
| 309 |
+
try:
|
| 310 |
+
runtime_config.update(body.key, body.value)
|
| 311 |
+
return {"message": f"{body.key} berhasil diperbarui ke '{body.value}'."}
|
| 312 |
+
except ValueError as e:
|
| 313 |
+
raise HTTPException(400, str(e))
|
| 314 |
+
|
| 315 |
+
# ── CRUD Insight ────────────────────────────────────���─────────────────────────
|
| 316 |
@app.get("/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 317 |
def get_insights(komoditas_id: int):
|
| 318 |
engine = get_engine()
|
| 319 |
+
with engine.connect() as conn:
|
| 320 |
+
rows = conn.execute(
|
| 321 |
+
text("SELECT * FROM insight_prediksi WHERE komoditas_id=:kid ORDER BY urutan"),
|
| 322 |
+
{'kid': komoditas_id}
|
| 323 |
+
).fetchall()
|
| 324 |
+
return [dict(r._mapping) for r in rows]
|
| 325 |
|
| 326 |
@app.put("/insight/{insight_id}", dependencies=[Depends(verify_token)])
|
| 327 |
def update_insight(insight_id: int, body: InsightUpdate):
|
|
|
|
| 351 |
conn.execute(text("DELETE FROM insight_prediksi WHERE id=:id"), {'id': insight_id})
|
| 352 |
return {"message": "Insight dihapus."}
|
| 353 |
|
| 354 |
+
# ── CRUD Ringkasan ────────────────────────────────────────────────────────────
|
| 355 |
@app.put("/ringkasan/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 356 |
def update_ringkasan(komoditas_id: int, body: RingkasanUpdate):
|
| 357 |
engine = get_engine()
|
|
|
|
| 366 |
)
|
| 367 |
return {"message": "Ringkasan diperbarui."}
|
| 368 |
|
| 369 |
+
# ── CRUD Model ML ─────────────────────────────────────────────────────────────
|
| 370 |
@app.put("/model-ml/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 371 |
def update_model_ml(komoditas_id: int, body: ModelUpdate):
|
| 372 |
engine = get_engine()
|
|
|
|
| 381 |
)
|
| 382 |
return {"message": "Model ML diperbarui."}
|
| 383 |
|
| 384 |
+
# ── Mount Frontend Static ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 385 |
if os.path.isdir("static"):
|
| 386 |
app.mount("/", StaticFiles(directory="static", html=True), name="frontend")
|
backend/ml_pipeline.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 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
|
|
@@ -7,11 +7,13 @@ 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)
|
|
@@ -76,6 +78,8 @@ def recommend_models(quality_info):
|
|
| 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 |
# ══════════════════════════════════════════════════════════════════════════════
|
|
@@ -155,20 +159,64 @@ def train_xgboost(X_train, y_train, X_test, y_test):
|
|
| 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 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
pred = np.clip(fc['yhat'].values, 0, None)
|
| 165 |
-
return m, evaluate_model(
|
| 166 |
|
| 167 |
def train_arima(y_train, y_test):
|
| 168 |
-
m
|
| 169 |
-
fc
|
| 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':
|
|
@@ -235,6 +283,16 @@ def forecast_tree(model, df_hist, target_col, other_cols, feature_cols, n_days=7
|
|
| 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)
|
|
@@ -329,7 +387,7 @@ def get_labels(mape, rmse, mean_price, cv, preds, komoditas_nama):
|
|
| 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.
|
|
@@ -392,11 +450,19 @@ def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
|
|
| 392 |
if 'arima' in models_to_run:
|
| 393 |
log("Training ARIMA...")
|
| 394 |
try:
|
| 395 |
-
|
| 396 |
-
all_results['ARIMA'] = {**res, 'obj':
|
| 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})")
|
|
@@ -423,13 +489,19 @@ def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
|
|
| 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
|
| 427 |
-
|
|
|
|
| 428 |
df_hist = df[['date', target_market] + [c for c in other_cols if c in df.columns]].copy()
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -478,6 +550,10 @@ def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
|
|
| 478 |
'predictions' : predictions,
|
| 479 |
'labels' : labels,
|
| 480 |
'metadata' : metadata,
|
| 481 |
-
'model_comparison': {k: {
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
}
|
|
|
|
| 1 |
# ml_pipeline.py — Full ML pipeline: cleaning → training → forecasting → labeling
|
| 2 |
+
import os, json, pickle, warnings, logging
|
| 3 |
import pandas as pd
|
| 4 |
import numpy as np
|
| 5 |
from datetime import date
|
|
|
|
| 7 |
from sklearn.model_selection import TimeSeriesSplit
|
| 8 |
import lightgbm as lgb
|
| 9 |
import xgboost as xgb
|
|
|
|
| 10 |
from statsmodels.tsa.arima.model import ARIMA
|
| 11 |
+
from statsmodels.tsa.statespace.sarimax import SARIMAX
|
| 12 |
import optuna
|
| 13 |
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
| 14 |
warnings.filterwarnings('ignore')
|
| 15 |
+
logging.getLogger('cmdstanpy').setLevel(logging.WARNING)
|
| 16 |
+
logging.getLogger('prophet').setLevel(logging.WARNING)
|
| 17 |
|
| 18 |
MODEL_DIR = os.getenv('MODEL_DIR', 'models')
|
| 19 |
os.makedirs(MODEL_DIR, exist_ok=True)
|
|
|
|
| 78 |
models.append('prophet')
|
| 79 |
if quality_info['cv'] < 5 and quality_info['missing_pct'] < 20:
|
| 80 |
models.append('arima')
|
| 81 |
+
if quality_info['total_records'] >= 90 and quality_info['missing_pct'] < 25:
|
| 82 |
+
models.append('sarima')
|
| 83 |
return models
|
| 84 |
|
| 85 |
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 159 |
return m, evaluate_model(y_test, m.predict(X_test), 'XGBoost')
|
| 160 |
|
| 161 |
def train_prophet(dates_train, y_train, dates_test, y_test):
|
| 162 |
+
"""Train Prophet with robust error handling and data validation."""
|
| 163 |
+
try:
|
| 164 |
+
from prophet import Prophet
|
| 165 |
+
except ImportError:
|
| 166 |
+
raise RuntimeError("Prophet not installed")
|
| 167 |
+
# Ensure clean datetime + float data
|
| 168 |
+
ds_train = pd.to_datetime(dates_train).reset_index(drop=True)
|
| 169 |
+
y_tr = y_train.reset_index(drop=True).astype(float)
|
| 170 |
+
ds_test = pd.to_datetime(dates_test).reset_index(drop=True)
|
| 171 |
+
y_te = y_test.reset_index(drop=True).astype(float)
|
| 172 |
+
# Remove NaN rows
|
| 173 |
+
mask = y_tr.notna()
|
| 174 |
+
ds_train = ds_train[mask]
|
| 175 |
+
y_tr = y_tr[mask]
|
| 176 |
+
if len(y_tr) < 30:
|
| 177 |
+
raise ValueError("Prophet: data training terlalu sedikit setelah cleaning")
|
| 178 |
+
df_train = pd.DataFrame({'ds': ds_train.values, 'y': y_tr.values})
|
| 179 |
+
df_test = pd.DataFrame({'ds': ds_test.values})
|
| 180 |
+
m = Prophet(
|
| 181 |
+
yearly_seasonality=True, weekly_seasonality=True,
|
| 182 |
+
daily_seasonality=False, changepoint_prior_scale=0.05,
|
| 183 |
+
)
|
| 184 |
+
# Suppress Prophet stdout
|
| 185 |
+
import io, sys
|
| 186 |
+
old_stdout = sys.stdout
|
| 187 |
+
sys.stdout = io.StringIO()
|
| 188 |
+
try:
|
| 189 |
+
m.fit(df_train)
|
| 190 |
+
finally:
|
| 191 |
+
sys.stdout = old_stdout
|
| 192 |
+
fc = m.predict(df_test)
|
| 193 |
pred = np.clip(fc['yhat'].values, 0, None)
|
| 194 |
+
return m, evaluate_model(y_te.values, pred, 'Prophet')
|
| 195 |
|
| 196 |
def train_arima(y_train, y_test):
|
| 197 |
+
m = ARIMA(y_train.values, order=(5, 1, 0)).fit()
|
| 198 |
+
fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
|
| 199 |
return m, evaluate_model(y_test.values, fc, 'ARIMA')
|
| 200 |
|
| 201 |
+
def train_sarima(y_train, y_test):
|
| 202 |
+
"""Train SARIMA with seasonal_order for weekly patterns."""
|
| 203 |
+
m = SARIMAX(
|
| 204 |
+
y_train.values, order=(1, 1, 1), seasonal_order=(1, 1, 1, 7),
|
| 205 |
+
enforce_stationarity=False, enforce_invertibility=False,
|
| 206 |
+
).fit(disp=False, maxiter=200)
|
| 207 |
+
fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
|
| 208 |
+
return m, evaluate_model(y_test.values, fc, 'SARIMA')
|
| 209 |
+
|
| 210 |
+
def forecast_sarima(model, n_days=7):
|
| 211 |
+
"""Forecast n_days ahead using fitted SARIMA model."""
|
| 212 |
+
fc = np.clip(model.forecast(steps=n_days), 0, None)
|
| 213 |
+
last_date = pd.Timestamp.now().normalize()
|
| 214 |
+
return [
|
| 215 |
+
{'tanggal': str((last_date + pd.Timedelta(days=i+1)).date()),
|
| 216 |
+
'harga_prediksi': round(float(fc[i]), 2)}
|
| 217 |
+
for i in range(n_days)
|
| 218 |
+
]
|
| 219 |
+
|
| 220 |
def tune_with_optuna(model_name, X_train, y_train, n_trials=50):
|
| 221 |
def objective(trial):
|
| 222 |
if model_name == 'LightGBM':
|
|
|
|
| 283 |
df_temp.loc[df_temp['date'] == next_date, target_col] = pred
|
| 284 |
return preds
|
| 285 |
|
| 286 |
+
def forecast_arima(model, n_days=7):
|
| 287 |
+
"""Forecast n_days ahead using fitted ARIMA model."""
|
| 288 |
+
fc = np.clip(model.forecast(steps=n_days), 0, None)
|
| 289 |
+
last_date = pd.Timestamp.now().normalize()
|
| 290 |
+
return [
|
| 291 |
+
{'tanggal': str((last_date + pd.Timedelta(days=i+1)).date()),
|
| 292 |
+
'harga_prediksi': round(float(fc[i]), 2)}
|
| 293 |
+
for i in range(n_days)
|
| 294 |
+
]
|
| 295 |
+
|
| 296 |
def forecast_prophet(model, last_date, n_days=7):
|
| 297 |
future = pd.DataFrame({'ds': pd.date_range(start=last_date + pd.Timedelta(days=1), periods=n_days)})
|
| 298 |
fc = model.predict(future)
|
|
|
|
| 387 |
|
| 388 |
def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
|
| 389 |
target_market: str, pasar_id: int, n_trials: int = 50,
|
| 390 |
+
forecast_days: int = 7, log_cb=None):
|
| 391 |
"""
|
| 392 |
Full pipeline: cleaning → training → tuning → forecasting → labeling → save.
|
| 393 |
log_cb: callback function(msg: str) untuk streaming log ke frontend.
|
|
|
|
| 450 |
if 'arima' in models_to_run:
|
| 451 |
log("Training ARIMA...")
|
| 452 |
try:
|
| 453 |
+
arima_model, res = train_arima(y_train, y_test)
|
| 454 |
+
all_results['ARIMA'] = {**res, 'obj': arima_model, 'type': 'arima'}
|
| 455 |
except Exception as e:
|
| 456 |
log(f"⚠️ ARIMA gagal: {e}")
|
| 457 |
|
| 458 |
+
if 'sarima' in models_to_run:
|
| 459 |
+
log("Training SARIMA...")
|
| 460 |
+
try:
|
| 461 |
+
sarima_model, res = train_sarima(y_train, y_test)
|
| 462 |
+
all_results['SARIMA'] = {**res, 'obj': sarima_model, 'type': 'sarima'}
|
| 463 |
+
except Exception as e:
|
| 464 |
+
log(f"⚠️ SARIMA gagal: {e}")
|
| 465 |
+
|
| 466 |
best_name = min(all_results, key=lambda k: all_results[k]['rmse'])
|
| 467 |
best = all_results[best_name]
|
| 468 |
log(f"🏆 Best model: {best_name} (RMSE={best['rmse']:.2f})")
|
|
|
|
| 489 |
final_name = f"{best_name}_Tuned"
|
| 490 |
log(f"✅ Tuned lebih baik, improvement={(best['rmse']-tuned_res['rmse'])/best['rmse']*100:.1f}%")
|
| 491 |
|
| 492 |
+
# ── Forecast ──
|
| 493 |
+
n_fc = forecast_days
|
| 494 |
+
log(f"📅 Forecasting {n_fc} hari...")
|
| 495 |
df_hist = df[['date', target_market] + [c for c in other_cols if c in df.columns]].copy()
|
| 496 |
+
f_type = final_result.get('type', '')
|
| 497 |
+
if f_type == 'prophet' or final_name == 'Prophet':
|
| 498 |
+
predictions = forecast_prophet(final_model, df_hist['date'].max(), n_fc)
|
| 499 |
+
elif f_type == 'arima':
|
| 500 |
+
predictions = forecast_arima(final_model, n_fc)
|
| 501 |
+
elif f_type == 'sarima':
|
| 502 |
+
predictions = forecast_sarima(final_model, n_fc)
|
| 503 |
else:
|
| 504 |
+
predictions = forecast_tree(final_model, df_hist, target_market, other_cols, feature_cols, n_fc)
|
| 505 |
|
| 506 |
# ── Labels ──
|
| 507 |
cv = quality_reports.get(target_market, {}).get('cv', 0)
|
|
|
|
| 550 |
'predictions' : predictions,
|
| 551 |
'labels' : labels,
|
| 552 |
'metadata' : metadata,
|
| 553 |
+
'model_comparison': {k: {
|
| 554 |
+
'rmse': round(v['rmse'], 4),
|
| 555 |
+
'mape': round(v['mape'], 4),
|
| 556 |
+
'mae': round(v['mae'], 4),
|
| 557 |
+
'r2': round(v['r2'], 4)
|
| 558 |
+
} for k, v in all_results.items()},
|
| 559 |
}
|
backend/requirements.txt
CHANGED
|
@@ -12,4 +12,6 @@ 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
|
|
|
|
|
|
|
|
|
| 12 |
sqlalchemy==2.0.30
|
| 13 |
python-dotenv==1.0.1
|
| 14 |
apscheduler==3.10.4
|
| 15 |
+
python-multipart==0.0.9
|
| 16 |
+
pydantic-settings==2.2.1
|
| 17 |
+
pytz==2024.1
|
backend/scheduler.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scheduler.py — Multiple-schedule forecast manager
|
| 2 |
+
# Ported from Daily Scrapper architecture with forecast-specific job
|
| 3 |
+
|
| 4 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 5 |
+
from apscheduler.triggers.cron import CronTrigger
|
| 6 |
+
from logger import get_app_logger
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from pytz import timezone as pytz_timezone
|
| 12 |
+
wib_tz = pytz_timezone("Asia/Jakarta")
|
| 13 |
+
except ImportError:
|
| 14 |
+
import zoneinfo
|
| 15 |
+
wib_tz = zoneinfo.ZoneInfo("Asia/Jakarta")
|
| 16 |
+
|
| 17 |
+
logger = get_app_logger("scheduler")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ForecastScheduler:
|
| 21 |
+
"""Manages multiple forecast schedules with APScheduler."""
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 25 |
+
self.is_running = False
|
| 26 |
+
self.last_run = None
|
| 27 |
+
self.last_result = None
|
| 28 |
+
self.schedules = {} # id -> {"cron": str, "label": str}
|
| 29 |
+
self._forecast_job_fn = None # Set by main.py
|
| 30 |
+
|
| 31 |
+
def set_forecast_job(self, fn):
|
| 32 |
+
"""Register the forecast function to be called by scheduled jobs."""
|
| 33 |
+
self._forecast_job_fn = fn
|
| 34 |
+
|
| 35 |
+
def _run_job(self):
|
| 36 |
+
"""Execute the registered forecast job."""
|
| 37 |
+
logger.info("⏰ Menjalankan forecast terjadwal otomatis...")
|
| 38 |
+
try:
|
| 39 |
+
if self._forecast_job_fn:
|
| 40 |
+
self._forecast_job_fn()
|
| 41 |
+
self.last_run = datetime.now()
|
| 42 |
+
self.last_result = {
|
| 43 |
+
"success": True,
|
| 44 |
+
"timestamp": self.last_run.isoformat(),
|
| 45 |
+
}
|
| 46 |
+
logger.info("✅ Forecast terjadwal selesai.")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
self.last_result = {
|
| 49 |
+
"success": False,
|
| 50 |
+
"error": str(e),
|
| 51 |
+
"timestamp": datetime.now().isoformat(),
|
| 52 |
+
}
|
| 53 |
+
logger.error(f"❌ Forecast terjadwal gagal: {e}")
|
| 54 |
+
|
| 55 |
+
def start_all(self, default_schedules: str = ""):
|
| 56 |
+
"""Start the scheduler engine. Optionally load default schedules."""
|
| 57 |
+
if not self.is_running:
|
| 58 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 59 |
+
if self._forecast_job_fn:
|
| 60 |
+
# Re-register all existing schedules
|
| 61 |
+
for jid, info in list(self.schedules.items()):
|
| 62 |
+
try:
|
| 63 |
+
trigger = CronTrigger.from_crontab(info["cron"], timezone=wib_tz)
|
| 64 |
+
self.scheduler.add_job(
|
| 65 |
+
self._run_job, trigger, id=jid, replace_existing=True
|
| 66 |
+
)
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
self.scheduler.start()
|
| 70 |
+
self.is_running = True
|
| 71 |
+
logger.info("▶️ Mesin Scheduler Forecast telah diaktifkan.")
|
| 72 |
+
|
| 73 |
+
# Load defaults if no schedules exist
|
| 74 |
+
if not self.schedules and default_schedules:
|
| 75 |
+
for cron in [s.strip() for s in default_schedules.split(",") if s.strip()]:
|
| 76 |
+
self.add_schedule(cron, label="Jadwal Default")
|
| 77 |
+
|
| 78 |
+
def stop_all(self):
|
| 79 |
+
"""Stop the scheduler engine and clear all jobs."""
|
| 80 |
+
if self.is_running:
|
| 81 |
+
try:
|
| 82 |
+
self.scheduler.shutdown(wait=False)
|
| 83 |
+
except Exception:
|
| 84 |
+
pass
|
| 85 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 86 |
+
self.is_running = False
|
| 87 |
+
self.schedules.clear()
|
| 88 |
+
logger.info("⏹️ Mesin Scheduler Forecast telah dimatikan.")
|
| 89 |
+
|
| 90 |
+
def add_schedule(self, cron_expression: str, label: str = "") -> str:
|
| 91 |
+
"""Add a new cron schedule. Returns job_id."""
|
| 92 |
+
job_id = str(uuid.uuid4())[:8]
|
| 93 |
+
try:
|
| 94 |
+
trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
|
| 95 |
+
self.scheduler.add_job(
|
| 96 |
+
self._run_job, trigger, id=job_id, replace_existing=True
|
| 97 |
+
)
|
| 98 |
+
if not label:
|
| 99 |
+
label = f"Jadwal {cron_expression}"
|
| 100 |
+
self.schedules[job_id] = {"cron": cron_expression, "label": label}
|
| 101 |
+
logger.info(f"📅 Jadwal baru ditambahkan: {cron_expression} ({label})")
|
| 102 |
+
|
| 103 |
+
# Auto-start if not running
|
| 104 |
+
if not self.is_running:
|
| 105 |
+
self.scheduler.start()
|
| 106 |
+
self.is_running = True
|
| 107 |
+
logger.info("▶️ Scheduler otomatis menyala karena ada jadwal baru.")
|
| 108 |
+
|
| 109 |
+
return job_id
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"❌ Gagal menambahkan jadwal cron '{cron_expression}': {e}")
|
| 112 |
+
raise ValueError(f"Format Cron tidak valid: {e}")
|
| 113 |
+
|
| 114 |
+
def remove_schedule(self, job_id: str):
|
| 115 |
+
"""Remove a schedule by job_id."""
|
| 116 |
+
if job_id in self.schedules:
|
| 117 |
+
try:
|
| 118 |
+
self.scheduler.remove_job(job_id)
|
| 119 |
+
except Exception:
|
| 120 |
+
pass
|
| 121 |
+
deleted = self.schedules.pop(job_id)
|
| 122 |
+
logger.info(f"🗑️ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
|
| 123 |
+
else:
|
| 124 |
+
raise KeyError("ID Jadwal tidak ditemukan")
|
| 125 |
+
|
| 126 |
+
def list_schedules(self):
|
| 127 |
+
"""List all active schedules with next_run info."""
|
| 128 |
+
result = []
|
| 129 |
+
jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
|
| 130 |
+
for jid, info in self.schedules.items():
|
| 131 |
+
job_obj = jobs_map.get(jid)
|
| 132 |
+
next_run = (
|
| 133 |
+
job_obj.next_run_time.isoformat()
|
| 134 |
+
if (job_obj and job_obj.next_run_time)
|
| 135 |
+
else None
|
| 136 |
+
)
|
| 137 |
+
result.append({
|
| 138 |
+
"id": jid,
|
| 139 |
+
"cron_expression": info["cron"],
|
| 140 |
+
"label": info["label"],
|
| 141 |
+
"next_run": next_run,
|
| 142 |
+
})
|
| 143 |
+
return result
|
| 144 |
+
|
| 145 |
+
def get_status(self):
|
| 146 |
+
"""Get overall scheduler status."""
|
| 147 |
+
jobs = self.scheduler.get_jobs() if self.is_running else []
|
| 148 |
+
next_runs = [j.next_run_time for j in jobs if j.next_run_time]
|
| 149 |
+
next_run_overall = min(next_runs).isoformat() if next_runs else None
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
"is_running": self.is_running,
|
| 153 |
+
"last_run": self.last_run.isoformat() if self.last_run else None,
|
| 154 |
+
"last_result": self.last_result,
|
| 155 |
+
"next_run": next_run_overall,
|
| 156 |
+
"active_schedules_count": len(self.schedules),
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# Singleton instance
|
| 161 |
+
forecast_scheduler = ForecastScheduler()
|
frontend/app/dashboard/forecast/page.tsx
CHANGED
|
@@ -1,121 +1,283 @@
|
|
| 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]
|
| 9 |
-
const [
|
| 10 |
-
const [
|
| 11 |
-
const [
|
| 12 |
-
const [
|
| 13 |
-
const [
|
| 14 |
-
const [
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
async function runOne(id: number) {
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
async function runAll() {
|
| 40 |
-
|
| 41 |
-
await api.runForecastAll()
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
-
async function
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return (
|
| 54 |
-
<div className="
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
<
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
<
|
| 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 |
-
<
|
| 75 |
-
className="
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
<
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
</div>
|
| 105 |
|
| 106 |
-
{/*
|
| 107 |
-
<div className="
|
| 108 |
-
<div className="flex items-center justify-between mb-
|
| 109 |
-
<h2 className="font-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</div>
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
}
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
</div>
|
|
|
|
| 1 |
+
// app/dashboard/forecast/page.tsx — Forecast Management: Scheduler + Logs + Run
|
| 2 |
'use client'
|
| 3 |
+
import { useEffect, useState, useRef } from 'react'
|
| 4 |
import { api } from '@/lib/api'
|
| 5 |
+
import { Play, Square, RefreshCw, Calendar, Terminal, Plus, Trash2, Info } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
interface LogItem { timestamp: string; level: string; source: string; message: string }
|
| 8 |
|
| 9 |
export default function ForecastPage() {
|
| 10 |
+
const [komoditas, setKomoditas] = useState<any[]>([])
|
| 11 |
+
const [status, setStatus] = useState<any>(null)
|
| 12 |
+
const [schedules, setSchedules] = useState<any[]>([])
|
| 13 |
+
const [logs, setLogs] = useState<LogItem[]>([])
|
| 14 |
+
const [runningIds, setRunningIds] = useState<Record<number, boolean>>({})
|
| 15 |
+
const [runAllLoading, setRunAllLoading] = useState(false)
|
| 16 |
+
const [logLevelFilter, setLogLevelFilter] = useState('ALL')
|
| 17 |
|
| 18 |
+
// Schedule form
|
| 19 |
+
const [selectedHour, setSelectedHour] = useState('01')
|
| 20 |
+
const [selectedMinute, setSelectedMinute] = useState('00')
|
| 21 |
+
const [newLabel, setNewLabel] = useState('')
|
| 22 |
+
|
| 23 |
+
const logsContainerRef = useRef<HTMLDivElement>(null)
|
| 24 |
+
const prevLogsLengthRef = useRef(0)
|
| 25 |
+
|
| 26 |
+
const fetchData = async () => {
|
| 27 |
+
try {
|
| 28 |
+
const [k, s, sc, l] = await Promise.all([
|
| 29 |
+
api.getKomoditas(), api.getStatus(), api.getSchedules(),
|
| 30 |
+
api.getLogs(200, logLevelFilter !== 'ALL' ? logLevelFilter : undefined)
|
| 31 |
+
])
|
| 32 |
+
setKomoditas(k); setStatus(s); setSchedules(sc); setLogs(l)
|
| 33 |
+
// Sync running states from backend
|
| 34 |
+
if (s?.running_tasks) {
|
| 35 |
+
setRunningIds(prev => ({ ...prev, ...Object.fromEntries(Object.entries(s.running_tasks).map(([k, v]) => [Number(k), v])) }))
|
| 36 |
+
}
|
| 37 |
+
} catch {}
|
| 38 |
}
|
| 39 |
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
fetchData()
|
| 42 |
+
const interval = setInterval(fetchData, 3000)
|
| 43 |
+
return () => clearInterval(interval)
|
| 44 |
+
}, [logLevelFilter])
|
| 45 |
+
|
| 46 |
+
// Auto-scroll logs
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (logs.length > prevLogsLengthRef.current && logsContainerRef.current) {
|
| 49 |
+
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
|
| 50 |
+
}
|
| 51 |
+
prevLogsLengthRef.current = logs.length
|
| 52 |
+
}, [logs])
|
| 53 |
+
|
| 54 |
+
// Actions
|
| 55 |
async function runOne(id: number) {
|
| 56 |
+
setRunningIds(prev => ({ ...prev, [id]: true }))
|
| 57 |
+
try {
|
| 58 |
+
await api.runForecast(id)
|
| 59 |
+
} catch (e: any) {
|
| 60 |
+
alert(`❌ Gagal: ${e.message}`)
|
| 61 |
+
setRunningIds(prev => ({ ...prev, [id]: false }))
|
| 62 |
+
}
|
| 63 |
}
|
| 64 |
|
| 65 |
async function runAll() {
|
| 66 |
+
setRunAllLoading(true)
|
| 67 |
+
try { await api.runForecastAll() } catch (e: any) { alert(`❌ ${e.message}`) }
|
| 68 |
+
setRunAllLoading(false)
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async function stopForecast() {
|
| 72 |
+
try { await api.stopForecast() } catch {}
|
| 73 |
}
|
| 74 |
|
| 75 |
+
async function handleAddSchedule(e: React.FormEvent) {
|
| 76 |
+
e.preventDefault()
|
| 77 |
+
try {
|
| 78 |
+
const cron = `${selectedMinute} ${selectedHour} * * *`
|
| 79 |
+
await api.addSchedule(cron, newLabel.trim() || `Jadwal ${selectedHour}:${selectedMinute}`)
|
| 80 |
+
setNewLabel('')
|
| 81 |
+
fetchData()
|
| 82 |
+
} catch (e: any) { alert(`❌ ${e.message}`) }
|
| 83 |
}
|
| 84 |
|
| 85 |
+
async function removeSchedule(jobId: string) {
|
| 86 |
+
try { await api.removeSchedule(jobId); fetchData() } catch (e: any) { alert(`❌ ${e.message}`) }
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function toggleScheduler() {
|
| 90 |
+
try {
|
| 91 |
+
if (status?.is_running) await api.stopScheduler()
|
| 92 |
+
else await api.startScheduler()
|
| 93 |
+
fetchData()
|
| 94 |
+
} catch (e: any) { alert(`❌ ${e.message}`) }
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const anyRunning = status?.any_forecast_running || runAllLoading
|
| 98 |
+
|
| 99 |
return (
|
| 100 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 101 |
+
{/* LEFT: Controls */}
|
| 102 |
+
<div className="lg:col-span-2 space-y-6">
|
| 103 |
+
{/* Scheduler Status & Toggle */}
|
| 104 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 105 |
+
<div className="flex items-center justify-between mb-4">
|
| 106 |
+
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
| 107 |
+
<Calendar className="w-4 h-4 text-teal-400" /> Status Scheduler
|
| 108 |
+
</h2>
|
| 109 |
+
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
| 110 |
+
status?.is_running ? 'bg-teal-500/10 text-teal-400 border border-teal-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
|
| 111 |
+
}`}>
|
| 112 |
+
{status?.is_running ? 'RUNNING' : 'STOPPED'}
|
| 113 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
+
<div className="grid grid-cols-2 gap-3 mb-4">
|
| 116 |
+
<div className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
|
| 117 |
+
<span className="text-xs text-slate-500 block">Jadwal Aktif</span>
|
| 118 |
+
<span className="text-lg font-bold text-slate-200">{schedules.length} <span className="text-xs font-normal text-slate-400">tugas</span></span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
|
| 121 |
+
<span className="text-xs text-slate-500 block">Run Berikutnya</span>
|
| 122 |
+
<span className="text-sm font-bold text-teal-400 block truncate mt-0.5">
|
| 123 |
+
{status?.next_run ? new Date(status.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : 'Tidak ada'}
|
| 124 |
+
</span>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
<button onClick={toggleScheduler}
|
| 128 |
+
className={`w-full py-2.5 rounded-xl text-xs font-bold flex items-center justify-center gap-2 border transition ${
|
| 129 |
+
status?.is_running ? 'bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border-rose-500/20' : 'bg-teal-500/10 hover:bg-teal-500/20 text-teal-400 border-teal-500/20'
|
| 130 |
+
}`}>
|
| 131 |
+
{status?.is_running ? <><Square className="w-3.5 h-3.5" /> Matikan Scheduler</> : <><Play className="w-3.5 h-3.5" /> Nyalakan Scheduler</>}
|
| 132 |
</button>
|
| 133 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
{/* Schedule Manager */}
|
| 136 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 137 |
+
<h2 className="text-base font-bold text-slate-100 flex items-center gap-2 mb-1">
|
| 138 |
+
<Calendar className="w-5 h-5 text-teal-400" /> Manajemen Jadwal
|
| 139 |
+
</h2>
|
| 140 |
+
<p className="text-xs text-slate-400 mb-4">Atur waktu forecast otomatis. Bisa tambah lebih dari satu jadwal.</p>
|
| 141 |
+
|
| 142 |
+
{/* Quick presets */}
|
| 143 |
+
<div className="flex flex-wrap gap-1.5 mb-4">
|
| 144 |
+
<span className="text-[10px] font-semibold text-slate-500 px-2 self-center">Preset:</span>
|
| 145 |
+
{[['01','00','Dini Hari'],['06','00','Pagi'],['12','00','Siang'],['18','00','Sore']].map(([h,m,l]) => (
|
| 146 |
+
<button key={h+m} onClick={() => { setSelectedHour(h); setSelectedMinute(m); setNewLabel(`${l} (${h}:${m})`) }}
|
| 147 |
+
className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition">{h}:{m}</button>
|
| 148 |
+
))}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Add form */}
|
| 152 |
+
<form onSubmit={handleAddSchedule} className="grid grid-cols-12 gap-3 mb-5 bg-slate-900/40 p-4 rounded-xl border border-white/5">
|
| 153 |
+
<div className="col-span-3">
|
| 154 |
+
<label className="block text-[11px] font-medium text-slate-400 mb-1">Jam (WIB)</label>
|
| 155 |
+
<select value={selectedHour} onChange={e => setSelectedHour(e.target.value)} className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none">
|
| 156 |
+
{Array.from({length:24}).map((_,i) => <option key={i} value={i.toString().padStart(2,'0')} className="bg-slate-900">{i.toString().padStart(2,'0')}</option>)}
|
| 157 |
+
</select>
|
| 158 |
+
</div>
|
| 159 |
+
<div className="col-span-3">
|
| 160 |
+
<label className="block text-[11px] font-medium text-slate-400 mb-1">Menit</label>
|
| 161 |
+
<select value={selectedMinute} onChange={e => setSelectedMinute(e.target.value)} className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none">
|
| 162 |
+
{Array.from({length:60}).map((_,i) => <option key={i} value={i.toString().padStart(2,'0')} className="bg-slate-900">{i.toString().padStart(2,'0')}</option>)}
|
| 163 |
+
</select>
|
| 164 |
+
</div>
|
| 165 |
+
<div className="col-span-4">
|
| 166 |
+
<label className="block text-[11px] font-medium text-slate-400 mb-1">Label</label>
|
| 167 |
+
<input type="text" value={newLabel} onChange={e => setNewLabel(e.target.value)} placeholder="Contoh: Forecast Pagi"
|
| 168 |
+
className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 placeholder:text-slate-600 focus:outline-none" />
|
| 169 |
+
</div>
|
| 170 |
+
<div className="col-span-2 flex items-end">
|
| 171 |
+
<button type="submit" className="w-full bg-teal-500 hover:bg-teal-400 text-slate-900 font-bold py-2 px-3 rounded-lg text-xs flex items-center justify-center gap-1 transition">
|
| 172 |
+
<Plus className="w-3.5 h-3.5 stroke-[3]" /> Tambah
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
</form>
|
| 176 |
+
|
| 177 |
+
{/* Schedules table */}
|
| 178 |
+
<table className="w-full text-left border-collapse">
|
| 179 |
+
<thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
|
| 180 |
+
<th className="pb-3 px-3">Label</th><th className="pb-3 px-3">Waktu</th><th className="pb-3 px-3">Next Run</th><th className="pb-3 px-3 text-right">Aksi</th>
|
| 181 |
+
</tr></thead>
|
| 182 |
+
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
|
| 183 |
+
{schedules.length === 0 ? (
|
| 184 |
+
<tr><td colSpan={4} className="text-center py-6 text-slate-500 italic">Belum ada jadwal.</td></tr>
|
| 185 |
+
) : schedules.map(item => (
|
| 186 |
+
<tr key={item.id} className="hover:bg-slate-800/30 transition">
|
| 187 |
+
<td className="py-3 px-3 font-medium text-slate-200">{item.label}</td>
|
| 188 |
+
<td className="py-3 px-3">
|
| 189 |
+
<span className="font-mono text-teal-400 font-bold bg-teal-500/10 py-1 px-2 rounded-lg border border-teal-500/20">
|
| 190 |
+
{(() => { const p = item.cron_expression.split(' '); return p.length >= 2 ? `${p[1].padStart(2,'0')}:${p[0].padStart(2,'0')}` : item.cron_expression })()}
|
| 191 |
+
</span>
|
| 192 |
+
</td>
|
| 193 |
+
<td className="py-3 px-3 text-slate-400">{item.next_run ? new Date(item.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : '-'}</td>
|
| 194 |
+
<td className="py-3 px-3 text-right">
|
| 195 |
+
<button onClick={() => removeSchedule(item.id)} className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition" title="Hapus">
|
| 196 |
+
<Trash2 className="w-4 h-4" />
|
| 197 |
+
</button>
|
| 198 |
+
</td>
|
| 199 |
+
</tr>
|
| 200 |
+
))}
|
| 201 |
+
</tbody>
|
| 202 |
+
</table>
|
| 203 |
</div>
|
| 204 |
+
|
| 205 |
+
{/* Manual Forecast */}
|
| 206 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 207 |
+
<div className="flex items-center justify-between mb-4">
|
| 208 |
+
<h2 className="text-base font-bold text-slate-100 flex items-center gap-2">
|
| 209 |
+
<RefreshCw className="w-5 h-5 text-indigo-400" /> Manual Forecast
|
| 210 |
+
</h2>
|
| 211 |
+
<div className="flex gap-2">
|
| 212 |
+
<button onClick={runAll} disabled={anyRunning}
|
| 213 |
+
className="bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-semibold py-2 px-4 rounded-xl text-xs flex items-center gap-2 disabled:opacity-50 transition active:scale-[0.98]">
|
| 214 |
+
{anyRunning ? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Processing...</> : <><Play className="w-3.5 h-3.5 fill-current" /> Run Semua</>}
|
| 215 |
</button>
|
| 216 |
+
{anyRunning && (
|
| 217 |
+
<button onClick={stopForecast}
|
| 218 |
+
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 font-medium py-2 px-4 rounded-xl text-xs flex items-center gap-2 border border-rose-500/30 transition animate-pulse">
|
| 219 |
+
<Square className="w-3.5 h-3.5 fill-current" /> Stop
|
| 220 |
+
</button>
|
| 221 |
+
)}
|
| 222 |
</div>
|
| 223 |
+
</div>
|
| 224 |
+
<div className="grid grid-cols-2 gap-3">
|
| 225 |
+
{komoditas.map((k: any) => {
|
| 226 |
+
const isRunning = runningIds[k.id] || status?.running_tasks?.[k.id]
|
| 227 |
+
return (
|
| 228 |
+
<div key={k.id} className="flex items-center justify-between bg-slate-900/50 rounded-xl px-4 py-3 border border-white/5 hover:border-white/10 transition">
|
| 229 |
+
<span className="text-sm font-medium text-slate-200">{k.nama}</span>
|
| 230 |
+
<button onClick={() => runOne(k.id)} disabled={isRunning || anyRunning}
|
| 231 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
| 232 |
+
isRunning ? 'bg-amber-500/10 text-amber-400 border border-amber-500/20' : 'bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 disabled:opacity-30'
|
| 233 |
+
}`}>
|
| 234 |
+
{isRunning ? <><RefreshCw className="w-3 h-3 animate-spin" /> Running</> : <><Play className="w-3 h-3" /> Run</>}
|
| 235 |
+
</button>
|
| 236 |
+
</div>
|
| 237 |
+
)
|
| 238 |
+
})}
|
| 239 |
+
</div>
|
| 240 |
</div>
|
| 241 |
</div>
|
| 242 |
|
| 243 |
+
{/* RIGHT: Terminal Logs */}
|
| 244 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5 flex flex-col h-[500px] lg:h-[calc(100vh-180px)] lg:sticky lg:top-24">
|
| 245 |
+
<div className="flex items-center justify-between pb-4 mb-4 border-b border-white/5">
|
| 246 |
+
<h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
| 247 |
+
<Terminal className="w-4 h-4 text-indigo-400" /> Log Real-time
|
| 248 |
+
</h2>
|
| 249 |
+
<div className="flex bg-slate-900/80 rounded-lg p-0.5 border border-white/5">
|
| 250 |
+
{['ALL', 'INFO', 'WARNING', 'ERROR'].map(lvl => (
|
| 251 |
+
<button key={lvl} onClick={() => setLogLevelFilter(lvl)}
|
| 252 |
+
className={`px-2 py-1 rounded-md text-[10px] font-bold transition ${logLevelFilter === lvl ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-300'}`}>
|
| 253 |
+
{lvl}
|
| 254 |
+
</button>
|
| 255 |
+
))}
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<div ref={logsContainerRef} className="flex-1 bg-slate-950 rounded-xl p-4 font-mono text-xs overflow-y-auto border border-black/50 shadow-inner space-y-1">
|
| 260 |
+
{logs.length === 0 ? (
|
| 261 |
+
<div className="m-auto text-slate-600 text-center italic h-full flex items-center justify-center">[ Menunggu aktivitas... ]</div>
|
| 262 |
+
) : logs.map((log, idx) => {
|
| 263 |
+
let color = 'text-teal-400'
|
| 264 |
+
if (log.level === 'WARNING') color = 'text-amber-400'
|
| 265 |
+
if (log.level === 'ERROR') color = 'text-rose-400'
|
| 266 |
+
const t = new Date(log.timestamp).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
| 267 |
+
return (
|
| 268 |
+
<div key={idx} className="leading-relaxed break-words hover:bg-white/[0.02] px-1 rounded transition-colors">
|
| 269 |
+
<span className="text-slate-600 select-none">[{t}]</span>{' '}
|
| 270 |
+
<span className={`font-semibold ${color} select-none`}>[{log.level}]</span>{' '}
|
| 271 |
+
<span className="text-slate-500 select-none">({log.source})</span>{' '}
|
| 272 |
+
<span className="text-slate-300">{log.message}</span>
|
| 273 |
+
</div>
|
| 274 |
+
)
|
| 275 |
+
})}
|
| 276 |
</div>
|
| 277 |
+
|
| 278 |
+
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-500 px-1">
|
| 279 |
+
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse" /> Live polling aktif</span>
|
| 280 |
+
<span>Buffer: {logs.length} baris</span>
|
|
|
|
| 281 |
</div>
|
| 282 |
</div>
|
| 283 |
</div>
|
frontend/app/dashboard/layout.tsx
CHANGED
|
@@ -1,48 +1,144 @@
|
|
| 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',
|
| 22 |
{ href: '/dashboard/prediksi', icon: BarChart2, label: 'Prediksi' },
|
| 23 |
-
{ href: '/dashboard/forecast', icon: Terminal,
|
| 24 |
-
{ href: '/dashboard/settings', icon: Settings,
|
| 25 |
]
|
| 26 |
|
| 27 |
return (
|
| 28 |
-
<div className="
|
| 29 |
-
{/*
|
| 30 |
-
<
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
-
</
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
)
|
| 48 |
}
|
|
|
|
| 1 |
+
// app/dashboard/layout.tsx — Dashboard shell with navbar, realtime clock, sidebar nav
|
| 2 |
'use client'
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { useRouter, usePathname } from 'next/navigation'
|
| 5 |
import Link from 'next/link'
|
| 6 |
+
import { BarChart2, Settings, Terminal, Home, LogOut, Activity } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
| 9 |
const router = useRouter()
|
| 10 |
+
const pathname = usePathname()
|
| 11 |
+
const [currentTime, setCurrentTime] = useState('')
|
| 12 |
+
const [status, setStatus] = useState<any>(null)
|
| 13 |
|
| 14 |
useEffect(() => {
|
| 15 |
if (!localStorage.getItem('sikomo_token')) router.push('/')
|
| 16 |
}, [])
|
| 17 |
|
| 18 |
+
// Realtime clock WIB
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const updateClock = () => {
|
| 21 |
+
try {
|
| 22 |
+
setCurrentTime(new Date().toLocaleTimeString('id-ID', {
|
| 23 |
+
timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
| 24 |
+
}))
|
| 25 |
+
} catch {
|
| 26 |
+
setCurrentTime(new Date().toLocaleTimeString('id-ID'))
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
updateClock()
|
| 30 |
+
const timer = setInterval(updateClock, 1000)
|
| 31 |
+
return () => clearInterval(timer)
|
| 32 |
+
}, [])
|
| 33 |
+
|
| 34 |
+
// Polling status
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const fetchStatus = async () => {
|
| 37 |
+
try {
|
| 38 |
+
const token = localStorage.getItem('sikomo_token')
|
| 39 |
+
if (!token) return
|
| 40 |
+
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/status`, {
|
| 41 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 42 |
+
})
|
| 43 |
+
if (res.ok) setStatus(await res.json())
|
| 44 |
+
} catch {}
|
| 45 |
+
}
|
| 46 |
+
fetchStatus()
|
| 47 |
+
const interval = setInterval(fetchStatus, 5000)
|
| 48 |
+
return () => clearInterval(interval)
|
| 49 |
+
}, [])
|
| 50 |
+
|
| 51 |
function logout() {
|
| 52 |
localStorage.removeItem('sikomo_token')
|
| 53 |
router.push('/')
|
| 54 |
}
|
| 55 |
|
| 56 |
const links = [
|
| 57 |
+
{ href: '/dashboard', icon: Home, label: 'Overview' },
|
| 58 |
{ href: '/dashboard/prediksi', icon: BarChart2, label: 'Prediksi' },
|
| 59 |
+
{ href: '/dashboard/forecast', icon: Terminal, label: 'Forecast' },
|
| 60 |
+
{ href: '/dashboard/settings', icon: Settings, label: 'Pengaturan' },
|
| 61 |
]
|
| 62 |
|
| 63 |
return (
|
| 64 |
+
<div className="min-h-screen relative overflow-hidden">
|
| 65 |
+
{/* Subtle background glows */}
|
| 66 |
+
<div className="absolute top-0 right-1/4 w-96 h-96 bg-teal-500/10 rounded-full blur-3xl pointer-events-none" />
|
| 67 |
+
<div className="absolute bottom-10 left-10 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none" />
|
| 68 |
+
|
| 69 |
+
{/* Top Navbar */}
|
| 70 |
+
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-30 px-6 py-4">
|
| 71 |
+
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
| 72 |
+
<div className="flex items-center gap-3">
|
| 73 |
+
<div className="w-10 h-10 bg-gradient-to-tr from-teal-500 to-indigo-500 rounded-xl flex items-center justify-center shadow-md">
|
| 74 |
+
<Activity className="w-5 h-5 text-white" />
|
| 75 |
+
</div>
|
| 76 |
+
<div>
|
| 77 |
+
<h1 className="font-bold text-lg leading-tight bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">
|
| 78 |
+
SIKOMO Forecast
|
| 79 |
+
</h1>
|
| 80 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 81 |
+
<span className="flex h-2 w-2 relative">
|
| 82 |
+
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${status?.is_running ? 'bg-teal-400' : 'bg-rose-400'}`} />
|
| 83 |
+
<span className={`relative inline-flex rounded-full h-2 w-2 ${status?.is_running ? 'bg-teal-500' : 'bg-rose-500'}`} />
|
| 84 |
+
</span>
|
| 85 |
+
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">
|
| 86 |
+
Engine: {status?.is_running ? 'Active' : 'Inactive'}
|
| 87 |
+
</span>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="flex items-center gap-3">
|
| 93 |
+
{/* Navigation pills */}
|
| 94 |
+
<nav className="hidden md:flex items-center gap-1 bg-slate-950/60 px-1.5 py-1 rounded-xl border border-white/5">
|
| 95 |
+
{links.map(l => {
|
| 96 |
+
const active = pathname === l.href
|
| 97 |
+
return (
|
| 98 |
+
<Link key={l.href} href={l.href}
|
| 99 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
| 100 |
+
active ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-slate-200'
|
| 101 |
+
}`}>
|
| 102 |
+
<l.icon size={14} /> {l.label}
|
| 103 |
+
</Link>
|
| 104 |
+
)
|
| 105 |
+
})}
|
| 106 |
+
</nav>
|
| 107 |
+
|
| 108 |
+
{/* Realtime clock */}
|
| 109 |
+
{currentTime && (
|
| 110 |
+
<div className="hidden sm:flex items-center gap-2 bg-slate-950/60 px-3 py-1.5 rounded-xl border border-white/5 font-mono text-xs text-teal-400">
|
| 111 |
+
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
|
| 112 |
+
<span>{currentTime} WIB</span>
|
| 113 |
+
</div>
|
| 114 |
+
)}
|
| 115 |
+
|
| 116 |
+
<button onClick={logout}
|
| 117 |
+
className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium border border-slate-700 transition">
|
| 118 |
+
<LogOut className="w-3.5 h-3.5" /> Keluar
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
</div>
|
| 122 |
+
</header>
|
| 123 |
+
|
| 124 |
+
{/* Mobile nav */}
|
| 125 |
+
<nav className="md:hidden flex items-center gap-1 px-4 py-2 border-b border-slate-800 bg-slate-900/60 overflow-x-auto">
|
| 126 |
+
{links.map(l => {
|
| 127 |
+
const active = pathname === l.href
|
| 128 |
+
return (
|
| 129 |
+
<Link key={l.href} href={l.href}
|
| 130 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition ${
|
| 131 |
+
active ? 'bg-slate-700 text-white' : 'text-slate-400'
|
| 132 |
+
}`}>
|
| 133 |
+
<l.icon size={14} /> {l.label}
|
| 134 |
+
</Link>
|
| 135 |
+
)
|
| 136 |
+
})}
|
| 137 |
+
</nav>
|
| 138 |
+
|
| 139 |
+
<main className="max-w-7xl mx-auto px-6 py-8 relative z-10 animate-fade-in">
|
| 140 |
+
{children}
|
| 141 |
+
</main>
|
| 142 |
</div>
|
| 143 |
)
|
| 144 |
}
|
frontend/app/dashboard/page.tsx
CHANGED
|
@@ -1,68 +1,139 @@
|
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
useEffect(() => {
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
}, [])
|
| 14 |
|
| 15 |
return (
|
| 16 |
-
<div>
|
| 17 |
-
<h1 className="text-2xl font-bold
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
</p>
|
| 30 |
</div>
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
</p>
|
|
|
|
| 36 |
</div>
|
| 37 |
</div>
|
| 38 |
|
| 39 |
-
{/*
|
| 40 |
-
<div className="
|
| 41 |
-
<
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
<
|
| 55 |
-
<
|
| 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 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
</div>
|
| 68 |
)
|
|
|
|
| 1 |
+
// app/dashboard/page.tsx — Overview with bento grid, stats, polling
|
| 2 |
'use client'
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { api } from '@/lib/api'
|
| 5 |
+
import { Activity, BarChart3, Calendar, Layers, TrendingUp, Zap } from 'lucide-react'
|
| 6 |
|
| 7 |
export default function OverviewPage() {
|
| 8 |
const [komoditas, setKomoditas] = useState<any[]>([])
|
| 9 |
+
const [status, setStatus] = useState<any>(null)
|
| 10 |
+
const [schedules, setSchedules] = useState<any[]>([])
|
| 11 |
+
|
| 12 |
+
const fetchData = async () => {
|
| 13 |
+
try {
|
| 14 |
+
const [k, s, sc] = await Promise.all([
|
| 15 |
+
api.getKomoditas(), api.getStatus(), api.getSchedules()
|
| 16 |
+
])
|
| 17 |
+
setKomoditas(k); setStatus(s); setSchedules(sc)
|
| 18 |
+
} catch {}
|
| 19 |
+
}
|
| 20 |
|
| 21 |
useEffect(() => {
|
| 22 |
+
fetchData()
|
| 23 |
+
const interval = setInterval(fetchData, 5000)
|
| 24 |
+
return () => clearInterval(interval)
|
| 25 |
}, [])
|
| 26 |
|
| 27 |
return (
|
| 28 |
+
<div className="space-y-6">
|
| 29 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">
|
| 30 |
+
Dashboard Overview
|
| 31 |
+
</h1>
|
| 32 |
+
|
| 33 |
+
{/* Stats Bento Grid */}
|
| 34 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 35 |
+
{/* Total Komoditas */}
|
| 36 |
+
<div className="glass-panel rounded-2xl p-5 border border-white/5 animate-slide-up">
|
| 37 |
+
<div className="flex items-center gap-2 mb-3">
|
| 38 |
+
<Layers className="w-4 h-4 text-teal-400" />
|
| 39 |
+
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Total Komoditas</span>
|
| 40 |
+
</div>
|
| 41 |
+
<p className="text-3xl font-bold text-white">{komoditas.length}</p>
|
| 42 |
+
<p className="text-xs text-slate-500 mt-1">komoditas aktif terdaftar</p>
|
| 43 |
+
</div>
|
| 44 |
|
| 45 |
+
{/* Scheduler Status */}
|
| 46 |
+
<div className="glass-panel rounded-2xl p-5 border border-white/5 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
| 47 |
+
<div className="flex items-center gap-2 mb-3">
|
| 48 |
+
<Activity className="w-4 h-4 text-indigo-400" />
|
| 49 |
+
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Scheduler</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="flex items-center gap-2">
|
| 52 |
+
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
| 53 |
+
status?.is_running ? 'bg-teal-500/10 text-teal-400 border border-teal-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
|
| 54 |
+
}`}>
|
| 55 |
+
{status?.is_running ? 'RUNNING' : 'STOPPED'}
|
| 56 |
+
</span>
|
| 57 |
+
</div>
|
| 58 |
+
<p className="text-xs text-slate-500 mt-2">{schedules.length} jadwal terdaftar</p>
|
| 59 |
</div>
|
| 60 |
+
|
| 61 |
+
{/* Next Run */}
|
| 62 |
+
<div className="glass-panel rounded-2xl p-5 border border-white/5 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
| 63 |
+
<div className="flex items-center gap-2 mb-3">
|
| 64 |
+
<Calendar className="w-4 h-4 text-amber-400" />
|
| 65 |
+
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Run Berikutnya</span>
|
| 66 |
+
</div>
|
| 67 |
+
<p className="text-sm font-bold text-teal-400 truncate">
|
| 68 |
+
{status?.next_run
|
| 69 |
+
? new Date(status.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' })
|
| 70 |
+
: 'Tidak ada jadwal'}
|
| 71 |
</p>
|
| 72 |
</div>
|
| 73 |
+
|
| 74 |
+
{/* Active Tasks */}
|
| 75 |
+
<div className="glass-panel rounded-2xl p-5 border border-white/5 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
| 76 |
+
<div className="flex items-center gap-2 mb-3">
|
| 77 |
+
<Zap className="w-4 h-4 text-amber-400" />
|
| 78 |
+
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Forecast Aktif</span>
|
| 79 |
+
</div>
|
| 80 |
+
<p className="text-3xl font-bold text-white">
|
| 81 |
+
{status?.running_tasks ? Object.keys(status.running_tasks).length : 0}
|
| 82 |
</p>
|
| 83 |
+
<p className="text-xs text-slate-500 mt-1">komoditas sedang diproses</p>
|
| 84 |
</div>
|
| 85 |
</div>
|
| 86 |
|
| 87 |
+
{/* Komoditas Table */}
|
| 88 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5 animate-slide-up" style={{ animationDelay: '0.4s' }}>
|
| 89 |
+
<div className="flex items-center justify-between mb-5">
|
| 90 |
+
<h2 className="text-base font-bold text-slate-100 flex items-center gap-2">
|
| 91 |
+
<BarChart3 className="w-5 h-5 text-teal-400" /> Daftar Komoditas
|
| 92 |
+
</h2>
|
| 93 |
+
<span className="text-xs text-slate-500">{komoditas.length} items</span>
|
| 94 |
+
</div>
|
| 95 |
+
<div className="overflow-x-auto">
|
| 96 |
+
<table className="w-full text-left border-collapse">
|
| 97 |
+
<thead>
|
| 98 |
+
<tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
|
| 99 |
+
<th className="pb-3 px-3">ID</th>
|
| 100 |
+
<th className="pb-3 px-3">Nama Komoditas</th>
|
| 101 |
+
<th className="pb-3 px-3">Volatilitas</th>
|
| 102 |
+
<th className="pb-3 px-3">Skor</th>
|
| 103 |
+
<th className="pb-3 px-3">Status</th>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
</tr>
|
| 105 |
+
</thead>
|
| 106 |
+
<tbody className="divide-y divide-slate-800/60 text-sm text-slate-300">
|
| 107 |
+
{komoditas.map((k: any) => (
|
| 108 |
+
<tr key={k.id} className="hover:bg-slate-800/30 transition">
|
| 109 |
+
<td className="py-3 px-3 text-slate-500 font-mono text-xs">{k.id}</td>
|
| 110 |
+
<td className="py-3 px-3 font-medium text-slate-200">{k.nama}</td>
|
| 111 |
+
<td className="py-3 px-3">
|
| 112 |
+
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
| 113 |
+
k.volatile
|
| 114 |
+
? 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
|
| 115 |
+
: 'bg-teal-500/10 text-teal-400 border border-teal-500/20'
|
| 116 |
+
}`}>
|
| 117 |
+
{k.volatile ? 'Tinggi' : 'Rendah'}
|
| 118 |
+
</span>
|
| 119 |
+
</td>
|
| 120 |
+
<td className="py-3 px-3 font-mono text-xs text-slate-400">
|
| 121 |
+
{k.volatilitas_skor?.toFixed(2) ?? '-'}
|
| 122 |
+
</td>
|
| 123 |
+
<td className="py-3 px-3">
|
| 124 |
+
{status?.running_tasks?.[k.id] ? (
|
| 125 |
+
<span className="flex items-center gap-1 text-xs text-amber-400">
|
| 126 |
+
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" /> Running
|
| 127 |
+
</span>
|
| 128 |
+
) : (
|
| 129 |
+
<span className="text-xs text-slate-500">Idle</span>
|
| 130 |
+
)}
|
| 131 |
+
</td>
|
| 132 |
+
</tr>
|
| 133 |
+
))}
|
| 134 |
+
</tbody>
|
| 135 |
+
</table>
|
| 136 |
+
</div>
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
)
|
frontend/app/dashboard/prediksi/page.tsx
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 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]
|
| 10 |
-
const [data, setData]
|
| 11 |
const [editInsight, setEditInsight] = useState<any>(null)
|
| 12 |
-
const [newInsight, setNewInsight]
|
| 13 |
-
const [form, setForm]
|
| 14 |
const [editRingkasan, setEditRingkasan] = useState(false)
|
| 15 |
-
const [ringForm, setRingForm]
|
| 16 |
const [editModel, setEditModel] = useState(false)
|
| 17 |
-
const [modelForm, setModelForm] = useState({ deskripsi:'', catatan_validasi:'' })
|
| 18 |
|
| 19 |
useEffect(() => { api.getKomoditas().then(setKomoditas) }, [])
|
| 20 |
|
|
@@ -22,239 +22,215 @@ export default function PrediksiPage() {
|
|
| 22 |
setSelected(id)
|
| 23 |
const d = await api.getPrediksi(id)
|
| 24 |
setData(d)
|
| 25 |
-
setRingForm({
|
| 26 |
-
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
}
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
{/*
|
| 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-
|
| 81 |
-
selected === k.id
|
| 82 |
-
|
| 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
|
| 94 |
-
<div className="
|
| 95 |
<div className="flex justify-between items-center mb-3">
|
| 96 |
-
<h2 className="font-semibold">
|
| 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-
|
| 102 |
-
<div className="flex justify-between">
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
</
|
| 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 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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-
|
| 130 |
-
<button onClick={() => setEditRingkasan(false)} className="flex items-center gap-1 px-3 py-1 bg-
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
)}
|
| 134 |
</div>
|
| 135 |
|
| 136 |
{/* Model ML */}
|
| 137 |
-
<div className="
|
| 138 |
<div className="flex justify-between items-center mb-3">
|
| 139 |
-
<h2 className="font-semibold">
|
| 140 |
-
<button onClick={() => setEditModel(!editModel)} className="text-
|
| 141 |
</div>
|
| 142 |
{!editModel ? (
|
| 143 |
-
<div className="space-y-
|
| 144 |
-
<div className="flex justify-between"><span className="text-
|
| 145 |
-
<div className="flex justify-between"><span className="text-
|
| 146 |
-
<div className="flex justify-between"><span className="text-
|
| 147 |
-
<div className="flex justify-between"><span className="text-
|
| 148 |
-
<span className="uppercase text-
|
| 149 |
</div>
|
| 150 |
-
<p className="text-
|
| 151 |
</div>
|
| 152 |
) : (
|
| 153 |
<div className="space-y-3">
|
| 154 |
-
<div>
|
| 155 |
-
<
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 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-
|
| 166 |
-
<button onClick={() => setEditModel(false)} className="flex items-center gap-1 px-3 py-1 bg-
|
| 167 |
</div>
|
| 168 |
</div>
|
| 169 |
)}
|
| 170 |
</div>
|
| 171 |
|
| 172 |
-
{/*
|
| 173 |
-
<div className="col-span-2
|
| 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-
|
| 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-
|
| 185 |
-
<textarea value={form.konten} onChange={e => setForm({...form, konten: e.target.value})}
|
| 186 |
-
|
| 187 |
<div className="flex gap-2">
|
| 188 |
-
<select value={form.tipe} onChange={e => setForm({...form, tipe: e.target.value})}
|
| 189 |
-
|
| 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 |
-
|
| 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-
|
| 199 |
-
<button onClick={() => setEditInsight(null)} className="flex items-center gap-1 px-3 py-1 bg-
|
| 200 |
</div>
|
| 201 |
</div>
|
| 202 |
) : (
|
| 203 |
-
<div className="flex items-start justify-between bg-
|
| 204 |
<div className="flex items-start gap-3">
|
| 205 |
-
<span className={`text-
|
| 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 |
-
|
| 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-
|
| 221 |
-
<textarea value={form.konten} onChange={e => setForm({...form, konten: e.target.value})}
|
| 222 |
-
|
| 223 |
<div className="flex gap-2">
|
| 224 |
-
<select value={form.tipe} onChange={e => setForm({...form, tipe: e.target.value})}
|
| 225 |
-
|
| 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 |
-
|
| 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-
|
| 235 |
-
<button onClick={() => setNewInsight(false)} className="flex items-center gap-1 px-3 py-1 bg-
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
)}
|
| 239 |
</div>
|
| 240 |
</div>
|
| 241 |
|
| 242 |
-
{/*
|
| 243 |
-
<div className="col-span-2
|
| 244 |
-
<h2 className="font-semibold mb-4
|
|
|
|
|
|
|
|
|
|
| 245 |
<div className="overflow-x-auto">
|
| 246 |
-
<table className="w-full text-
|
| 247 |
-
<thead><tr className="
|
| 248 |
-
<th className="
|
| 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="
|
| 255 |
-
<td className="py-
|
| 256 |
-
<td className="py-
|
| 257 |
-
<td className="py-
|
| 258 |
</tr>
|
| 259 |
))}
|
| 260 |
</tbody>
|
|
|
|
| 1 |
+
// app/dashboard/prediksi/page.tsx — Prediksi data + Model Comparison
|
| 2 |
'use client'
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { api } from '@/lib/api'
|
| 5 |
+
import { Pencil, Trash2, Plus, Check, X, BarChart3, TrendingUp, Shield, Layers } 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 |
|
|
|
|
| 22 |
setSelected(id)
|
| 23 |
const d = await api.getPrediksi(id)
|
| 24 |
setData(d)
|
| 25 |
+
setRingForm({ status_analisis: d.ringkasan?.status_analisis || '', deskripsi_status: d.ringkasan?.deskripsi_status || '' })
|
| 26 |
+
setModelForm({ deskripsi: d.model?.deskripsi || '', catatan_validasi: d.model?.catatan_validasi || '' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
+
async function saveInsightEdit() { await api.updateInsight(editInsight.id, form); setEditInsight(null); selectKomoditas(selected!) }
|
| 30 |
+
async function saveNewInsight() { await api.addInsight(selected!, form); setNewInsight(false); setForm({ konten: '', tipe: 'positif', ikon: 'check-circle', urutan: 1 }); selectKomoditas(selected!) }
|
| 31 |
+
async function deleteInsight(id: number) { if (!confirm('Hapus insight ini?')) return; await api.deleteInsight(id); selectKomoditas(selected!) }
|
| 32 |
+
async function saveRingkasan() { await api.updateRingkasan(selected!, ringForm); setEditRingkasan(false); selectKomoditas(selected!) }
|
| 33 |
+
async function saveModel() { await api.updateModelMl(selected!, modelForm); setEditModel(false); selectKomoditas(selected!) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
const tipeColor: Record<string, string> = {
|
| 36 |
+
positif: 'bg-teal-500/10 text-teal-400 border-teal-500/20',
|
| 37 |
+
negatif: 'bg-rose-500/10 text-rose-400 border-rose-500/20',
|
| 38 |
+
netral: 'bg-slate-700/50 text-slate-300 border-slate-600/30',
|
| 39 |
}
|
| 40 |
|
| 41 |
+
function metricColor(val: number, type: 'mape' | 'r2') {
|
| 42 |
+
if (type === 'mape') return val < 5 ? 'text-teal-400' : val < 15 ? 'text-amber-400' : 'text-rose-400'
|
| 43 |
+
return val > 0.8 ? 'text-teal-400' : val > 0.5 ? 'text-amber-400' : 'text-rose-400'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
return (
|
| 47 |
<div className="space-y-6">
|
| 48 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">Data Prediksi</h1>
|
| 49 |
|
| 50 |
+
{/* Komoditas selector */}
|
| 51 |
<div className="flex gap-2 flex-wrap">
|
| 52 |
{komoditas.map((k: any) => (
|
| 53 |
<button key={k.id} onClick={() => selectKomoditas(k.id)}
|
| 54 |
+
className={`px-4 py-2 rounded-xl text-xs font-medium border transition ${
|
| 55 |
+
selected === k.id ? 'bg-gradient-to-r from-teal-500/20 to-indigo-500/20 border-teal-500/30 text-white' : 'border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200'
|
| 56 |
+
}`}>{k.nama}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
))}
|
| 58 |
</div>
|
| 59 |
|
| 60 |
{data && (
|
| 61 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in">
|
| 62 |
+
|
| 63 |
+
{/* Model Comparison (NEW) */}
|
| 64 |
+
{data.all_models && data.all_models.length > 0 && (
|
| 65 |
+
<div className="lg:col-span-2 glass-panel rounded-2xl p-6 border border-white/5">
|
| 66 |
+
<h2 className="text-base font-bold text-slate-100 flex items-center gap-2 mb-4">
|
| 67 |
+
<Layers className="w-5 h-5 text-indigo-400" /> Perbandingan Model
|
| 68 |
+
</h2>
|
| 69 |
+
<div className="overflow-x-auto">
|
| 70 |
+
<table className="w-full text-left border-collapse">
|
| 71 |
+
<thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
|
| 72 |
+
<th className="pb-3 px-3">Model</th><th className="pb-3 px-3">RMSE</th><th className="pb-3 px-3">MAPE (%)</th><th className="pb-3 px-3">MAE</th><th className="pb-3 px-3">R²</th><th className="pb-3 px-3">Status</th>
|
| 73 |
+
</tr></thead>
|
| 74 |
+
<tbody className="divide-y divide-slate-800/60 text-sm">
|
| 75 |
+
{data.all_models.map((m: any, i: number) => (
|
| 76 |
+
<tr key={i} className={`hover:bg-slate-800/30 transition ${m.is_active ? 'bg-teal-500/5' : ''}`}>
|
| 77 |
+
<td className="py-3 px-3 font-medium text-slate-200">{m.nama_model}</td>
|
| 78 |
+
<td className="py-3 px-3 font-mono text-xs text-slate-300">{Number(m.rmse).toFixed(2)}</td>
|
| 79 |
+
<td className={`py-3 px-3 font-mono text-xs font-bold ${metricColor(m.mape, 'mape')}`}>{Number(m.mape).toFixed(2)}%</td>
|
| 80 |
+
<td className="py-3 px-3 font-mono text-xs text-slate-300">{Number(m.mae).toFixed(2)}</td>
|
| 81 |
+
<td className={`py-3 px-3 font-mono text-xs font-bold ${metricColor(m.r2_score, 'r2')}`}>{Number(m.r2_score).toFixed(4)}</td>
|
| 82 |
+
<td className="py-3 px-3">
|
| 83 |
+
{m.is_active ? (
|
| 84 |
+
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-gradient-to-r from-teal-500/20 to-indigo-500/20 text-teal-400 border border-teal-500/20">🏆 BEST</span>
|
| 85 |
+
) : <span className="text-xs text-slate-500">Runner-up</span>}
|
| 86 |
+
</td>
|
| 87 |
+
</tr>
|
| 88 |
+
))}
|
| 89 |
+
</tbody>
|
| 90 |
+
</table>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
|
| 95 |
+
{/* Ringkasan */}
|
| 96 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 97 |
<div className="flex justify-between items-center mb-3">
|
| 98 |
+
<h2 className="font-semibold flex items-center gap-2"><TrendingUp className="w-4 h-4 text-teal-400" /> Ringkasan</h2>
|
| 99 |
+
<button onClick={() => setEditRingkasan(!editRingkasan)} className="text-slate-400 hover:text-white"><Pencil size={15} /></button>
|
|
|
|
| 100 |
</div>
|
| 101 |
{!editRingkasan ? (
|
| 102 |
+
<div className="space-y-3 text-sm">
|
| 103 |
+
<div className="flex justify-between"><span className="text-slate-400">Rentang Harga</span><span className="font-mono">Rp {data.ringkasan?.harga_min?.toLocaleString()} – {data.ringkasan?.harga_max?.toLocaleString()}</span></div>
|
| 104 |
+
<div className="flex justify-between"><span className="text-slate-400">Tren</span><span>{data.ringkasan?.tren}</span></div>
|
| 105 |
+
<div className="flex justify-between"><span className="text-slate-400">Status</span><span>{data.ringkasan?.status_analisis}</span></div>
|
| 106 |
+
<p className="text-slate-500 text-xs mt-2 bg-slate-900/50 rounded-lg p-3 border border-white/5">{data.ringkasan?.deskripsi_status}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
</div>
|
| 108 |
) : (
|
| 109 |
<div className="space-y-3">
|
| 110 |
+
<div><label className="text-xs text-slate-400">Status Analisis</label>
|
| 111 |
+
<input value={ringForm.status_analisis} onChange={e => setRingForm({ ...ringForm, status_analisis: e.target.value })}
|
| 112 |
+
className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
|
| 113 |
+
<div><label className="text-xs text-slate-400">Deskripsi</label>
|
| 114 |
+
<textarea value={ringForm.deskripsi_status} onChange={e => setRingForm({ ...ringForm, deskripsi_status: e.target.value })}
|
| 115 |
+
rows={3} className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<div className="flex gap-2">
|
| 117 |
+
<button onClick={saveRingkasan} className="flex items-center gap-1 px-3 py-1.5 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Simpan</button>
|
| 118 |
+
<button onClick={() => setEditRingkasan(false)} className="flex items-center gap-1 px-3 py-1.5 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
)}
|
| 122 |
</div>
|
| 123 |
|
| 124 |
{/* Model ML */}
|
| 125 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 126 |
<div className="flex justify-between items-center mb-3">
|
| 127 |
+
<h2 className="font-semibold flex items-center gap-2"><Shield className="w-4 h-4 text-indigo-400" /> Model Aktif</h2>
|
| 128 |
+
<button onClick={() => setEditModel(!editModel)} className="text-slate-400 hover:text-white"><Pencil size={15} /></button>
|
| 129 |
</div>
|
| 130 |
{!editModel ? (
|
| 131 |
+
<div className="space-y-3 text-sm">
|
| 132 |
+
<div className="flex justify-between"><span className="text-slate-400">Model</span><span className="font-bold text-teal-400">{data.model?.nama_model}</span></div>
|
| 133 |
+
<div className="flex justify-between"><span className="text-slate-400">MAPE</span><span className="font-mono">{data.model?.mape}%</span></div>
|
| 134 |
+
<div className="flex justify-between"><span className="text-slate-400">RMSE</span><span className="font-mono">{data.model?.rmse}</span></div>
|
| 135 |
+
<div className="flex justify-between"><span className="text-slate-400">Stabilitas</span>
|
| 136 |
+
<span className="uppercase text-[11px] font-bold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">{data.model?.stabilitas}</span>
|
| 137 |
</div>
|
| 138 |
+
<p className="text-slate-500 text-xs mt-2">{data.model?.deskripsi}</p>
|
| 139 |
</div>
|
| 140 |
) : (
|
| 141 |
<div className="space-y-3">
|
| 142 |
+
<div><label className="text-xs text-slate-400">Deskripsi</label>
|
| 143 |
+
<textarea value={modelForm.deskripsi} onChange={e => setModelForm({ ...modelForm, deskripsi: e.target.value })}
|
| 144 |
+
rows={3} className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
|
| 145 |
+
<div><label className="text-xs text-slate-400">Catatan Validasi</label>
|
| 146 |
+
<textarea value={modelForm.catatan_validasi} onChange={e => setModelForm({ ...modelForm, catatan_validasi: e.target.value })}
|
| 147 |
+
rows={2} className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
<div className="flex gap-2">
|
| 149 |
+
<button onClick={saveModel} className="flex items-center gap-1 px-3 py-1.5 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Simpan</button>
|
| 150 |
+
<button onClick={() => setEditModel(false)} className="flex items-center gap-1 px-3 py-1.5 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
)}
|
| 154 |
</div>
|
| 155 |
|
| 156 |
+
{/* Insights */}
|
| 157 |
+
<div className="lg:col-span-2 glass-panel rounded-2xl p-6 border border-white/5">
|
| 158 |
<div className="flex justify-between items-center mb-4">
|
| 159 |
+
<h2 className="font-semibold flex items-center gap-2"><BarChart3 className="w-4 h-4 text-amber-400" /> Insight Prediktif</h2>
|
| 160 |
+
<button onClick={() => setNewInsight(true)} className="flex items-center gap-1 px-3 py-1.5 bg-teal-500 hover:bg-teal-400 text-slate-900 rounded-lg text-xs font-bold">
|
| 161 |
+
<Plus size={13} /> Tambah
|
| 162 |
</button>
|
| 163 |
</div>
|
| 164 |
<div className="space-y-3">
|
| 165 |
{(data.insights || []).map((ins: any) => (
|
| 166 |
<div key={ins.id}>
|
| 167 |
{editInsight?.id === ins.id ? (
|
| 168 |
+
<div className="bg-slate-900/60 rounded-xl p-4 space-y-2 border border-white/5">
|
| 169 |
+
<textarea value={form.konten} onChange={e => setForm({ ...form, konten: e.target.value })} rows={2}
|
| 170 |
+
className="w-full glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" />
|
| 171 |
<div className="flex gap-2">
|
| 172 |
+
<select value={form.tipe} onChange={e => setForm({ ...form, tipe: e.target.value })} className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none">
|
| 173 |
+
<option value="positif">positif</option><option value="negatif">negatif</option><option value="netral">netral</option>
|
|
|
|
| 174 |
</select>
|
| 175 |
+
<input value={form.ikon} onChange={e => setForm({ ...form, ikon: e.target.value })} placeholder="ikon" className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none flex-1" />
|
| 176 |
+
<input type="number" value={form.urutan} onChange={e => setForm({ ...form, urutan: parseInt(e.target.value) })} className="w-16 glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none" />
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
<div className="flex gap-2">
|
| 179 |
+
<button onClick={saveInsightEdit} className="flex items-center gap-1 px-3 py-1 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Simpan</button>
|
| 180 |
+
<button onClick={() => setEditInsight(null)} className="flex items-center gap-1 px-3 py-1 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
|
| 181 |
</div>
|
| 182 |
</div>
|
| 183 |
) : (
|
| 184 |
+
<div className="flex items-start justify-between bg-slate-900/50 rounded-xl px-4 py-3 border border-white/5 hover:border-white/10 transition">
|
| 185 |
<div className="flex items-start gap-3">
|
| 186 |
+
<span className={`text-[11px] px-2.5 py-0.5 rounded-full mt-0.5 font-bold border ${tipeColor[ins.tipe] || tipeColor.netral}`}>{ins.tipe}</span>
|
| 187 |
+
<p className="text-sm text-slate-200">{ins.konten}</p>
|
| 188 |
</div>
|
| 189 |
<div className="flex gap-2 ml-4 shrink-0">
|
| 190 |
+
<button onClick={() => { setEditInsight(ins); setForm({ konten: ins.konten, tipe: ins.tipe, ikon: ins.ikon, urutan: ins.urutan }) }} className="text-slate-400 hover:text-white"><Pencil size={14} /></button>
|
| 191 |
+
<button onClick={() => deleteInsight(ins.id)} className="text-slate-400 hover:text-rose-400"><Trash2 size={14} /></button>
|
|
|
|
| 192 |
</div>
|
| 193 |
</div>
|
| 194 |
)}
|
| 195 |
</div>
|
| 196 |
))}
|
|
|
|
|
|
|
| 197 |
{newInsight && (
|
| 198 |
+
<div className="bg-slate-900/60 rounded-xl p-4 space-y-2 border border-teal-500/20">
|
| 199 |
+
<textarea value={form.konten} onChange={e => setForm({ ...form, konten: e.target.value })} rows={2} placeholder="Isi insight..."
|
| 200 |
+
className="w-full glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" />
|
| 201 |
<div className="flex gap-2">
|
| 202 |
+
<select value={form.tipe} onChange={e => setForm({ ...form, tipe: e.target.value })} className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none">
|
| 203 |
+
<option value="positif">positif</option><option value="negatif">negatif</option><option value="netral">netral</option>
|
|
|
|
| 204 |
</select>
|
| 205 |
+
<input value={form.ikon} onChange={e => setForm({ ...form, ikon: e.target.value })} placeholder="ikon" className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none flex-1" />
|
| 206 |
+
<input type="number" value={form.urutan} onChange={e => setForm({ ...form, urutan: parseInt(e.target.value) })} className="w-16 glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none" />
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
<div className="flex gap-2">
|
| 209 |
+
<button onClick={saveNewInsight} className="flex items-center gap-1 px-3 py-1 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Tambah</button>
|
| 210 |
+
<button onClick={() => setNewInsight(false)} className="flex items-center gap-1 px-3 py-1 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
|
| 211 |
</div>
|
| 212 |
</div>
|
| 213 |
)}
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
+
{/* Prediksi 7 Hari */}
|
| 218 |
+
<div className="lg:col-span-2 glass-panel rounded-2xl p-6 border border-white/5">
|
| 219 |
+
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
| 220 |
+
<BarChart3 className="w-4 h-4 text-teal-400" /> Hasil Prediksi 7 Hari
|
| 221 |
+
<span className="text-slate-500 text-xs font-normal ml-1">(read-only)</span>
|
| 222 |
+
</h2>
|
| 223 |
<div className="overflow-x-auto">
|
| 224 |
+
<table className="w-full text-left border-collapse">
|
| 225 |
+
<thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
|
| 226 |
+
<th className="pb-3 px-3">Tanggal</th><th className="pb-3 px-3 text-right">Harga Prediksi</th><th className="pb-3 px-3 text-right">Confidence</th>
|
|
|
|
|
|
|
| 227 |
</tr></thead>
|
| 228 |
+
<tbody className="divide-y divide-slate-800/60 text-sm">
|
| 229 |
{(data.prediksi_7hari || []).map((p: any, i: number) => (
|
| 230 |
+
<tr key={i} className="hover:bg-slate-800/30 transition">
|
| 231 |
+
<td className="py-3 px-3 text-slate-300">{p.tanggal_target}</td>
|
| 232 |
+
<td className="py-3 px-3 text-right font-mono font-bold text-white">Rp {Number(p.harga_prediksi).toLocaleString()}</td>
|
| 233 |
+
<td className="py-3 px-3 text-right text-slate-400">{p.confidence_level}%</td>
|
| 234 |
</tr>
|
| 235 |
))}
|
| 236 |
</tbody>
|
frontend/app/dashboard/settings/page.tsx
CHANGED
|
@@ -1,59 +1,96 @@
|
|
| 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
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 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 [
|
| 23 |
-
const [saved, setSaved]
|
|
|
|
| 24 |
|
| 25 |
-
useEffect(() => { api.
|
| 26 |
|
| 27 |
async function save(key: string) {
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
return (
|
| 34 |
-
<div className="max-w-
|
| 35 |
-
<h1 className="text-2xl font-bold">
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</div>
|
| 49 |
-
|
| 50 |
-
|
| 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 |
)
|
|
|
|
| 1 |
+
// app/dashboard/settings/page.tsx — Runtime config editor with glass UI
|
| 2 |
'use client'
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { api } from '@/lib/api'
|
| 5 |
+
import { Save, Settings, Shield, Sliders, CheckCircle } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
const RUNTIME_FIELDS: Record<string, { label: string; desc: string; type: string }> = {
|
| 8 |
+
optuna_trials: { label: 'Jumlah Optuna Trials', desc: 'Jumlah percobaan hyperparameter tuning', type: 'number' },
|
| 9 |
+
model_dir: { label: 'Folder Model', desc: 'Direktori penyimpanan file model .pkl', type: 'text' },
|
| 10 |
+
forecast_days: { label: 'Hari Prediksi', desc: 'Jumlah hari prediksi ke depan', type: 'number' },
|
| 11 |
+
default_schedules: { label: 'Default Schedules', desc: 'Cron expressions default (comma-separated)', type: 'text' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
export default function SettingsPage() {
|
| 15 |
+
const [settings, setSettings] = useState<Record<string, any>>({})
|
| 16 |
+
const [saved, setSaved] = useState<string | null>(null)
|
| 17 |
+
const [saving, setSaving] = useState<string | null>(null)
|
| 18 |
|
| 19 |
+
useEffect(() => { api.getSettings().then(setSettings) }, [])
|
| 20 |
|
| 21 |
async function save(key: string) {
|
| 22 |
+
setSaving(key)
|
| 23 |
+
try {
|
| 24 |
+
await api.updateSetting(key, String(settings[key] || ''))
|
| 25 |
+
setSaved(key)
|
| 26 |
+
setTimeout(() => setSaved(null), 2500)
|
| 27 |
+
} catch (e: any) {
|
| 28 |
+
alert(`❌ Gagal: ${e.message}`)
|
| 29 |
+
} finally {
|
| 30 |
+
setSaving(null)
|
| 31 |
+
}
|
| 32 |
}
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div className="max-w-3xl space-y-6">
|
| 36 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">
|
| 37 |
+
Pengaturan
|
| 38 |
+
</h1>
|
| 39 |
+
|
| 40 |
+
{/* Runtime Config */}
|
| 41 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 42 |
+
<div className="flex items-center gap-2 mb-1">
|
| 43 |
+
<Sliders className="w-5 h-5 text-teal-400" />
|
| 44 |
+
<h2 className="text-base font-bold text-slate-100">Konfigurasi Runtime</h2>
|
| 45 |
+
</div>
|
| 46 |
+
<p className="text-xs text-slate-400 mb-6">Perubahan langsung berlaku tanpa restart server.</p>
|
| 47 |
+
|
| 48 |
+
<div className="space-y-5">
|
| 49 |
+
{Object.entries(RUNTIME_FIELDS).map(([key, cfg]) => (
|
| 50 |
+
<div key={key} className="flex items-start gap-4">
|
| 51 |
+
<div className="flex-1">
|
| 52 |
+
<label className="text-xs font-semibold text-slate-300 block mb-1">{cfg.label}</label>
|
| 53 |
+
<p className="text-[11px] text-slate-500 mb-2">{cfg.desc}</p>
|
| 54 |
+
<input
|
| 55 |
+
type={cfg.type}
|
| 56 |
+
value={settings[key] ?? ''}
|
| 57 |
+
onChange={e => setSettings({ ...settings, [key]: e.target.value })}
|
| 58 |
+
className="w-full glass-input rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none transition-all"
|
| 59 |
+
/>
|
| 60 |
+
</div>
|
| 61 |
+
<div className="pt-8">
|
| 62 |
+
<button onClick={() => save(key)} disabled={saving === key}
|
| 63 |
+
className={`flex items-center gap-1.5 px-4 py-2.5 rounded-xl text-xs font-bold transition ${
|
| 64 |
+
saved === key
|
| 65 |
+
? 'bg-teal-500/10 text-teal-400 border border-teal-500/20'
|
| 66 |
+
: 'bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700'
|
| 67 |
+
} disabled:opacity-50`}>
|
| 68 |
+
{saved === key ? <><CheckCircle className="w-3.5 h-3.5" /> Tersimpan</> : <><Save className="w-3.5 h-3.5" /> Simpan</>}
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
))}
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{/* Info Card */}
|
| 77 |
+
<div className="glass-panel rounded-2xl p-6 border border-white/5">
|
| 78 |
+
<div className="flex items-center gap-2 mb-3">
|
| 79 |
+
<Shield className="w-5 h-5 text-indigo-400" />
|
| 80 |
+
<h2 className="text-base font-bold text-slate-100">Keamanan & Database</h2>
|
| 81 |
+
</div>
|
| 82 |
+
<p className="text-xs text-slate-400 mb-4">
|
| 83 |
+
Kredensial database dan API key dikonfigurasi melalui file <code className="px-1.5 py-0.5 bg-slate-800 rounded text-teal-400">.env</code> di server.
|
| 84 |
+
Untuk keamanan, nilai-nilai ini tidak ditampilkan di dashboard.
|
| 85 |
+
</p>
|
| 86 |
+
<div className="grid grid-cols-2 gap-3">
|
| 87 |
+
{['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER'].map(k => (
|
| 88 |
+
<div key={k} className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
|
| 89 |
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider block">{k}</span>
|
| 90 |
+
<span className="text-xs text-slate-400 font-mono">••••••••</span>
|
| 91 |
</div>
|
| 92 |
+
))}
|
| 93 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
)
|
frontend/app/globals.css
CHANGED
|
@@ -3,6 +3,53 @@
|
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
body {
|
| 6 |
-
background-color: #
|
| 7 |
-
color: #
|
| 8 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
body {
|
| 6 |
+
background-color: #020617; /* slate-950 */
|
| 7 |
+
color: #f1f5f9; /* slate-100 */
|
| 8 |
}
|
| 9 |
+
|
| 10 |
+
@layer utilities {
|
| 11 |
+
.glass-panel {
|
| 12 |
+
background: rgba(15, 23, 42, 0.65);
|
| 13 |
+
backdrop-filter: blur(12px);
|
| 14 |
+
-webkit-backdrop-filter: blur(12px);
|
| 15 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 16 |
+
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.glass-input {
|
| 20 |
+
background: rgba(255, 255, 255, 0.05);
|
| 21 |
+
backdrop-filter: blur(4px);
|
| 22 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.glass-input:focus {
|
| 26 |
+
background: rgba(255, 255, 255, 0.08);
|
| 27 |
+
border-color: rgba(45, 212, 191, 0.5);
|
| 28 |
+
box-shadow: 0 0 15px rgba(45, 212, 191, 0.2);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* Custom Scrollbar */
|
| 33 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 34 |
+
::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.8); }
|
| 35 |
+
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; }
|
| 36 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(45, 212, 191, 0.4); }
|
| 37 |
+
|
| 38 |
+
/* Animations */
|
| 39 |
+
@keyframes glow {
|
| 40 |
+
0%, 100% { opacity: 0.4; transform: scale(1); }
|
| 41 |
+
50% { opacity: 0.6; transform: scale(1.05); }
|
| 42 |
+
}
|
| 43 |
+
.animate-glow { animation: glow 8s ease-in-out infinite alternate; }
|
| 44 |
+
|
| 45 |
+
@keyframes fadeIn {
|
| 46 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 47 |
+
to { opacity: 1; transform: translateY(0); }
|
| 48 |
+
}
|
| 49 |
+
.animate-fade-in { animation: fadeIn 0.4s ease-out forwards; }
|
| 50 |
+
|
| 51 |
+
@keyframes slideUp {
|
| 52 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 53 |
+
to { opacity: 1; transform: translateY(0); }
|
| 54 |
+
}
|
| 55 |
+
.animate-slide-up { animation: slideUp 0.5s ease-out forwards; }
|
frontend/app/page.tsx
CHANGED
|
@@ -1,16 +1,18 @@
|
|
| 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]
|
| 9 |
-
const [err, setErr]
|
| 10 |
const [load, setLoad] = useState(false)
|
| 11 |
-
const router
|
| 12 |
|
| 13 |
-
async function handleLogin() {
|
|
|
|
| 14 |
setLoad(true); setErr('')
|
| 15 |
try {
|
| 16 |
const res = await api.login(pw)
|
|
@@ -22,27 +24,60 @@ export default function LoginPage() {
|
|
| 22 |
}
|
| 23 |
|
| 24 |
return (
|
| 25 |
-
<div className="min-h-screen
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
className="
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
>
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
)
|
|
|
|
| 1 |
+
// app/page.tsx — Login Page with glassmorphism design
|
| 2 |
'use client'
|
| 3 |
import { useState } from 'react'
|
| 4 |
import { useRouter } from 'next/navigation'
|
| 5 |
import { api } from '@/lib/api'
|
| 6 |
+
import { Lock, AlertTriangle, BarChart3 } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function LoginPage() {
|
| 9 |
+
const [pw, setPw] = useState('')
|
| 10 |
+
const [err, setErr] = useState('')
|
| 11 |
const [load, setLoad] = useState(false)
|
| 12 |
+
const router = useRouter()
|
| 13 |
|
| 14 |
+
async function handleLogin(e: React.FormEvent) {
|
| 15 |
+
e.preventDefault()
|
| 16 |
setLoad(true); setErr('')
|
| 17 |
try {
|
| 18 |
const res = await api.login(pw)
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
return (
|
| 27 |
+
<div className="min-h-screen relative overflow-hidden flex items-center justify-center p-4">
|
| 28 |
+
{/* Background glowing blobs */}
|
| 29 |
+
<div className="absolute w-[500px] h-[500px] bg-teal-500/20 rounded-full blur-3xl -top-32 -left-32 animate-glow pointer-events-none" />
|
| 30 |
+
<div className="absolute w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl -bottom-48 -right-48 animate-glow pointer-events-none" style={{ animationDelay: '3s' }} />
|
| 31 |
+
|
| 32 |
+
<div className="w-full max-w-md glass-panel rounded-2xl p-8 relative z-10 border border-white/10 shadow-2xl animate-fade-in">
|
| 33 |
+
<div className="text-center mb-8">
|
| 34 |
+
<div className="w-16 h-16 bg-gradient-to-tr from-teal-500 to-indigo-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-teal-500/25">
|
| 35 |
+
<BarChart3 className="w-8 h-8 text-white" />
|
| 36 |
+
</div>
|
| 37 |
+
<h1 className="text-3xl font-bold bg-gradient-to-r from-white via-slate-100 to-slate-400 bg-clip-text text-transparent">
|
| 38 |
+
SIKOMO Forecast
|
| 39 |
+
</h1>
|
| 40 |
+
<p className="text-slate-400 text-sm mt-2">
|
| 41 |
+
Masukkan kata sandi untuk mengakses dashboard prediksi harga komoditas.
|
| 42 |
+
</p>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<form onSubmit={handleLogin} className="space-y-6">
|
| 46 |
+
<div>
|
| 47 |
+
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
|
| 48 |
+
Kata Sandi
|
| 49 |
+
</label>
|
| 50 |
+
<input
|
| 51 |
+
type="password"
|
| 52 |
+
value={pw}
|
| 53 |
+
onChange={e => setPw(e.target.value)}
|
| 54 |
+
placeholder="••••••••••••"
|
| 55 |
+
className={`w-full glass-input rounded-xl px-4 py-3.5 text-slate-100 placeholder:text-slate-500 focus:outline-none transition-all ${
|
| 56 |
+
err ? 'border-rose-500/50 focus:border-rose-500' : ''
|
| 57 |
+
}`}
|
| 58 |
+
autoFocus
|
| 59 |
+
/>
|
| 60 |
+
{err && (
|
| 61 |
+
<p className="text-xs text-rose-400 mt-2 flex items-center gap-1">
|
| 62 |
+
<AlertTriangle className="w-3.5 h-3.5" /> {err}
|
| 63 |
+
</p>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<button
|
| 68 |
+
type="submit"
|
| 69 |
+
disabled={load}
|
| 70 |
+
className="w-full bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-semibold py-3.5 px-4 rounded-xl shadow-lg shadow-indigo-500/20 active:scale-[0.98] transition-all duration-200 disabled:opacity-50"
|
| 71 |
+
>
|
| 72 |
+
{load ? 'Memverifikasi...' : 'Masuk ke Dashboard'}
|
| 73 |
+
</button>
|
| 74 |
+
</form>
|
| 75 |
+
|
| 76 |
+
<div className="mt-6 text-center border-t border-white/5 pt-4">
|
| 77 |
+
<span className="text-[11px] text-slate-500">
|
| 78 |
+
Sistem Informasi Komoditas & Pasar Kabupaten Batang
|
| 79 |
+
</span>
|
| 80 |
+
</div>
|
| 81 |
</div>
|
| 82 |
</div>
|
| 83 |
)
|
frontend/lib/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// lib/api.ts —
|
| 2 |
const BASE = process.env.NEXT_PUBLIC_API_URL || ''
|
| 3 |
|
| 4 |
function getToken() {
|
|
@@ -23,20 +23,38 @@ async function req(path: string, opts: RequestInit = {}) {
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export const api = {
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
|
|
|
| 1 |
+
// lib/api.ts — All API calls to SIKOMO Forecast backend
|
| 2 |
const BASE = process.env.NEXT_PUBLIC_API_URL || ''
|
| 3 |
|
| 4 |
function getToken() {
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export const api = {
|
| 26 |
+
// Auth
|
| 27 |
+
login: (password: string) => req('/auth/login', { method: 'POST', body: JSON.stringify({ password }) }),
|
| 28 |
+
|
| 29 |
+
// Komoditas & Prediksi
|
| 30 |
+
getKomoditas: () => req('/komoditas'),
|
| 31 |
+
getPrediksi: (id: number) => req(`/prediksi/${id}`),
|
| 32 |
+
|
| 33 |
+
// Forecast actions
|
| 34 |
+
runForecast: (komoditas_id: number) => req('/forecast/run', { method: 'POST', body: JSON.stringify({ komoditas_id }) }),
|
| 35 |
+
runForecastAll: () => req('/forecast/run-all', { method: 'POST' }),
|
| 36 |
+
stopForecast: () => req('/forecast/stop', { method: 'POST' }),
|
| 37 |
+
|
| 38 |
+
// Status & Logs (realtime)
|
| 39 |
+
getStatus: () => req('/status'),
|
| 40 |
+
getLogs: (limit = 200, level?: string) => req(`/logs?limit=${limit}${level && level !== 'ALL' ? `&level=${level}` : ''}`),
|
| 41 |
+
|
| 42 |
+
// Scheduler (multiple schedules)
|
| 43 |
+
getSchedules: () => req('/schedules'),
|
| 44 |
+
addSchedule: (cron_expression: string, label: string) => req('/schedules/add', { method: 'POST', body: JSON.stringify({ cron_expression, label }) }),
|
| 45 |
+
removeSchedule: (jobId: string) => req(`/schedules/${jobId}/remove`, { method: 'DELETE' }),
|
| 46 |
+
startScheduler: () => req('/schedule/start', { method: 'POST' }),
|
| 47 |
+
stopScheduler: () => req('/schedule/stop', { method: 'POST' }),
|
| 48 |
+
|
| 49 |
+
// Runtime settings
|
| 50 |
+
getSettings: () => req('/settings'),
|
| 51 |
+
updateSetting: (key: string, value: string) => req('/settings', { method: 'POST', body: JSON.stringify({ key, value }) }),
|
| 52 |
+
|
| 53 |
+
// CRUD
|
| 54 |
+
getInsights: (id: number) => req(`/insight/${id}`),
|
| 55 |
+
updateInsight: (id: number, body: object) => req(`/insight/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
| 56 |
+
addInsight: (id: number, body: object) => req(`/insight/${id}`, { method: 'POST', body: JSON.stringify(body) }),
|
| 57 |
+
deleteInsight: (id: number) => req(`/insight/${id}`, { method: 'DELETE' }),
|
| 58 |
+
updateRingkasan: (id: number, body: object) => req(`/ringkasan/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
| 59 |
+
updateModelMl: (id: number, body: object) => req(`/model-ml/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
| 60 |
}
|