anoderb commited on
Commit
39018b4
·
1 Parent(s): f325b17

feat: complete Docker setup for Next.js & FastAPI deployment

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