anoderb commited on
Commit
bdef324
·
1 Parent(s): a75a367

Modernisasi Forecast Dashboard: Glassmorphism UI, SARIMA Model, and Backend Refactor

Browse files
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' : komoditas,
248
- 'harga_terkini': harga_terkini,
249
- 'model' : model,
250
- 'ringkasan' : ringkasan,
 
251
  'prediksi_7hari': hasil,
252
- 'insights' : 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: endpoint training, forecasting, CRUD, scheduler, env
2
- import os, json
 
 
3
  from datetime import datetime
4
  from typing import Optional
5
- from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
6
  from fastapi.staticfiles import StaticFiles
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
9
  from pydantic import BaseModel
10
- from dotenv import load_dotenv, set_key
11
- from apscheduler.schedulers.background import BackgroundScheduler
12
-
13
- load_dotenv()
14
 
 
 
 
15
  from database import (
16
  get_all_komoditas, get_harga_harian, get_pasar_list,
17
  get_prediksi_data, save_model_ml, save_hasil_prediksi,
18
  save_ringkasan_prediksi, save_insight_prediksi,
19
- update_komoditas_volatilitas, get_engine
 
20
  )
21
  from ml_pipeline import run_pipeline
22
  from sqlalchemy import text
23
 
 
 
 
24
  app = FastAPI(title="SIKOMO ML API")
25
 
26
  app.add_middleware(
@@ -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 != API_KEY:
39
  raise HTTPException(status_code=401, detail="Unauthorized")
40
  return creds.credentials
41
 
42
- # ── Scheduler ─────────────────────────────────────────────────────────────────
43
- scheduler = BackgroundScheduler()
44
- forecast_log = [] # in-memory log untuk SSE
45
 
46
- def auto_forecast_job():
47
- forecast_log.append(f"[{datetime.now()}] Auto forecast dimulai...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  try:
49
  komoditas_list = get_all_komoditas()
50
- for _, row in komoditas_list.iterrows():
 
 
 
 
 
51
  run_forecast_for_komoditas(int(row['id']), row['nama'])
52
- forecast_log.append(f"[{datetime.now()}] Auto forecast selesai.")
53
  except Exception as e:
54
- forecast_log.append(f"[{datetime.now()}] ERROR: {e}")
55
 
56
- def run_forecast_for_komoditas(komoditas_id: int, komoditas_nama: str):
57
- df = get_harga_harian(komoditas_id)
58
- if df.empty or len(df) < 60:
59
- forecast_log.append(f" ⚠️ {komoditas_nama}: data kurang")
60
- return
61
 
62
- # Kolom pertama non-date sebagai target, sisanya other
63
- price_cols = [c for c in df.columns if c != 'date']
64
- target_col = price_cols[0]
65
-
66
- # Ambil pasar_id dari DB
67
- pasar_df = get_pasar_list(komoditas_id)
68
- pasar_id = int(pasar_df.iloc[0]['id']) if not pasar_df.empty else 1
69
-
70
- def log_cb(msg): forecast_log.append(f" [{komoditas_nama}] {msg}")
71
-
72
- result = run_pipeline(
73
- komoditas_id=komoditas_id,
74
- komoditas_nama=komoditas_nama,
75
- df=df,
76
- target_market=target_col,
77
- pasar_id=pasar_id,
78
- n_trials=int(os.getenv('OPTUNA_TRIALS', 30)),
79
- log_cb=log_cb,
80
- )
81
-
82
- # Simpan ke DB
83
- meta = result['metadata']
84
- labels = result['labels']
85
- preds = result['predictions']
86
-
87
- model_id = save_model_ml(meta)
88
- save_hasil_prediksi(preds, komoditas_id, pasar_id, model_id, meta)
89
- save_ringkasan_prediksi({
90
- 'harga_min' : labels['harga_min'],
91
- 'harga_max' : labels['harga_max'],
92
- 'tren' : labels['tren'],
93
- 'confidence_level': labels['confidence_level'],
94
- 'status_analisis' : labels['status_analisis']['judul'],
95
- 'deskripsi_status': labels['status_analisis']['deskripsi'],
96
- 'tanggal_mulai' : preds[0]['tanggal'],
97
- 'tanggal_akhir' : preds[-1]['tanggal'],
98
- }, komoditas_id, model_id)
99
- save_insight_prediksi(labels['insights'], komoditas_id, model_id)
100
- cv = meta.get('data_quality', {}).get('cv', 0)
101
- update_komoditas_volatilitas(komoditas_id, 1 if cv >= 5 else 0, cv)
102
 
103
- def start_scheduler():
104
- hour = int(os.getenv('AUTO_FORECAST_HOUR', 1))
105
- minute = int(os.getenv('AUTO_FORECAST_MINUTE', 0))
106
- if scheduler.get_jobs():
107
- scheduler.remove_all_jobs()
108
- if os.getenv('AUTO_FORECAST_ENABLED', 'false').lower() == 'true':
109
- scheduler.add_job(auto_forecast_job, 'cron', hour=hour, minute=minute)
110
- if not scheduler.running:
111
- scheduler.start()
112
-
113
- start_scheduler()
114
-
115
- # ── Models / Pydantic ─────────────────────────────────────────────────────────
 
 
 
 
116
  class ForecastRequest(BaseModel):
117
  komoditas_id: int
118
 
119
  class InsightUpdate(BaseModel):
120
- konten : str
121
- tipe : str
122
- ikon : str
123
- urutan : int
124
 
125
  class RingkasanUpdate(BaseModel):
126
- status_analisis : Optional[str] = None
127
- deskripsi_status : Optional[str] = None
128
- tren : Optional[str] = None
129
 
130
  class ModelUpdate(BaseModel):
131
- deskripsi : Optional[str] = None
132
  catatan_validasi: Optional[str] = None
133
 
134
- class EnvUpdate(BaseModel):
135
- key : str
136
  value: str
137
 
138
- class SchedulerConfig(BaseModel):
139
- enabled: bool
140
- hour : int = 1
141
- minute : int = 0
142
 
143
- # ── Endpoints ─────────────────────────────────────────────────────────────────
 
 
144
 
145
  @app.get("/health")
146
  def health():
147
  return {"status": "ok", "time": datetime.now().isoformat()}
148
 
149
- # AUTH
150
  @app.post("/auth/login")
151
  def login(body: dict):
152
- if body.get('password') != os.getenv('DASHBOARD_PASSWORD', 'Bandulan112'):
153
  raise HTTPException(status_code=401, detail="Password salah")
154
- return {"token": API_KEY}
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- # KOMODITAS
 
 
 
 
 
157
  @app.get("/komoditas", dependencies=[Depends(verify_token)])
158
  def list_komoditas():
159
  df = get_all_komoditas()
160
  return df.to_dict('records')
161
 
162
- # PREDIKSI DATA (untuk dashboard frontend)
163
  @app.get("/prediksi/{komoditas_id}", dependencies=[Depends(verify_token)])
164
  def get_prediksi(komoditas_id: int):
165
  return get_prediksi_data(komoditas_id)
166
 
167
- # MANUAL FORECAST (trigger dari frontend)
168
  @app.post("/forecast/run", dependencies=[Depends(verify_token)])
169
  def run_forecast_manual(req: ForecastRequest, bg: BackgroundTasks):
 
 
 
 
170
  komoditas_list = get_all_komoditas()
171
  row = komoditas_list[komoditas_list['id'] == req.komoditas_id]
172
  if row.empty:
173
  raise HTTPException(404, "Komoditas tidak ditemukan")
174
  nama = row.iloc[0]['nama']
 
175
  bg.add_task(run_forecast_for_komoditas, req.komoditas_id, nama)
176
  return {"message": f"Forecast untuk {nama} dimulai di background."}
177
 
178
  @app.post("/forecast/run-all", dependencies=[Depends(verify_token)])
179
  def run_forecast_all(bg: BackgroundTasks):
180
- bg.add_task(auto_forecast_job)
 
 
 
 
181
  return {"message": "Forecast semua komoditas dimulai."}
182
 
183
- # LOG
184
- @app.get("/forecast/log", dependencies=[Depends(verify_token)])
185
- def get_log():
186
- return {"log": forecast_log[-100:]} # 100 baris terakhir
 
 
187
 
188
- # SCHEDULER
189
- @app.get("/scheduler/status", dependencies=[Depends(verify_token)])
190
- def scheduler_status():
191
- return {
192
- "enabled": os.getenv('AUTO_FORECAST_ENABLED', 'false'),
193
- "hour" : os.getenv('AUTO_FORECAST_HOUR', '1'),
194
- "minute" : os.getenv('AUTO_FORECAST_MINUTE', '0'),
195
- "running": scheduler.running,
196
- "jobs" : [str(j) for j in scheduler.get_jobs()],
197
- }
198
 
199
- @app.post("/scheduler/config", dependencies=[Depends(verify_token)])
200
- def update_scheduler(cfg: SchedulerConfig):
201
- env_file = '.env'
202
- set_key(env_file, 'AUTO_FORECAST_ENABLED', str(cfg.enabled).lower())
203
- set_key(env_file, 'AUTO_FORECAST_HOUR', str(cfg.hour))
204
- set_key(env_file, 'AUTO_FORECAST_MINUTE', str(cfg.minute))
205
- os.environ['AUTO_FORECAST_ENABLED'] = str(cfg.enabled).lower()
206
- os.environ['AUTO_FORECAST_HOUR'] = str(cfg.hour)
207
- os.environ['AUTO_FORECAST_MINUTE'] = str(cfg.minute)
208
- start_scheduler()
209
- return {"message": "Scheduler diperbarui."}
210
-
211
- # ENV EDITOR
212
- @app.get("/env", dependencies=[Depends(verify_token)])
213
- def get_env():
214
- """Baca .env untuk ditampilkan di dashboard (tanpa nilai sensitif diekspos mentah)."""
215
- allowed = [
216
- 'DB_HOST','DB_PORT','DB_NAME','DB_USER',
217
- 'AUTO_FORECAST_ENABLED','AUTO_FORECAST_HOUR','AUTO_FORECAST_MINUTE',
218
- 'OPTUNA_TRIALS','MODEL_DIR',
219
- ]
220
- return {k: os.getenv(k, '') for k in allowed}
221
-
222
- @app.post("/env", dependencies=[Depends(verify_token)])
223
- def update_env(body: EnvUpdate):
224
- allowed = [
225
- 'DB_HOST','DB_PORT','DB_NAME','DB_USER','DB_PASSWORD',
226
- 'AUTO_FORECAST_ENABLED','AUTO_FORECAST_HOUR','AUTO_FORECAST_MINUTE',
227
- 'OPTUNA_TRIALS','MODEL_DIR','API_SECRET_KEY',
228
- ]
229
- if body.key not in allowed:
230
- raise HTTPException(400, f"Key '{body.key}' tidak diizinkan untuk diubah.")
231
- set_key('.env', body.key, body.value)
232
- os.environ[body.key] = body.value
233
- return {"message": f"{body.key} berhasil diperbarui."}
234
-
235
- # CRUD INSIGHT
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  @app.get("/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
237
  def get_insights(komoditas_id: int):
238
  engine = get_engine()
239
- rows = engine.execute(
240
- f"SELECT * FROM insight_prediksi WHERE komoditas_id={komoditas_id} ORDER BY urutan"
241
- ).fetchall()
242
- return [dict(r) for r in rows]
 
 
243
 
244
  @app.put("/insight/{insight_id}", dependencies=[Depends(verify_token)])
245
  def update_insight(insight_id: int, body: InsightUpdate):
@@ -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 RINGKASAN
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 MODEL ML
288
  @app.put("/model-ml/{komoditas_id}", dependencies=[Depends(verify_token)])
289
  def update_model_ml(komoditas_id: int, body: ModelUpdate):
290
  engine = get_engine()
@@ -299,8 +381,6 @@ def update_model_ml(komoditas_id: int, body: ModelUpdate):
299
  )
300
  return {"message": "Model ML diperbarui."}
301
 
302
- # MOUNT FRONTEND STATIS
303
- # Mount static files as the very last route so it catches all non-API paths
304
- import os
305
  if os.path.isdir("static"):
306
  app.mount("/", StaticFiles(directory="static", html=True), name="frontend")
 
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
- df_train = pd.DataFrame({'ds': dates_train.values, 'y': y_train.values})
159
- df_test = pd.DataFrame({'ds': dates_test.values})
160
- m = Prophet(yearly_seasonality=True, weekly_seasonality=True,
161
- daily_seasonality=False, changepoint_prior_scale=0.05)
162
- m.fit(df_train)
163
- fc = m.predict(df_test)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  pred = np.clip(fc['yhat'].values, 0, None)
165
- return m, evaluate_model(y_test.values, pred, 'Prophet')
166
 
167
  def train_arima(y_train, y_test):
168
- m = ARIMA(y_train.values, order=(5, 1, 0)).fit()
169
- fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
170
  return m, evaluate_model(y_test.values, fc, 'ARIMA')
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def tune_with_optuna(model_name, X_train, y_train, n_trials=50):
173
  def objective(trial):
174
  if model_name == 'LightGBM':
@@ -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
- _, res = train_arima(y_train, y_test)
396
- all_results['ARIMA'] = {**res, 'obj': None, 'type': 'arima'}
397
  except Exception as e:
398
  log(f"⚠️ ARIMA gagal: {e}")
399
 
 
 
 
 
 
 
 
 
400
  best_name = min(all_results, key=lambda k: all_results[k]['rmse'])
401
  best = all_results[best_name]
402
  log(f"🏆 Best model: {best_name} (RMSE={best['rmse']:.2f})")
@@ -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 7 hari ──
427
- log("📅 Forecasting 7 hari...")
 
428
  df_hist = df[['date', target_market] + [c for c in other_cols if c in df.columns]].copy()
429
- if final_result.get('type') == 'prophet' or final_name == 'Prophet':
430
- predictions = forecast_prophet(final_model, df_hist['date'].max())
 
 
 
 
 
431
  else:
432
- predictions = forecast_tree(final_model, df_hist, target_market, other_cols, feature_cols)
433
 
434
  # ── Labels ──
435
  cv = quality_reports.get(target_market, {}).get('cv', 0)
@@ -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: {'rmse': round(v['rmse'],2), 'mape': round(v['mape'],4)}
482
- for k, v in all_results.items()},
 
 
 
 
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] = useState<any[]>([])
9
- const [scheduler, setScheduler] = useState<any>(null)
10
- const [log, setLog] = useState<string[]>([])
11
- const [loading, setLoading] = useState(false)
12
- const [hour, setHour] = useState('1')
13
- const [minute, setMinute] = useState('0')
14
- const [autoEnabled, setAuto] = useState(false)
15
 
16
- useEffect(() => {
17
- api.getKomoditas().then(setKomoditas)
18
- api.getScheduler().then(s => {
19
- setScheduler(s)
20
- setHour(s.hour)
21
- setMinute(s.minute)
22
- setAuto(s.enabled === 'true')
23
- })
24
- fetchLog()
25
- }, [])
26
-
27
- async function fetchLog() {
28
- const res = await api.getLog()
29
- setLog(res.log)
 
 
 
 
 
 
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  async function runOne(id: number) {
33
- setLoading(true)
34
- await api.runForecast(id)
35
- setTimeout(fetchLog, 2000)
36
- setLoading(false)
 
 
 
37
  }
38
 
39
  async function runAll() {
40
- setLoading(true)
41
- await api.runForecastAll()
42
- setTimeout(fetchLog, 3000)
43
- setLoading(false)
 
 
 
44
  }
45
 
46
- async function saveScheduler() {
47
- await api.updateScheduler({ enabled: autoEnabled, hour: parseInt(hour), minute: parseInt(minute) })
48
- const s = await api.getScheduler()
49
- setScheduler(s)
50
- alert('Scheduler diperbarui!')
 
 
 
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  return (
54
- <div className="space-y-6">
55
- <h1 className="text-2xl font-bold">Forecast Management</h1>
56
-
57
- {/* Scheduler */}
58
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
59
- <h2 className="font-semibold mb-4">⏰ Auto Forecast Scheduler</h2>
60
- <div className="flex items-center gap-4 flex-wrap">
61
- <label className="flex items-center gap-2 text-sm">
62
- <input type="checkbox" checked={autoEnabled} onChange={e => setAuto(e.target.checked)}
63
- className="w-4 h-4 accent-blue-500"/>
64
- Aktifkan auto forecast harian
65
- </label>
66
- <div className="flex items-center gap-2">
67
- <span className="text-sm text-gray-400">Jam:</span>
68
- <input type="number" min="0" max="23" value={hour} onChange={e => setHour(e.target.value)}
69
- className="w-16 px-2 py-1 rounded bg-gray-800 text-white text-sm border border-gray-700"/>
70
- <span className="text-gray-400">:</span>
71
- <input type="number" min="0" max="59" value={minute} onChange={e => setMinute(e.target.value)}
72
- className="w-16 px-2 py-1 rounded bg-gray-800 text-white text-sm border border-gray-700"/>
73
  </div>
74
- <button onClick={saveScheduler}
75
- className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm font-medium">
76
- Simpan
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  </button>
78
  </div>
79
- <p className="text-gray-500 text-xs mt-3">
80
- Status: {scheduler?.running ? '🟢 Running' : '⚫ Stopped'} | Jobs: {scheduler?.jobs?.length || 0}
81
- </p>
82
- </div>
83
 
84
- {/* Manual Trigger */}
85
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
86
- <div className="flex items-center justify-between mb-4">
87
- <h2 className="font-semibold">🚀 Manual Forecast</h2>
88
- <button onClick={runAll} disabled={loading}
89
- className="flex items-center gap-2 px-4 py-1.5 bg-green-700 hover:bg-green-600 rounded-lg text-sm disabled:opacity-50">
90
- <Play size={14}/> Run Semua
91
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  </div>
93
- <div className="grid grid-cols-2 gap-3">
94
- {komoditas.map((k: any) => (
95
- <div key={k.id} className="flex items-center justify-between bg-gray-800 rounded-lg px-4 py-3">
96
- <span className="text-sm font-medium">{k.nama}</span>
97
- <button onClick={() => runOne(k.id)} disabled={loading}
98
- className="flex items-center gap-1 px-3 py-1 bg-blue-700 hover:bg-blue-600 rounded text-xs disabled:opacity-50">
99
- <Play size={12}/> Run
 
 
 
 
100
  </button>
 
 
 
 
 
 
101
  </div>
102
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  </div>
104
  </div>
105
 
106
- {/* Log */}
107
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
108
- <div className="flex items-center justify-between mb-3">
109
- <h2 className="font-semibold">📋 Log</h2>
110
- <button onClick={fetchLog} className="text-gray-400 hover:text-white">
111
- <RefreshCw size={16}/>
112
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </div>
114
- <div className="bg-black rounded-lg p-4 h-64 overflow-y-auto font-mono text-xs text-green-400">
115
- {log.length === 0
116
- ? <span className="text-gray-600">Belum ada log.</span>
117
- : log.map((l, i) => <div key={i}>{l}</div>)
118
- }
119
  </div>
120
  </div>
121
  </div>
 
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', icon: Home, label: 'Overview' },
22
  { href: '/dashboard/prediksi', icon: BarChart2, label: 'Prediksi' },
23
- { href: '/dashboard/forecast', icon: Terminal, label: 'Forecast' },
24
- { href: '/dashboard/settings', icon: Settings, label: 'Pengaturan' },
25
  ]
26
 
27
  return (
28
- <div className="flex min-h-screen bg-gray-950 text-white">
29
- {/* Sidebar */}
30
- <aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col py-6 px-4 gap-2">
31
- <div className="text-xl font-bold text-white mb-6 px-2">SIKOMO</div>
32
- {links.map(l => (
33
- <Link key={l.href} href={l.href}
34
- className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-white transition text-sm">
35
- <l.icon size={16}/> {l.label}
36
- </Link>
37
- ))}
38
- <div className="mt-auto">
39
- <button onClick={logout}
40
- className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-500 hover:text-red-400 transition text-sm w-full">
41
- <LogOut size={16}/> Logout
42
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  </div>
44
- </aside>
45
- <main className="flex-1 p-8 overflow-auto">{children}</main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  </div>
47
  )
48
  }
 
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 [scheduler, setScheduler] = useState<any>(null)
 
 
 
 
 
 
 
 
 
 
9
 
10
  useEffect(() => {
11
- api.getKomoditas().then(setKomoditas)
12
- api.getScheduler().then(setScheduler)
 
13
  }, [])
14
 
15
  return (
16
- <div>
17
- <h1 className="text-2xl font-bold mb-6">Overview</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- {/* Stats */}
20
- <div className="grid grid-cols-3 gap-4 mb-8">
21
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
22
- <p className="text-gray-400 text-sm">Total Komoditas</p>
23
- <p className="text-3xl font-bold mt-1">{komoditas.length}</p>
 
 
 
 
 
 
 
 
 
24
  </div>
25
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
26
- <p className="text-gray-400 text-sm">Status Scheduler</p>
27
- <p className={`text-xl font-bold mt-1 ${scheduler?.enabled === 'true' ? 'text-green-400' : 'text-gray-500'}`}>
28
- {scheduler?.enabled === 'true' ? '🟢 Aktif' : '⚫ Nonaktif'}
 
 
 
 
 
 
 
29
  </p>
30
  </div>
31
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
32
- <p className="text-gray-400 text-sm">Jadwal Forecast</p>
33
- <p className="text-xl font-bold mt-1">
34
- {scheduler ? `${String(scheduler.hour).padStart(2,'0')}:${String(scheduler.minute).padStart(2,'0')}` : '--:--'}
 
 
 
 
 
35
  </p>
 
36
  </div>
37
  </div>
38
 
39
- {/* Daftar Komoditas */}
40
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
41
- <h2 className="font-semibold mb-4">Daftar Komoditas</h2>
42
- <table className="w-full text-sm">
43
- <thead>
44
- <tr className="text-gray-400 border-b border-gray-800">
45
- <th className="text-left py-2">ID</th>
46
- <th className="text-left py-2">Nama</th>
47
- <th className="text-left py-2">Volatilitas</th>
48
- <th className="text-left py-2">Skor</th>
49
- </tr>
50
- </thead>
51
- <tbody>
52
- {komoditas.map((k: any) => (
53
- <tr key={k.id} className="border-b border-gray-800 hover:bg-gray-800/50">
54
- <td className="py-2 text-gray-400">{k.id}</td>
55
- <td className="py-2 font-medium">{k.nama}</td>
56
- <td className="py-2">
57
- <span className={`px-2 py-0.5 rounded-full text-xs ${
58
- k.volatile ? 'bg-red-900 text-red-300' : 'bg-green-900 text-green-300'
59
- }`}>{k.volatile ? 'Tinggi' : 'Rendah'}</span>
60
- </td>
61
- <td className="py-2 text-gray-300">{k.volatilitas_skor?.toFixed(2)}</td>
62
  </tr>
63
- ))}
64
- </tbody>
65
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
  </div>
68
  )
 
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] = 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,239 +22,215 @@ export default function PrediksiPage() {
22
  setSelected(id)
23
  const d = await api.getPrediksi(id)
24
  setData(d)
25
- setRingForm({
26
- status_analisis : d.ringkasan?.status_analisis || '',
27
- deskripsi_status: d.ringkasan?.deskripsi_status || '',
28
- })
29
- setModelForm({
30
- deskripsi : d.model?.deskripsi || '',
31
- catatan_validasi: d.model?.catatan_validasi || '',
32
- })
33
  }
34
 
35
- async function saveInsightEdit() {
36
- await api.updateInsight(editInsight.id, form)
37
- setEditInsight(null)
38
- selectKomoditas(selected!)
39
- }
40
-
41
- async function saveNewInsight() {
42
- await api.addInsight(selected!, form)
43
- setNewInsight(false)
44
- setForm({ konten:'', tipe:'positif', ikon:'check-circle', urutan:1 })
45
- selectKomoditas(selected!)
46
- }
47
-
48
- async function deleteInsight(id: number) {
49
- if (!confirm('Hapus insight ini?')) return
50
- await api.deleteInsight(id)
51
- selectKomoditas(selected!)
52
- }
53
 
54
- async function saveRingkasan() {
55
- await api.updateRingkasan(selected!, ringForm)
56
- setEditRingkasan(false)
57
- selectKomoditas(selected!)
58
  }
59
 
60
- async function saveModel() {
61
- await api.updateModelMl(selected!, modelForm)
62
- setEditModel(false)
63
- selectKomoditas(selected!)
64
- }
65
-
66
- const tipeColor: Record<string, string> = {
67
- positif: 'bg-green-900 text-green-300',
68
- negatif: 'bg-red-900 text-red-300',
69
- netral : 'bg-gray-700 text-gray-300',
70
  }
71
 
72
  return (
73
  <div className="space-y-6">
74
- <h1 className="text-2xl font-bold">Data Prediksi</h1>
75
 
76
- {/* Pilih komoditas */}
77
  <div className="flex gap-2 flex-wrap">
78
  {komoditas.map((k: any) => (
79
  <button key={k.id} onClick={() => selectKomoditas(k.id)}
80
- className={`px-4 py-1.5 rounded-full text-sm font-medium border transition ${
81
- selected === k.id
82
- ? 'bg-blue-600 border-blue-500 text-white'
83
- : 'border-gray-700 text-gray-400 hover:border-gray-500'
84
- }`}>
85
- {k.nama}
86
- </button>
87
  ))}
88
  </div>
89
 
90
  {data && (
91
- <div className="grid grid-cols-2 gap-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- {/* Ringkasan Prediksi */}
94
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
95
  <div className="flex justify-between items-center mb-3">
96
- <h2 className="font-semibold">Ringkasan Prediksi</h2>
97
- <button onClick={() => setEditRingkasan(!editRingkasan)}
98
- className="text-gray-400 hover:text-white"><Pencil size={15}/></button>
99
  </div>
100
  {!editRingkasan ? (
101
- <div className="space-y-2 text-sm">
102
- <div className="flex justify-between">
103
- <span className="text-gray-400">Rentang Harga</span>
104
- <span>Rp {data.ringkasan?.harga_min?.toLocaleString()} Rp {data.ringkasan?.harga_max?.toLocaleString()}</span>
105
- </div>
106
- <div className="flex justify-between">
107
- <span className="text-gray-400">Tren</span>
108
- <span>{data.ringkasan?.tren}</span>
109
- </div>
110
- <div className="flex justify-between">
111
- <span className="text-gray-400">Status</span>
112
- <span>{data.ringkasan?.status_analisis}</span>
113
- </div>
114
- <p className="text-gray-500 text-xs mt-2">{data.ringkasan?.deskripsi_status}</p>
115
  </div>
116
  ) : (
117
  <div className="space-y-3">
118
- <div>
119
- <label className="text-xs text-gray-400">Status Analisis</label>
120
- <input value={ringForm.status_analisis} onChange={e => setRingForm({...ringForm, status_analisis: e.target.value})}
121
- className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
122
- </div>
123
- <div>
124
- <label className="text-xs text-gray-400">Deskripsi Status</label>
125
- <textarea value={ringForm.deskripsi_status} onChange={e => setRingForm({...ringForm, deskripsi_status: e.target.value})}
126
- rows={3} className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
127
- </div>
128
  <div className="flex gap-2">
129
- <button onClick={saveRingkasan} className="flex items-center gap-1 px-3 py-1 bg-blue-600 rounded text-xs"><Check size={12}/>Simpan</button>
130
- <button onClick={() => setEditRingkasan(false)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
131
  </div>
132
  </div>
133
  )}
134
  </div>
135
 
136
  {/* Model ML */}
137
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
138
  <div className="flex justify-between items-center mb-3">
139
- <h2 className="font-semibold">Model & Metodologi</h2>
140
- <button onClick={() => setEditModel(!editModel)} className="text-gray-400 hover:text-white"><Pencil size={15}/></button>
141
  </div>
142
  {!editModel ? (
143
- <div className="space-y-2 text-sm">
144
- <div className="flex justify-between"><span className="text-gray-400">Model</span><span>{data.model?.nama_model}</span></div>
145
- <div className="flex justify-between"><span className="text-gray-400">MAPE</span><span>{data.model?.mape}%</span></div>
146
- <div className="flex justify-between"><span className="text-gray-400">RMSE</span><span>{data.model?.rmse}</span></div>
147
- <div className="flex justify-between"><span className="text-gray-400">Stabilitas</span>
148
- <span className="uppercase text-xs font-bold text-purple-300">{data.model?.stabilitas}</span>
149
  </div>
150
- <p className="text-gray-500 text-xs mt-2">{data.model?.deskripsi}</p>
151
  </div>
152
  ) : (
153
  <div className="space-y-3">
154
- <div>
155
- <label className="text-xs text-gray-400">Deskripsi Model</label>
156
- <textarea value={modelForm.deskripsi} onChange={e => setModelForm({...modelForm, deskripsi: e.target.value})}
157
- rows={3} className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
158
- </div>
159
- <div>
160
- <label className="text-xs text-gray-400">Catatan Validasi</label>
161
- <textarea value={modelForm.catatan_validasi} onChange={e => setModelForm({...modelForm, catatan_validasi: e.target.value})}
162
- rows={2} className="w-full mt-1 px-3 py-1.5 rounded bg-gray-800 text-sm text-white border border-gray-700"/>
163
- </div>
164
  <div className="flex gap-2">
165
- <button onClick={saveModel} className="flex items-center gap-1 px-3 py-1 bg-blue-600 rounded text-xs"><Check size={12}/>Simpan</button>
166
- <button onClick={() => setEditModel(false)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
167
  </div>
168
  </div>
169
  )}
170
  </div>
171
 
172
- {/* Insight Editor */}
173
- <div className="col-span-2 bg-gray-900 border border-gray-800 rounded-xl p-5">
174
  <div className="flex justify-between items-center mb-4">
175
- <h2 className="font-semibold">Insight Prediktif</h2>
176
- <button onClick={() => setNewInsight(true)} className="flex items-center gap-1 px-3 py-1.5 bg-green-700 hover:bg-green-600 rounded text-xs">
177
- <Plus size={13}/> Tambah
178
  </button>
179
  </div>
180
  <div className="space-y-3">
181
  {(data.insights || []).map((ins: any) => (
182
  <div key={ins.id}>
183
  {editInsight?.id === ins.id ? (
184
- <div className="bg-gray-800 rounded-lg p-3 space-y-2">
185
- <textarea value={form.konten} onChange={e => setForm({...form, konten: e.target.value})}
186
- rows={2} className="w-full px-3 py-1.5 rounded bg-gray-700 text-sm text-white border border-gray-600"/>
187
  <div className="flex gap-2">
188
- <select value={form.tipe} onChange={e => setForm({...form, tipe: e.target.value})}
189
- className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600">
190
- <option>positif</option><option>negatif</option><option>netral</option>
191
  </select>
192
- <input value={form.ikon} onChange={e => setForm({...form, ikon: e.target.value})}
193
- placeholder="ikon (lucide name)" className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600 flex-1"/>
194
- <input type="number" value={form.urutan} onChange={e => setForm({...form, urutan: parseInt(e.target.value)})}
195
- className="w-16 px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600"/>
196
  </div>
197
  <div className="flex gap-2">
198
- <button onClick={saveInsightEdit} className="flex items-center gap-1 px-3 py-1 bg-blue-600 rounded text-xs"><Check size={12}/>Simpan</button>
199
- <button onClick={() => setEditInsight(null)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
200
  </div>
201
  </div>
202
  ) : (
203
- <div className="flex items-start justify-between bg-gray-800 rounded-lg px-4 py-3">
204
  <div className="flex items-start gap-3">
205
- <span className={`text-xs px-2 py-0.5 rounded-full mt-0.5 ${tipeColor[ins.tipe] || tipeColor.netral}`}>{ins.tipe}</span>
206
- <p className="text-sm">{ins.konten}</p>
207
  </div>
208
  <div className="flex gap-2 ml-4 shrink-0">
209
- <button onClick={() => { setEditInsight(ins); setForm({ konten: ins.konten, tipe: ins.tipe, ikon: ins.ikon, urutan: ins.urutan }) }}
210
- className="text-gray-400 hover:text-white"><Pencil size={14}/></button>
211
- <button onClick={() => deleteInsight(ins.id)} className="text-gray-400 hover:text-red-400"><Trash2 size={14}/></button>
212
  </div>
213
  </div>
214
  )}
215
  </div>
216
  ))}
217
-
218
- {/* Form tambah baru */}
219
  {newInsight && (
220
- <div className="bg-gray-800 rounded-lg p-3 space-y-2 border border-green-800">
221
- <textarea value={form.konten} onChange={e => setForm({...form, konten: e.target.value})}
222
- rows={2} placeholder="Isi insight..." className="w-full px-3 py-1.5 rounded bg-gray-700 text-sm text-white border border-gray-600"/>
223
  <div className="flex gap-2">
224
- <select value={form.tipe} onChange={e => setForm({...form, tipe: e.target.value})}
225
- className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600">
226
- <option>positif</option><option>negatif</option><option>netral</option>
227
  </select>
228
- <input value={form.ikon} onChange={e => setForm({...form, ikon: e.target.value})}
229
- placeholder="ikon" className="px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600 flex-1"/>
230
- <input type="number" value={form.urutan} onChange={e => setForm({...form, urutan: parseInt(e.target.value)})}
231
- className="w-16 px-2 py-1 rounded bg-gray-700 text-xs text-white border border-gray-600"/>
232
  </div>
233
  <div className="flex gap-2">
234
- <button onClick={saveNewInsight} className="flex items-center gap-1 px-3 py-1 bg-green-700 rounded text-xs"><Check size={12}/>Tambah</button>
235
- <button onClick={() => setNewInsight(false)} className="flex items-center gap-1 px-3 py-1 bg-gray-700 rounded text-xs"><X size={12}/>Batal</button>
236
  </div>
237
  </div>
238
  )}
239
  </div>
240
  </div>
241
 
242
- {/* Hasil Prediksi 7 Hari (read-only) */}
243
- <div className="col-span-2 bg-gray-900 border border-gray-800 rounded-xl p-5">
244
- <h2 className="font-semibold mb-4">Hasil Prediksi 7 Hari <span className="text-gray-500 text-xs font-normal">(read-only)</span></h2>
 
 
 
245
  <div className="overflow-x-auto">
246
- <table className="w-full text-sm">
247
- <thead><tr className="text-gray-400 border-b border-gray-800">
248
- <th className="text-left py-2">Tanggal</th>
249
- <th className="text-right py-2">Harga Prediksi</th>
250
- <th className="text-right py-2">Confidence</th>
251
  </tr></thead>
252
- <tbody>
253
  {(data.prediksi_7hari || []).map((p: any, i: number) => (
254
- <tr key={i} className="border-b border-gray-800">
255
- <td className="py-2">{p.tanggal_target}</td>
256
- <td className="py-2 text-right font-mono">Rp {Number(p.harga_prediksi).toLocaleString()}</td>
257
- <td className="py-2 text-right text-gray-400">{p.confidence_level}%</td>
258
  </tr>
259
  ))}
260
  </tbody>
 
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 ENV_LABELS: Record<string, string> = {
8
- DB_HOST : 'Database Host',
9
- DB_PORT : 'Database Port',
10
- DB_NAME : 'Database Name',
11
- DB_USER : 'Database User',
12
- DB_PASSWORD : 'Database Password',
13
- AUTO_FORECAST_ENABLED : 'Auto Forecast Aktif',
14
- AUTO_FORECAST_HOUR : 'Jam Forecast (0-23)',
15
- AUTO_FORECAST_MINUTE : 'Menit Forecast (0-59)',
16
- OPTUNA_TRIALS : 'Jumlah Optuna Trials',
17
- MODEL_DIR : 'Folder Model',
18
- API_SECRET_KEY : 'API Secret Key',
19
  }
20
 
21
  export default function SettingsPage() {
22
- const [envData, setEnvData] = useState<Record<string, string>>({})
23
- const [saved, setSaved] = useState<string | null>(null)
 
24
 
25
- useEffect(() => { api.getEnv().then(setEnvData) }, [])
26
 
27
  async function save(key: string) {
28
- await api.updateEnv(key, envData[key] || '')
29
- setSaved(key)
30
- setTimeout(() => setSaved(null), 2000)
 
 
 
 
 
 
 
31
  }
32
 
33
  return (
34
- <div className="max-w-2xl space-y-6">
35
- <h1 className="text-2xl font-bold">Pengaturan</h1>
36
- <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 space-y-4">
37
- <p className="text-gray-400 text-sm">Edit konfigurasi environment HuggingFace Space. Perubahan langsung berlaku.</p>
38
- {Object.entries(ENV_LABELS).map(([key, label]) => (
39
- <div key={key} className="flex items-center gap-3">
40
- <div className="flex-1">
41
- <label className="text-xs text-gray-400 block mb-1">{label}</label>
42
- <input
43
- type={key.includes('PASSWORD') || key.includes('SECRET') ? 'password' : 'text'}
44
- value={envData[key] || ''}
45
- onChange={e => setEnvData({...envData, [key]: e.target.value})}
46
- className="w-full px-3 py-2 rounded-lg bg-gray-800 text-white text-sm border border-gray-700 focus:outline-none focus:border-blue-500"
47
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </div>
49
- <button onClick={() => save(key)}
50
- className={`flex items-center gap-1 px-3 py-2 rounded-lg text-xs font-medium mt-5 transition ${
51
- saved === key ? 'bg-green-700 text-white' : 'bg-gray-700 hover:bg-gray-600 text-gray-300'
52
- }`}>
53
- <Save size={13}/> {saved === key ? 'Tersimpan' : 'Simpan'}
54
- </button>
55
- </div>
56
- ))}
57
  </div>
58
  </div>
59
  )
 
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: #111827; /* gray-950 */
7
- color: #f3f4f6; /* gray-100 */
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] = useState('')
9
- const [err, setErr] = useState('')
10
  const [load, setLoad] = useState(false)
11
- const router = useRouter()
12
 
13
- async function handleLogin() {
 
14
  setLoad(true); setErr('')
15
  try {
16
  const res = await api.login(pw)
@@ -22,27 +24,60 @@ export default function LoginPage() {
22
  }
23
 
24
  return (
25
- <div className="min-h-screen bg-gray-950 flex items-center justify-center">
26
- <div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 w-full max-w-sm shadow-2xl">
27
- <h1 className="text-2xl font-bold text-white mb-1">SIKOMO</h1>
28
- <p className="text-gray-400 text-sm mb-6">Dashboard Admin Prediksi Komoditas</p>
29
- <label className="text-gray-300 text-sm">Password</label>
30
- <input
31
- type="password"
32
- value={pw}
33
- onChange={e => setPw(e.target.value)}
34
- onKeyDown={e => e.key === 'Enter' && handleLogin()}
35
- className="w-full mt-1 mb-4 px-4 py-2 rounded-lg bg-gray-800 text-white border border-gray-700 focus:outline-none focus:border-blue-500"
36
- placeholder="••••••••••"
37
- />
38
- {err && <p className="text-red-400 text-sm mb-3">{err}</p>}
39
- <button
40
- onClick={handleLogin}
41
- disabled={load}
42
- className="w-full py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold transition disabled:opacity-50"
43
- >
44
- {load ? 'Masuk...' : 'Masuk'}
45
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  </div>
47
  </div>
48
  )
 
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 — semua pemanggilan ke HuggingFace API
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
- login : (password: string) => req('/auth/login', { method:'POST', body: JSON.stringify({ password }) }),
27
- getKomoditas : () => req('/komoditas'),
28
- getPrediksi : (id: number) => req(`/prediksi/${id}`),
29
- runForecast : (komoditas_id: number) => req('/forecast/run', { method:'POST', body: JSON.stringify({ komoditas_id }) }),
30
- runForecastAll : () => req('/forecast/run-all', { method:'POST' }),
31
- getLog : () => req('/forecast/log'),
32
- getScheduler : () => req('/scheduler/status'),
33
- updateScheduler : (body: object) => req('/scheduler/config', { method:'POST', body: JSON.stringify(body) }),
34
- getEnv : () => req('/env'),
35
- updateEnv : (key: string, value: string) => req('/env', { method:'POST', body: JSON.stringify({ key, value }) }),
36
- getInsights : (id: number) => req(`/insight/${id}`),
37
- updateInsight : (id: number, body: object) => req(`/insight/${id}`, { method:'PUT', body: JSON.stringify(body) }),
38
- addInsight : (id: number, body: object) => req(`/insight/${id}`, { method:'POST', body: JSON.stringify(body) }),
39
- deleteInsight : (id: number) => req(`/insight/${id}`, { method:'DELETE' }),
40
- updateRingkasan : (id: number, body: object) => req(`/ringkasan/${id}`, { method:'PUT', body: JSON.stringify(body) }),
41
- updateModelMl : (id: number, body: object) => req(`/model-ml/${id}`, { method:'PUT', body: JSON.stringify(body) }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
 
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
  }