anoderb commited on
Commit
4b529fd
·
0 Parent(s):

Fix: clean_harga logic and rename functions to match traceback

Browse files
.dockerignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ backend/.env
16
+ frontend/.env
17
+
18
+ # Logs
19
+ npm-debug.log*
20
+ yarn-debug.log*
21
+ yarn-error.log*
22
+
23
+ # OS Files
24
+ .DS_Store
25
+ Thumbs.db
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # OS Files
29
+ .DS_Store
30
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── STAGE 1: Build Frontend (Next.js Static Export) ──
2
+ FROM node:20-alpine AS builder
3
+ WORKDIR /app/frontend
4
+
5
+ # Copy dependencies definitions
6
+ COPY frontend/package*.json ./
7
+ RUN npm install
8
+
9
+ # Copy application source code
10
+ COPY frontend/ ./
11
+
12
+ # Allocate sufficient heap limit for production bundle compilation
13
+ ENV NODE_OPTIONS="--max-old-space-size=2048"
14
+ RUN npm run build
15
+
16
+ # ── STAGE 2: Production Python Runtime Runner ──
17
+ FROM python:3.11-slim
18
+
19
+ # Create dedicated non-root user required by Hugging Face Spaces security policy
20
+ RUN useradd -m -u 1000 user
21
+ WORKDIR /app
22
+
23
+ # Install minimal OS build dependencies
24
+ RUN apt-get update && apt-get install -y \
25
+ gcc \
26
+ && rm -rf /var/lib/apt/lists/*
27
+
28
+ # Install python dependencies as user
29
+ COPY --chown=user backend/requirements.txt ./
30
+ RUN pip install --no-cache-dir --upgrade pip \
31
+ && pip install --no-cache-dir -r requirements.txt
32
+
33
+ # Copy backend python packages
34
+ COPY --chown=user backend/ ./
35
+
36
+ # Establish web serving static destination directory
37
+ RUN mkdir -p /app/static && chown -R user:user /app/static
38
+ COPY --chown=user --from=builder /app/frontend/out /app/static
39
+
40
+ # Enforce secure runner execution ownership context
41
+ USER user
42
+ ENV TZ="Asia/Jakarta"
43
+ EXPOSE 7860
44
+
45
+ # Initiate robust application framework entrypoint
46
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Sikomo Daily Scraper Portal
3
+ emoji: 🕷️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Sikomo Daily Scraper Portal
11
+
12
+ Sistem Otomatisasi Pengambilan dan Pemrosesan Harga Komoditas Harian Kabupaten Batang yang dikonfigurasi untuk berjalan mulus di atas arsitektur cloud containerized **Hugging Face Spaces**.
13
+
14
+ ## Pengaturan Rahasia Lingkungan (HF Secrets)
15
+ Harap konfigurasikan variabel rahasia berikut pada menu pengaturan **Settings → Secrets** di repositori Space Anda sebelum memicu proses *Build*:
16
+
17
+ - `MYSQL_HOST` : Alamat host server database MySQL (contoh: `db.khamdanu.xyz`)
18
+ - `MYSQL_PORT` : Port koneksi standar MySQL (contoh: `3306`)
19
+ - `MYSQL_DATABASE` : Nama skema database (contoh: `sikomo_db`)
20
+ - `MYSQL_USER` : Nama pengguna akses database
21
+ - `MYSQL_PASSWORD` : Kata sandi autentikasi pengguna database
22
+
23
+ > **Peringatan Keamanan**: Jangan pernah menanamkan (hardcode) kata sandi atau kredensial basis data langsung di dalam salinan repositori publik ini.
backend/app/__init__.py ADDED
File without changes
backend/app/config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from functools import lru_cache
3
+
4
+ class Settings(BaseSettings):
5
+ # Database
6
+ MYSQL_HOST: str
7
+ MYSQL_PORT: int = 3306
8
+ MYSQL_DATABASE: str
9
+ MYSQL_USER: str
10
+ MYSQL_PASSWORD: str
11
+
12
+ # API
13
+ API_PORT: int = 8000
14
+ API_HOST: str = "0.0.0.0"
15
+
16
+ # Scheduler
17
+ DEFAULT_SCHEDULE: str = "0 8 * * *"
18
+ DEFAULT_SCHEDULES: str = "0 8 * * *" # comma-separated if multiple
19
+
20
+ # CORS
21
+ FRONTEND_URL: str = "http://localhost:3000"
22
+
23
+ @property
24
+ def DATABASE_URL(self) -> str:
25
+ return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}"
26
+
27
+ class Config:
28
+ env_file = ".env"
29
+ extra = "ignore"
30
+
31
+ @lru_cache()
32
+ def get_settings():
33
+ return Settings()
backend/app/database.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from app.config import get_settings
3
+
4
+ settings = get_settings()
5
+
6
+ engine = create_engine(
7
+ settings.DATABASE_URL,
8
+ pool_pre_ping=True,
9
+ pool_recycle=3600
10
+ )
11
+
12
+ def get_db_engine():
13
+ return engine
backend/app/logger.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime
3
+ from typing import List, Dict, Optional
4
+
5
+ class MemoryLogHandler(logging.Handler):
6
+ def __init__(self, capacity: int = 500):
7
+ super().__init__()
8
+ self.capacity = capacity
9
+ self.logs: List[Dict] = []
10
+
11
+ def emit(self, record):
12
+ try:
13
+ # We want just the message or formatted message
14
+ msg = record.getMessage()
15
+ log_entry = {
16
+ "timestamp": datetime.fromtimestamp(record.created).isoformat(),
17
+ "level": record.levelname,
18
+ "source": record.name.split('.')[-1],
19
+ "message": msg
20
+ }
21
+ self.logs.append(log_entry)
22
+ if len(self.logs) > self.capacity:
23
+ self.logs.pop(0)
24
+ except Exception:
25
+ self.handleError(record)
26
+
27
+ def get_logs(self, limit: int = 100, level: Optional[str] = None) -> List[Dict]:
28
+ filtered = self.logs
29
+ if level and level.upper() != "ALL":
30
+ filtered = [l for l in filtered if l["level"] == level.upper()]
31
+ return filtered[-limit:]
32
+
33
+ def clear_logs(self):
34
+ self.logs.clear()
35
+
36
+ memory_handler = MemoryLogHandler()
37
+
38
+ def get_app_logger(name: str):
39
+ logger = logging.getLogger(f"app.{name}")
40
+ logger.setLevel(logging.INFO)
41
+ # Ensure memory handler is added
42
+ if not any(isinstance(h, MemoryLogHandler) for h in logger.handlers):
43
+ logger.addHandler(memory_handler)
44
+ return logger
backend/app/main.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Query
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.responses import FileResponse
5
+ from pydantic import BaseModel
6
+ from typing import Optional
7
+ from app.scheduler import scraper_scheduler
8
+ from app.scraper import scraping_hari_ini, upload_to_mysql
9
+ from app.database import get_db_engine
10
+ from app.config import get_settings
11
+ from app.logger import get_app_logger, memory_handler
12
+ import os
13
+
14
+ logger = get_app_logger("main")
15
+ settings = get_settings()
16
+
17
+ app = FastAPI(title="Sikomo Scraper API", version="1.1.0")
18
+
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=["*"], # Allow all for HF Spaces compatibility
22
+ allow_credentials=True,
23
+ allow_methods=["*"],
24
+ allow_headers=["*"],
25
+ )
26
+
27
+ class ScheduleAdd(BaseModel):
28
+ cron_expression: str
29
+ label: Optional[str] = ""
30
+
31
+ class ScheduleUpdate(BaseModel):
32
+ cron_expression: str
33
+
34
+ @app.on_event("startup")
35
+ async def startup_event():
36
+ logger.info("🎉 Memulai Sikomo Scraper API Server...")
37
+ scraper_scheduler.start_all()
38
+ logger.info("🚀 API Server siap menerima permintaan.")
39
+
40
+ @app.on_event("shutdown")
41
+ async def shutdown_event():
42
+ scraper_scheduler.stop_all()
43
+ logger.info("🛑 API Server dimatikan.")
44
+
45
+ # --- API Endpoints ---
46
+
47
+ @app.get("/status")
48
+ async def get_status():
49
+ return scraper_scheduler.get_status()
50
+
51
+ @app.get("/logs")
52
+ async def get_logs(limit: int = Query(100, le=500), level: Optional[str] = None):
53
+ return memory_handler.get_logs(limit=limit, level=level)
54
+
55
+ @app.get("/schedules")
56
+ async def list_schedules():
57
+ return scraper_scheduler.list_schedules()
58
+
59
+ @app.post("/schedules/add")
60
+ async def add_schedule(schedule: ScheduleAdd):
61
+ try:
62
+ job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label=schedule.label)
63
+ return {
64
+ "success": True,
65
+ "job_id": job_id,
66
+ "message": f"Jadwal berhasil ditambahkan: {schedule.cron_expression}"
67
+ }
68
+ except Exception as e:
69
+ raise HTTPException(status_code=400, detail=str(e))
70
+
71
+ @app.delete("/schedules/{job_id}/remove")
72
+ async def remove_schedule(job_id: str):
73
+ try:
74
+ scraper_scheduler.remove_schedule(job_id)
75
+ return {"success": True, "message": "Jadwal berhasil dihapus"}
76
+ except KeyError:
77
+ raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
78
+ except Exception as e:
79
+ raise HTTPException(status_code=400, detail=str(e))
80
+
81
+ # Backward compatibility for single schedule update if UI still uses it
82
+ @app.post("/schedule/update")
83
+ async def update_schedule(schedule: ScheduleUpdate):
84
+ try:
85
+ # Clear existing and add new
86
+ scraper_scheduler.stop_all()
87
+ scraper_scheduler.start_all()
88
+ job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label="Jadwal Utama")
89
+ return {"success": True, "message": f"Jadwal diupdate ke: {schedule.cron_expression}"}
90
+ except Exception as e:
91
+ raise HTTPException(status_code=400, detail=str(e))
92
+
93
+ @app.post("/scrape/manual")
94
+ def manual_scrape():
95
+ logger.info("⚡ Permintaan manual scraping dipicu via API...")
96
+ try:
97
+ df = scraping_hari_ini()
98
+ engine = get_db_engine()
99
+ stats = upload_to_mysql(df, engine)
100
+ return {"success": True, "stats": stats}
101
+ except Exception as e:
102
+ logger.error(f"❌ Manual scrape gagal: {e}")
103
+ raise HTTPException(status_code=500, detail=str(e))
104
+
105
+ @app.post("/scrape/stop")
106
+ def stop_manual_scrape():
107
+ from app.scraper import stop_event
108
+ logger.warning("🛑 Menerima permintaan penghentian scraping manual...")
109
+ stop_event.set()
110
+ return {"success": True, "message": "Permintaan penghentian dikirim"}
111
+
112
+ @app.post("/schedule/start")
113
+ async def start_scheduler():
114
+ scraper_scheduler.start_all()
115
+ return {"success": True, "message": "Scheduler otomatis diaktifkan"}
116
+
117
+ @app.post("/schedule/stop")
118
+ async def stop_scheduler():
119
+ scraper_scheduler.stop_all()
120
+ return {"success": True, "message": "Scheduler otomatis dimatikan"}
121
+
122
+ # --- Static Files / SPA Serving ---
123
+ # Mount static folder if exists (in Docker production or local test)
124
+ static_dirs = [
125
+ "/app/static",
126
+ os.path.join(os.path.dirname(os.path.dirname(__file__)), "static"),
127
+ os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend", "out")
128
+ ]
129
+
130
+ mounted = False
131
+ for sdir in static_dirs:
132
+ if os.path.exists(sdir):
133
+ app.mount("/", StaticFiles(directory=sdir, html=True), name="spa")
134
+ mounted = True
135
+ break
136
+
137
+ if not mounted:
138
+ @app.get("/")
139
+ async def root_fallback():
140
+ return {
141
+ "message": "Sikomo Scraper API Server beroperasi normal.",
142
+ "note": "Frontend static files belum di-build/mount ke direktori static."
143
+ }
backend/app/scheduler.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from apscheduler.schedulers.background import BackgroundScheduler
2
+ from apscheduler.triggers.cron import CronTrigger
3
+ from app.scraper import scraping_hari_ini, upload_to_mysql
4
+ from app.database import get_db_engine
5
+ from app.config import get_settings
6
+ from app.logger import get_app_logger
7
+ from datetime import datetime
8
+ import uuid
9
+ from pytz import timezone
10
+
11
+ logger = get_app_logger("scheduler")
12
+ settings = get_settings()
13
+ wib_tz = timezone("Asia/Jakarta")
14
+
15
+ class ScraperScheduler:
16
+ def __init__(self):
17
+ self.scheduler = BackgroundScheduler(timezone=wib_tz)
18
+ self.is_running = False
19
+ self.last_run = None
20
+ self.last_result = None
21
+ self.schedules = {} # id -> {"cron": str, "label": str}
22
+
23
+ def run_scraping(self):
24
+ logger.info("⏰ Menjalankan tugas scraping terjadwal otomatis...")
25
+ try:
26
+ df = scraping_hari_ini()
27
+ engine = get_db_engine()
28
+ stats = upload_to_mysql(df, engine)
29
+ self.last_run = datetime.now()
30
+ self.last_result = {
31
+ "success": True,
32
+ "stats": stats,
33
+ "timestamp": self.last_run.isoformat()
34
+ }
35
+ logger.info("✅ Tugas scraping otomatis selesai dijalankan.")
36
+ except Exception as e:
37
+ self.last_result = {
38
+ "success": False,
39
+ "error": str(e),
40
+ "timestamp": datetime.now().isoformat()
41
+ }
42
+ logger.error(f"❌ Tugas scraping otomatis gagal: {e}")
43
+
44
+ def start_all(self):
45
+ if not self.is_running:
46
+ self.scheduler.start()
47
+ self.is_running = True
48
+ logger.info("▶️ Mesin Scheduler utama telah diaktifkan.")
49
+
50
+ # Load default schedules if empty
51
+ if not self.schedules:
52
+ defaults = [s.strip() for s in settings.DEFAULT_SCHEDULES.split(",") if s.strip()]
53
+ for cron in defaults:
54
+ self.add_schedule(cron, label="Jadwal Default")
55
+
56
+ def stop_all(self):
57
+ if self.is_running:
58
+ self.scheduler.shutdown(wait=False)
59
+ self.scheduler = BackgroundScheduler() # reset
60
+ self.is_running = False
61
+ self.schedules.clear()
62
+ logger.info("⏹️ Mesin Scheduler utama telah dimatikan.")
63
+
64
+ def add_schedule(self, cron_expression: str, label: str = "") -> str:
65
+ # Check if we already have this exact cron to avoid exact duplicates if desired,
66
+ # but let's allow unique IDs
67
+ job_id = str(uuid.uuid4())[:8]
68
+ try:
69
+ trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
70
+ self.scheduler.add_job(
71
+ self.run_scraping,
72
+ trigger,
73
+ id=job_id,
74
+ replace_existing=True
75
+ )
76
+ if not label:
77
+ label = f"Jadwal {cron_expression}"
78
+ self.schedules[job_id] = {"cron": cron_expression, "label": label}
79
+ logger.info(f"📅 Jadwal baru ditambahkan: {cron_expression} ({label})")
80
+
81
+ # Ensure scheduler is running if we add a job
82
+ if not self.is_running:
83
+ self.scheduler.start()
84
+ self.is_running = True
85
+ logger.info("▶️ Mesin Scheduler otomatis menyala karena ada jadwal baru.")
86
+
87
+ return job_id
88
+ except Exception as e:
89
+ logger.error(f"❌ Gagal menambahkan jadwal cron '{cron_expression}': {e}")
90
+ raise ValueError(f"Format Cron tidak valid: {e}")
91
+
92
+ def remove_schedule(self, job_id: str):
93
+ if job_id in self.schedules:
94
+ try:
95
+ self.scheduler.remove_job(job_id)
96
+ except:
97
+ pass
98
+ deleted = self.schedules.pop(job_id)
99
+ logger.info(f"🗑️ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
100
+ else:
101
+ raise KeyError("ID Jadwal tidak ditemukan")
102
+
103
+ def list_schedules(self):
104
+ result = []
105
+ # Get active jobs from APScheduler to find next_run_time
106
+ jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
107
+
108
+ for jid, info in self.schedules.items():
109
+ job_obj = jobs_map.get(jid)
110
+ next_run = job_obj.next_run_time.isoformat() if (job_obj and job_obj.next_run_time) else None
111
+ result.append({
112
+ "id": jid,
113
+ "cron_expression": info["cron"],
114
+ "label": info["label"],
115
+ "next_run": next_run
116
+ })
117
+ return result
118
+
119
+ def get_status(self):
120
+ # Let's find the absolute next run among all active jobs
121
+ jobs = self.scheduler.get_jobs()
122
+ next_runs = [j.next_run_time for j in jobs if j.next_run_time]
123
+ next_run_overall = min(next_runs).isoformat() if next_runs else None
124
+
125
+ return {
126
+ "is_running": self.is_running,
127
+ "last_run": self.last_run.isoformat() if self.last_run else None,
128
+ "last_result": self.last_result,
129
+ "next_run": next_run_overall,
130
+ "active_schedules_count": len(self.schedules)
131
+ }
132
+
133
+ scraper_scheduler = ScraperScheduler()
backend/app/scraper.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+ from datetime import datetime
4
+ import pandas as pd
5
+ import time
6
+ from typing import List, Dict
7
+ import threading
8
+ from app.logger import get_app_logger
9
+
10
+ logger = get_app_logger("scraper")
11
+
12
+ # Global event to signal stopping of scraping tasks
13
+ stop_event = threading.Event()
14
+
15
+ BASE_URL = "https://komoditas.batangkab.go.id/statistik_komo"
16
+
17
+ KOMODITAS_MAPPING = {
18
+ 34: {"id": 1, "nama": "Bawang Merah"},
19
+ 35: {"id": 2, "nama": "Bawang Putih"},
20
+ 3: {"id": 3, "nama": "Beras IR-64 (KW Medium)"},
21
+ 46: {"id": 4, "nama": "Beras IR-64 (KW Premium)"},
22
+ 30: {"id": 5, "nama": "Cabe Merah Besar Keriting"},
23
+ 31: {"id": 6, "nama": "Cabe Merah Besar Teropong"},
24
+ 32: {"id": 7, "nama": "Cabe Rawit Merah"},
25
+ 33: {"id": 8, "nama": "Cabe Rawit Hijau"},
26
+ 11: {"id": 9, "nama": "Daging Ayam Ras"},
27
+ 12: {"id": 10, "nama": "Daging Ayam Kampung"},
28
+ 10: {"id": 11, "nama": "Daging Sapi Murni (Has)"},
29
+ 14: {"id": 12, "nama": "Telur Ayam Ras"},
30
+ 15: {"id": 13, "nama": "Telur Ayam Kampung"},
31
+ 63: {"id": 14, "nama": "Jeruk (1 kg)"},
32
+ 23: {"id": 15, "nama": "Jagung Pipilan Kering"},
33
+ 42: {"id": 16, "nama": "Kacang Hijau"},
34
+ 43: {"id": 17, "nama": "Kacang Tanah"},
35
+ 27: {"id": 18, "nama": "Kacang Kedelai Import"},
36
+ 28: {"id": 19, "nama": "Kacang Kedelai Lokal"},
37
+ 44: {"id": 20, "nama": "Ketela Pohon"},
38
+ 60: {"id": 21, "nama": "Pisang Ambon/Pisang Lokal (1 kg)"},
39
+ 48: {"id": 22, "nama": "Ikan Bandeng"},
40
+ 64: {"id": 23, "nama": "Ikan Kembung"},
41
+ 49: {"id": 24, "nama": "Ikan Asin Teri"},
42
+ 57: {"id": 25, "nama": "Udang Jerbung"},
43
+ 24: {"id": 26, "nama": "Tepung Terigu"},
44
+ 25: {"id": 27, "nama": "Tepung Terigu Segitiga Biru (KW medium)"},
45
+ 5: {"id": 28, "nama": "Gula Pasir Lokal (KW Medium)"},
46
+ 38: {"id": 29, "nama": "Garam Beryodium Bata"},
47
+ 39: {"id": 30, "nama": "Garam Beryodium Halus"},
48
+ 45: {"id": 31, "nama": "LPG 3 KG"},
49
+ 7: {"id": 32, "nama": "Minyak Goreng Bimoli Botol"},
50
+ 8: {"id": 33, "nama": "Minyak Goreng Curah (Tanpa Merk)"},
51
+ 41: {"id": 34, "nama": "Mie Instan Merk Indomie Kari Ayam"},
52
+ 18: {"id": 35, "nama": "Susu Kental Manis Merk Bendera"},
53
+ 19: {"id": 36, "nama": "Susu Kental Manis Merk Indomilk"},
54
+ 21: {"id": 37, "nama": "Susu Bubuk Merk Indomilk Coklat"},
55
+ 22: {"id": 38, "nama": "Susu Bubuk Merk Indomilk Full Cream"},
56
+ 61: {"id": 39, "nama": "Dancow Vanila (400 - 500 gram)"},
57
+ 62: {"id": 40, "nama": "Susu Balita (SGM atau sejenisnya 400 gram)"},
58
+ 50: {"id": 41, "nama": "Pupuk UREA"},
59
+ 51: {"id": 42, "nama": "Pupuk SP-36"},
60
+ 52: {"id": 43, "nama": "Pupuk ZA"},
61
+ 53: {"id": 44, "nama": "Pupuk KCL"},
62
+ 54: {"id": 45, "nama": "Semen Tiga Roda"},
63
+ 55: {"id": 46, "nama": "Semen Gresik"},
64
+ 56: {"id": 47, "nama": "Semen Holcim"},
65
+ 58: {"id": 48, "nama": "Tempe (1 kg)"},
66
+ 59: {"id": 49, "nama": "Tahu Mentah (1 kg)"}
67
+ }
68
+
69
+ BULAN_MAP = {
70
+ "januari": "01", "februari": "02", "maret": "03", "april": "04",
71
+ "mei": "05", "juni": "06", "juli": "07", "agustus": "08",
72
+ "september": "09", "oktober": "10", "november": "11", "desember": "12"
73
+ }
74
+
75
+ NAMA_PASAR = {
76
+ 1: "Pasar Batang",
77
+ 2: "Pasar Bandar",
78
+ 3: "Pasar Limpung"
79
+ }
80
+
81
+ def convert_tanggal_indo(tanggal_text: str) -> str:
82
+ try:
83
+ parts = tanggal_text.strip().lower().split()
84
+ hari = parts[0].zfill(2)
85
+ bulan = BULAN_MAP[parts[1]]
86
+ tahun = parts[2]
87
+ return f"{tahun}-{bulan}-{hari}"
88
+ except:
89
+ return None
90
+
91
+ def clean_harga(value: str) -> float:
92
+ """
93
+ Cleans price string into float. Handles Indonesian formats (e.g., 13.500,00 or 13500,00).
94
+ """
95
+ if value is None:
96
+ return 0.0
97
+ value = str(value).strip()
98
+ if value in ("", "-", "0"):
99
+ return 0.0
100
+
101
+ # Remove currency symbols and other non-numeric chars except comma and dot
102
+ import re
103
+ value = re.sub(r'[^\d,.]', '', value)
104
+
105
+ # Handle Indonesian format: dots as thousands, comma as decimal
106
+ # Or common web format: commas as thousands, dot as decimal
107
+ # If both present, assume last one is decimal
108
+ if "," in value and "." in value:
109
+ if value.rfind(",") > value.rfind("."):
110
+ # Dot is thousands, comma is decimal
111
+ value = value.replace(".", "").replace(",", ".")
112
+ else:
113
+ # Comma is thousands, dot is decimal
114
+ value = value.replace(",", "")
115
+ elif "," in value:
116
+ # Only comma: if it looks like decimal (e.g. 13500,00), replace with dot
117
+ # If it looks like thousands (e.g. 13,500), remove it
118
+ if len(value.split(",")[-1]) <= 2: # Likely decimal
119
+ value = value.replace(",", ".")
120
+ else:
121
+ value = value.replace(",", "")
122
+
123
+ try:
124
+ return float(value)
125
+ except (ValueError, TypeError):
126
+ return 0.0
127
+
128
+ def is_summary_row(text: str) -> bool:
129
+ text = text.lower()
130
+ keywords = ["harga tertinggi", "harga rata", "harga terendah"]
131
+ return any(k in text for k in keywords)
132
+
133
+ def scraping_hari_ini() -> pd.DataFrame:
134
+ today = datetime.now()
135
+ hasil = []
136
+
137
+ logger.info("=" * 50)
138
+ logger.info(f"🕸️ Memulai proses scraping untuk tanggal {today.strftime('%Y-%m-%d')}")
139
+ logger.info("=" * 50)
140
+
141
+ stop_event.clear()
142
+
143
+ for scraping_id, info in KOMODITAS_MAPPING.items():
144
+ database_id = info["id"]
145
+ nama_komoditas = info["nama"]
146
+
147
+ if stop_event.is_set():
148
+ logger.warning("🛑 Scraping dihentikan atas permintaan pengguna.")
149
+ break
150
+
151
+ params = {
152
+ "id_komoditi": scraping_id,
153
+ "bulan": today.month,
154
+ "tahun": today.year
155
+ }
156
+
157
+ try:
158
+ response = requests.get(BASE_URL, params=params, timeout=30)
159
+ if response.status_code != 200:
160
+ logger.warning(f"⚠️ Gagal mengambil data ID {scraping_id} (HTTP {response.status_code})")
161
+ continue
162
+
163
+ soup = BeautifulSoup(response.text, "html.parser")
164
+ table = soup.find("table")
165
+ if not table:
166
+ continue
167
+
168
+ rows = table.find_all("tr")
169
+ found_data = False
170
+ for row in rows[1:]:
171
+ cols = row.find_all(["td", "th"])
172
+ data = [col.get_text(strip=True) for col in cols]
173
+
174
+ if len(data) < 4 or is_summary_row(data[0]):
175
+ continue
176
+
177
+ tanggal = convert_tanggal_indo(data[0])
178
+ if not tanggal or tanggal != today.strftime("%Y-%m-%d"):
179
+ continue
180
+
181
+ found_data = True
182
+ for pasar_id, harga in enumerate([clean_harga(data[1]), clean_harga(data[2]), clean_harga(data[3])], start=1):
183
+ hasil.append({
184
+ "komoditas_id": database_id,
185
+ "komoditas_nama": nama_komoditas,
186
+ "pasar_id": pasar_id,
187
+ "tanggal": tanggal,
188
+ "harga": harga
189
+ })
190
+
191
+ if found_data:
192
+ logger.info(f"✅ Scraping sukses untuk {nama_komoditas} (ID {scraping_id} -> DB ID {database_id})")
193
+ time.sleep(0.5)
194
+ except Exception as e:
195
+ logger.error(f"❌ Error scraping ID {scraping_id}: {e}")
196
+
197
+ df = pd.DataFrame(hasil)
198
+ logger.info(f"📊 Total baris hasil scraping: {len(df)}")
199
+ return df
200
+
201
+ def upload_to_mysql(df: pd.DataFrame, engine) -> Dict:
202
+ pasar_berhasil = set()
203
+ pasar_harga_0 = set()
204
+ komoditas_berhasil = []
205
+ komoditas_harga_0 = []
206
+
207
+ stats = {
208
+ "total_insert": 0,
209
+ "total_update": 0,
210
+ "total_skip": 0,
211
+ "total_harga_0": 0,
212
+ "pasar_berhasil": [],
213
+ "pasar_harga_0": [],
214
+ "komoditas_berhasil_count": 0,
215
+ "komoditas_harga_0_count": 0
216
+ }
217
+
218
+ logger.info("🚀 Memulai proses upload ke database MySQL...")
219
+
220
+ if df.empty:
221
+ logger.warning("⚠️ DataFrame kosong, tidak ada data yang diupload.")
222
+ return stats
223
+
224
+ with engine.begin() as conn:
225
+ for _, row in df.iterrows():
226
+ if stop_event.is_set():
227
+ logger.warning("🛑 Upload database dihentikan atas permintaan pengguna.")
228
+ break
229
+
230
+ komoditas_id = int(row["komoditas_id"])
231
+ komoditas_nama = row["komoditas_nama"]
232
+ pasar_id = int(row["pasar_id"])
233
+ tanggal = row["tanggal"]
234
+ harga_baru = float(row["harga"])
235
+ pasar_nama = NAMA_PASAR.get(pasar_id, f"Pasar {pasar_id}")
236
+
237
+ if harga_baru <= 0:
238
+ stats["total_harga_0"] += 1
239
+ komoditas_harga_0.append(komoditas_id)
240
+ pasar_harga_0.add(pasar_nama)
241
+ continue
242
+
243
+ check_query = """
244
+ SELECT id, harga FROM harga_harian
245
+ WHERE komoditas_id = %s AND pasar_id = %s AND tanggal = %s LIMIT 1
246
+ """
247
+ result = conn.exec_driver_sql(check_query, (komoditas_id, pasar_id, tanggal)).fetchone()
248
+
249
+ if result:
250
+ existing_id, harga_lama = result[0], float(result[1])
251
+ if harga_lama <= 0 and harga_baru > 0:
252
+ conn.exec_driver_sql("UPDATE harga_harian SET harga = %s WHERE id = %s", (harga_baru, existing_id))
253
+ stats["total_update"] += 1
254
+ komoditas_berhasil.append(komoditas_id)
255
+ pasar_berhasil.add(pasar_nama)
256
+ logger.info(f"✏️ Update sukses: {komoditas_nama} di {pasar_nama} -> Rp {harga_baru:,.0f}")
257
+ else:
258
+ stats["total_skip"] += 1
259
+ else:
260
+ conn.exec_driver_sql(
261
+ "INSERT INTO harga_harian (komoditas_id, pasar_id, tanggal, harga) VALUES (%s, %s, %s, %s)",
262
+ (komoditas_id, pasar_id, tanggal, harga_baru)
263
+ )
264
+ stats["total_insert"] += 1
265
+ komoditas_berhasil.append(komoditas_id)
266
+ pasar_berhasil.add(pasar_nama)
267
+ logger.info(f"📥 Insert sukses: {komoditas_nama} di {pasar_nama} -> Rp {harga_baru:,.0f}")
268
+
269
+ stats["pasar_berhasil"] = sorted(list(pasar_berhasil))
270
+ stats["pasar_harga_0"] = sorted(list(pasar_harga_0))
271
+ stats["komoditas_berhasil_count"] = len(set(komoditas_berhasil))
272
+ stats["komoditas_harga_0_count"] = len(set(komoditas_harga_0))
273
+
274
+ logger.info("=" * 50)
275
+ logger.info("📈 Ringkasan Upload Database:")
276
+ logger.info(f" • Insert Baru : {stats['total_insert']}")
277
+ logger.info(f" • Update Data : {stats['total_update']}")
278
+ logger.info(f" • Diabaikan : {stats['total_skip']}")
279
+ logger.info(f" • Harga Nol (0) : {stats['total_harga_0']}")
280
+ logger.info(f" • Pasar Sukses : {', '.join(stats['pasar_berhasil']) or '-'}")
281
+ logger.info("=" * 50)
282
+
283
+ return stats
backend/requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ requests==2.31.0
4
+ beautifulsoup4==4.12.3
5
+ pandas==2.2.0
6
+ pymysql==1.1.0
7
+ sqlalchemy==2.0.25
8
+ python-dotenv==1.0.1
9
+ apscheduler==3.10.4
10
+ pydantic==2.5.3
11
+ python-multipart==0.0.6
12
+ pydantic-settings==2.1.0
13
+ aiofiles==23.2.1
frontend/app/globals.css ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer utilities {
6
+ /* Glassmorphism effects */
7
+ .glass-panel {
8
+ background: rgba(15, 23, 42, 0.65);
9
+ backdrop-filter: blur(12px);
10
+ -webkit-backdrop-filter: blur(12px);
11
+ border: 1px solid rgba(255, 255, 255, 0.08);
12
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
13
+ }
14
+
15
+ .glass-input {
16
+ background: rgba(255, 255, 255, 0.05);
17
+ backdrop-filter: blur(4px);
18
+ border: 1px solid rgba(255, 255, 255, 0.1);
19
+ }
20
+
21
+ .glass-input:focus {
22
+ background: rgba(255, 255, 255, 0.08);
23
+ border-color: rgba(45, 212, 191, 0.5);
24
+ box-shadow: 0 0 15px rgba(45, 212, 191, 0.2);
25
+ }
26
+ }
27
+
28
+ /* Custom Scrollbar for Logs */
29
+ ::-webkit-scrollbar {
30
+ width: 6px;
31
+ height: 6px;
32
+ }
33
+
34
+ ::-webkit-scrollbar-track {
35
+ background: rgba(15, 23, 42, 0.8);
36
+ }
37
+
38
+ ::-webkit-scrollbar-thumb {
39
+ background: rgba(255, 255, 255, 0.15);
40
+ border-radius: 3px;
41
+ }
42
+
43
+ ::-webkit-scrollbar-thumb:hover {
44
+ background: rgba(45, 212, 191, 0.4);
45
+ }
46
+
47
+ /* Animations */
48
+ @keyframes glow {
49
+ 0%, 100% { opacity: 0.4; transform: scale(1); }
50
+ 50% { opacity: 0.6; transform: scale(1.05); }
51
+ }
52
+
53
+ .animate-glow {
54
+ animation: glow 8s ease-in-out infinite alternate;
55
+ }
frontend/app/layout.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './globals.css'
2
+ import type { Metadata } from 'next'
3
+ import { Inter } from 'next/font/google'
4
+
5
+ const inter = Inter({ subsets: ['latin'] })
6
+
7
+ export const metadata: Metadata = {
8
+ title: 'Sikomo Daily Scraper Portal',
9
+ description: 'Premium Automated Commodity Price Scraper & Control Panel',
10
+ }
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode
16
+ }) {
17
+ return (
18
+ <html lang="id" className="scroll-smooth">
19
+ <body className={`${inter.className} bg-slate-900 text-slate-100 min-h-screen antialiased selection:bg-teal-500 selection:text-white`}>
20
+ {children}
21
+ </body>
22
+ </html>
23
+ )
24
+ }
frontend/app/page.tsx ADDED
@@ -0,0 +1,781 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef } from 'react'
4
+ import axios from 'axios'
5
+ import {
6
+ Play,
7
+ Square,
8
+ RefreshCw,
9
+ Calendar,
10
+ Activity,
11
+ CheckCircle,
12
+ XCircle,
13
+ Lock,
14
+ LogOut,
15
+ Plus,
16
+ Trash2,
17
+ Terminal,
18
+ Filter,
19
+ Check,
20
+ AlertTriangle,
21
+ Info,
22
+ X,
23
+ Layers
24
+ } from 'lucide-react'
25
+
26
+ // Auto-detect base path/URL if running exported on FastAPI or standalone Next.js
27
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || ''
28
+
29
+ interface Status {
30
+ is_running: boolean
31
+ last_run: string | null
32
+ last_result: any
33
+ next_run: string | null
34
+ active_schedules_count: number
35
+ }
36
+
37
+ interface ScheduleItem {
38
+ id: string
39
+ cron_expression: string
40
+ label: string
41
+ next_run: string | null
42
+ }
43
+
44
+ interface LogItem {
45
+ timestamp: string
46
+ level: string
47
+ source: string
48
+ message: string
49
+ }
50
+
51
+ export default function Dashboard() {
52
+ // --- Auth State ---
53
+ const [isAuthenticated, setIsAuthenticated] = useState(false)
54
+ const [passwordInput, setPasswordInput] = useState('')
55
+ const [authError, setAuthError] = useState(false)
56
+
57
+ // --- Dashboard States ---
58
+ const [status, setStatus] = useState<Status | null>(null)
59
+ const [schedules, setSchedules] = useState<ScheduleItem[]>([])
60
+ const [logs, setLogs] = useState<LogItem[]>([])
61
+
62
+ // Controls
63
+ const [loadingScrape, setLoadingScrape] = useState(false)
64
+ const [newCron, setNewCron] = useState('')
65
+ const [newLabel, setNewLabel] = useState('')
66
+ const [selectedHour, setSelectedHour] = useState('08')
67
+ const [selectedMinute, setSelectedMinute] = useState('00')
68
+ const [logLevelFilter, setLogLevelFilter] = useState('ALL')
69
+
70
+ // Scrape Details Modal
71
+ const [showStatsModal, setShowStatsModal] = useState(false)
72
+ const [selectedStats, setSelectedStats] = useState<any>(null)
73
+
74
+ const logsEndRef = useRef<HTMLDivElement>(null)
75
+ const logsContainerRef = useRef<HTMLDivElement>(null)
76
+ const prevLogsLengthRef = useRef(0)
77
+ const [currentTime, setCurrentTime] = useState('')
78
+
79
+ // Check auth on load
80
+ useEffect(() => {
81
+ const sessionAuth = sessionStorage.getItem('sikomo_auth')
82
+ if (sessionAuth === 'authenticated') {
83
+ setIsAuthenticated(true)
84
+ }
85
+ }, [])
86
+
87
+ const handleLogin = (e: React.FormEvent) => {
88
+ e.preventDefault()
89
+ // Trim to be safe against accidental spaces
90
+ if (passwordInput.trim() === 'Bandulan112') {
91
+ setIsAuthenticated(true)
92
+ setAuthError(false)
93
+ sessionStorage.setItem('sikomo_auth', 'authenticated')
94
+ } else {
95
+ setAuthError(true)
96
+ }
97
+ }
98
+
99
+ const handleLogout = () => {
100
+ setIsAuthenticated(false)
101
+ setPasswordInput('')
102
+ sessionStorage.removeItem('sikomo_auth')
103
+ }
104
+
105
+ // Fetching Data API
106
+ const fetchData = async () => {
107
+ if (!isAuthenticated) return
108
+ try {
109
+ const [resStatus, resSchedules, resLogs] = await Promise.all([
110
+ axios.get(`${API_URL}/status`),
111
+ axios.get(`${API_URL}/schedules`),
112
+ axios.get(`${API_URL}/logs?limit=200${logLevelFilter !== 'ALL' ? `&level=${logLevelFilter}` : ''}`)
113
+ ])
114
+ setStatus(resStatus.data)
115
+ setSchedules(resSchedules.data)
116
+ setLogs(resLogs.data)
117
+ } catch (err) {
118
+ console.error('Error fetching dashboard API:', err)
119
+ }
120
+ }
121
+
122
+ // Polling data every 3 seconds
123
+ useEffect(() => {
124
+ fetchData()
125
+ const interval = setInterval(fetchData, 3000)
126
+ return () => clearInterval(interval)
127
+ }, [isAuthenticated, logLevelFilter])
128
+
129
+ // Realtime Clock Ticker synchronized to WIB (Asia/Jakarta)
130
+ useEffect(() => {
131
+ const updateClock = () => {
132
+ try {
133
+ const timeStr = new Date().toLocaleTimeString('id-ID', {
134
+ timeZone: 'Asia/Jakarta',
135
+ hour: '2-digit',
136
+ minute: '2-digit',
137
+ second: '2-digit'
138
+ })
139
+ setCurrentTime(timeStr)
140
+ } catch (e) {
141
+ // fallback if browser doesn't support specific timezone string
142
+ setCurrentTime(new Date().toLocaleTimeString('id-ID'))
143
+ }
144
+ }
145
+ updateClock()
146
+ const timer = setInterval(updateClock, 1000)
147
+ return () => clearInterval(timer)
148
+ }, [])
149
+
150
+ // Inner container targeted scroll to bottom only on new log append
151
+ useEffect(() => {
152
+ if (logs.length > prevLogsLengthRef.current) {
153
+ if (logsContainerRef.current) {
154
+ logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
155
+ } else {
156
+ // fallback to smooth inner ref if container ref not bound yet
157
+ logsEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
158
+ }
159
+ }
160
+ prevLogsLengthRef.current = logs.length
161
+ }, [logs])
162
+
163
+ // --- API Actions ---
164
+ const handleManualScrape = async () => {
165
+ setLoadingScrape(true)
166
+ try {
167
+ const res = await axios.post(`${API_URL}/scrape/manual`)
168
+ setSelectedStats(res.data.stats)
169
+ setShowStatsModal(true)
170
+ fetchData()
171
+ } catch (err: any) {
172
+ alert(`❌ Proses Scraping Gagal: ${err.response?.data?.detail || err.message}`)
173
+ } finally {
174
+ setLoadingScrape(false)
175
+ }
176
+ }
177
+
178
+ const handleAddSchedule = async (e: React.FormEvent) => {
179
+ e.preventDefault()
180
+ try {
181
+ // Format as cron: "minute hour * * *"
182
+ const cronExpression = `${selectedMinute} ${selectedHour} * * *`
183
+ await axios.post(`${API_URL}/schedules/add`, {
184
+ cron_expression: cronExpression,
185
+ label: newLabel.trim() || `Jadwal ${selectedHour}:${selectedMinute}`
186
+ })
187
+ setNewLabel('')
188
+ fetchData()
189
+ } catch (err: any) {
190
+ alert(`❌ Gagal menambah jadwal: ${err.response?.data?.detail || err.message}`)
191
+ }
192
+ }
193
+
194
+ const handleStopScrape = async () => {
195
+ try {
196
+ await axios.post(`${API_URL}/scrape/stop`)
197
+ // Loading will end when the main POST /scrape/manual request catches the signal and returns
198
+ } catch (err: any) {
199
+ console.error('Gagal stop scrape:', err)
200
+ }
201
+ }
202
+
203
+ const handleRemoveSchedule = async (jobId: string) => {
204
+ try {
205
+ await axios.delete(`${API_URL}/schedules/${jobId}/remove`)
206
+ fetchData()
207
+ } catch (err: any) {
208
+ alert(`❌ Gagal menghapus jadwal: ${err.response?.data?.detail || err.message}`)
209
+ }
210
+ }
211
+
212
+ const handleToggleScheduler = async () => {
213
+ try {
214
+ const endpoint = status?.is_running ? '/schedule/stop' : '/schedule/start'
215
+ await axios.post(`${API_URL}${endpoint}`)
216
+ fetchData()
217
+ } catch (err: any) {
218
+ alert(`❌ Error toggle scheduler: ${err.response?.data?.detail || err.message}`)
219
+ }
220
+ }
221
+
222
+ const addPresetCron = (hour: string, minute: string, label: string) => {
223
+ setSelectedHour(hour)
224
+ setSelectedMinute(minute)
225
+ setNewLabel(label)
226
+ }
227
+
228
+ // --- RENDER LOGIN GATE ---
229
+ if (!isAuthenticated) {
230
+ return (
231
+ <div className="min-h-screen relative overflow-hidden flex items-center justify-center p-4">
232
+ {/* Background glowing blobs */}
233
+ <div className="absolute w-[500px] h-[500px] bg-teal-500/20 rounded-full blur-3xl -top-32 -left-32 animate-glow pointer-events-none" />
234
+ <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' }} />
235
+
236
+ <div className="w-full max-w-md glass-panel rounded-2xl p-8 relative z-10 border border-white/10 shadow-2xl">
237
+ <div className="text-center mb-8">
238
+ <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">
239
+ <Lock className="w-8 h-8 text-white" />
240
+ </div>
241
+ <h1 className="text-3xl font-bold bg-gradient-to-r from-white via-slate-100 to-slate-400 bg-clip-text text-transparent">
242
+ Sikomo Scraper Portal
243
+ </h1>
244
+ <p className="text-slate-400 text-sm mt-2">
245
+ Masukkan kata sandi untuk mengakses panel kontrol scraping harga otomatis.
246
+ </p>
247
+ </div>
248
+
249
+ <form onSubmit={handleLogin} className="space-y-6">
250
+ <div>
251
+ <label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
252
+ Kata Sandi
253
+ </label>
254
+ <input
255
+ type="password"
256
+ value={passwordInput}
257
+ onChange={(e) => setPasswordInput(e.target.value)}
258
+ placeholder="••••••••••••"
259
+ className={`w-full glass-input rounded-xl px-4 py-3.5 text-slate-100 placeholder:text-slate-500 focus:outline-none transition-all ${
260
+ authError ? 'border-rose-500/50 focus:border-rose-500 focus:box-shadow-[0_0_15px_rgba(244,63,94,0.2)]' : ''
261
+ }`}
262
+ autoFocus
263
+ />
264
+ {authError && (
265
+ <p className="text-xs text-rose-400 mt-2 flex items-center gap-1">
266
+ <AlertTriangle className="w-3.5 h-3.5" /> Kata sandi tidak cocok.
267
+ </p>
268
+ )}
269
+ </div>
270
+
271
+ <button
272
+ type="submit"
273
+ 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"
274
+ >
275
+ Masuk ke Portal
276
+ </button>
277
+ </form>
278
+
279
+ <div className="mt-6 text-center border-t border-white/5 pt-4">
280
+ <span className="text-[11px] text-slate-500">
281
+ Sistem Informasi Komoditas & Pasar Kabupaten Batang
282
+ </span>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ )
287
+ }
288
+
289
+ // --- RENDER DASHBOARD ---
290
+ return (
291
+ <div className="min-h-screen relative overflow-hidden pb-16">
292
+ {/* Subtle Background Glows */}
293
+ <div className="absolute top-0 right-1/4 w-96 h-96 bg-teal-500/10 rounded-full blur-3xl pointer-events-none" />
294
+ <div className="absolute bottom-10 left-10 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none" />
295
+
296
+ {/* Top Navbar */}
297
+ <header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-30 px-6 py-4">
298
+ <div className="max-w-7xl mx-auto flex items-center justify-between">
299
+ <div className="flex items-center gap-3">
300
+ <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">
301
+ <Activity className="w-5 h-5 text-white" />
302
+ </div>
303
+ <div>
304
+ <h1 className="font-bold text-lg leading-tight bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">
305
+ Sikomo Daily Scraper
306
+ </h1>
307
+ <div className="flex items-center gap-2 mt-0.5">
308
+ <span className="flex h-2 w-2 relative">
309
+ <span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${status?.is_running ? 'bg-teal-400' : 'bg-rose-400'}`} />
310
+ <span className={`relative inline-flex rounded-full h-2 w-2 ${status?.is_running ? 'bg-teal-500' : 'bg-rose-500'}`} />
311
+ </span>
312
+ <span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">
313
+ Engine: {status?.is_running ? 'Active' : 'Inactive'}
314
+ </span>
315
+ </div>
316
+ </div>
317
+ </div>
318
+
319
+ <div className="flex items-center gap-3">
320
+ {currentTime && (
321
+ <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">
322
+ <span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
323
+ <span>{currentTime} WIB</span>
324
+ </div>
325
+ )}
326
+
327
+ <button
328
+ onClick={handleLogout}
329
+ 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"
330
+ >
331
+ <LogOut className="w-3.5 h-3.5" />
332
+ Keluar
333
+ </button>
334
+ </div>
335
+ </div>
336
+ </header>
337
+
338
+ {/* Main Container */}
339
+ <main className="max-w-7xl mx-auto px-6 mt-8 grid grid-cols-1 lg:grid-cols-3 gap-6">
340
+
341
+ {/* LEFT & CENTER COLUMNS: Status & Controls */}
342
+ <div className="lg:col-span-2 space-y-6">
343
+
344
+ {/* SECTION 1: Status & Quick Actions Bento */}
345
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
346
+
347
+ {/* Status Overview Card */}
348
+ <div className="glass-panel rounded-2xl p-6 border border-white/5 relative overflow-hidden flex flex-col justify-between">
349
+ <div>
350
+ <div className="flex items-center justify-between mb-4">
351
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
352
+ <Activity className="w-4 h-4 text-teal-400" /> Status Scheduler
353
+ </h2>
354
+ <span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${
355
+ 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'
356
+ }`}>
357
+ {status?.is_running ? 'RUNNING' : 'STOPPED'}
358
+ </span>
359
+ </div>
360
+
361
+ <div className="space-y-3 mt-4">
362
+ <div className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
363
+ <span className="text-xs text-slate-500 block">Jadwal Aktif</span>
364
+ <span className="text-lg font-bold text-slate-200 mt-0.5">
365
+ {status?.active_schedules_count || 0} <span className="text-xs font-normal text-slate-400">tugas rutin</span>
366
+ </span>
367
+ </div>
368
+
369
+ <div className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
370
+ <span className="text-xs text-slate-500 block">Eksekusi Rutin Berikutnya</span>
371
+ <span className="text-sm font-bold text-teal-400 block truncate mt-0.5">
372
+ {status?.next_run ? new Date(status.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : 'Tidak ada jadwal terdaftar'}
373
+ </span>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ <button
379
+ onClick={handleToggleScheduler}
380
+ className={`w-full mt-4 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center gap-2 border transition ${
381
+ status?.is_running
382
+ ? 'bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border-rose-500/20'
383
+ : 'bg-teal-500/10 hover:bg-teal-500/20 text-teal-400 border-teal-500/20'
384
+ }`}
385
+ >
386
+ {status?.is_running ? <Square className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
387
+ {status?.is_running ? 'Matikan Scheduler Otomatis' : 'Nyalakan Scheduler Otomatis'}
388
+ </button>
389
+ </div>
390
+
391
+ {/* Manual Scrape Card */}
392
+ <div className="glass-panel rounded-2xl p-6 border border-white/5 flex flex-col justify-between relative overflow-hidden">
393
+ <div className="absolute top-0 right-0 p-8 opacity-5 text-teal-500 pointer-events-none">
394
+ <Layers className="w-32 h-32" />
395
+ </div>
396
+
397
+ <div>
398
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2 mb-2">
399
+ <RefreshCw className="w-4 h-4 text-indigo-400" /> Aksi Cepat Manual
400
+ </h2>
401
+ <p className="text-xs text-slate-400 leading-relaxed mb-6">
402
+ Picu mesin scraper sekarang juga untuk mengambil harga terbaru dari server website sumber tanpa menunggu jadwal.
403
+ </p>
404
+
405
+ {/* Last Run Snippet */}
406
+ {status?.last_result?.stats && (
407
+ <div className="bg-slate-900/60 rounded-xl p-3 border border-white/5 mb-4 text-xs">
408
+ <div className="flex items-center justify-between text-slate-400 mb-1.5 pb-1.5 border-b border-white/5 font-medium">
409
+ <span>Statistik Scrape Terakhir</span>
410
+ <button
411
+ onClick={() => { setSelectedStats(status.last_result.stats); setShowStatsModal(true); }}
412
+ className="text-teal-400 hover:underline flex items-center gap-1"
413
+ >
414
+ Detail Breakdown <Info className="w-3 h-3" />
415
+ </button>
416
+ </div>
417
+ <div className="grid grid-cols-4 gap-1 text-center pt-0.5">
418
+ <div><span className="block text-[10px] text-slate-500">Insert</span><span className="font-bold text-teal-400">{status.last_result.stats.total_insert}</span></div>
419
+ <div><span className="block text-[10px] text-slate-500">Update</span><span className="font-bold text-indigo-400">{status.last_result.stats.total_update}</span></div>
420
+ <div><span className="block text-[10px] text-slate-500">Skip</span><span className="font-bold text-slate-400">{status.last_result.stats.total_skip}</span></div>
421
+ <div><span className="block text-[10px] text-slate-500">Harga 0</span><span className="font-bold text-amber-400">{status.last_result.stats.total_harga_0}</span></div>
422
+ </div>
423
+ </div>
424
+ )}
425
+ </div>
426
+
427
+ <div className="flex flex-col gap-2">
428
+ <button
429
+ onClick={handleManualScrape}
430
+ disabled={loadingScrape}
431
+ 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 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-teal-500/10 disabled:opacity-50 transition active:scale-[0.98]"
432
+ >
433
+ {loadingScrape ? (
434
+ <>
435
+ <RefreshCw className="w-4 h-4 animate-spin" />
436
+ Sedang Mengambil Data...
437
+ </>
438
+ ) : (
439
+ <>
440
+ <Play className="w-4 h-4 fill-current" />
441
+ Eksekusi Manual Sekarang
442
+ </>
443
+ )}
444
+ </button>
445
+
446
+ {loadingScrape && (
447
+ <button
448
+ onClick={handleStopScrape}
449
+ className="w-full bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 font-medium py-2 px-4 rounded-xl flex items-center justify-center gap-2 border border-rose-500/30 transition animate-pulse"
450
+ >
451
+ <Square className="w-3.5 h-3.5 fill-current" />
452
+ Hentikan Proses
453
+ </button>
454
+ )}
455
+ </div>
456
+ </div>
457
+
458
+ </div>
459
+
460
+ {/* SECTION 2: Multiple Schedules Manager */}
461
+ <div className="glass-panel rounded-2xl p-6 border border-white/5">
462
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
463
+ <div>
464
+ <h2 className="text-base font-bold text-slate-100 flex items-center gap-2">
465
+ <Calendar className="w-5 h-5 text-teal-400" />
466
+ Manajemen Jadwal Rutin (Multiple Schedules)
467
+ </h2>
468
+ <p className="text-xs text-slate-400 mt-1">
469
+ Atur waktu pengambilan harga otomatis. Anda dapat menambahkan lebih dari satu jadwal per hari.
470
+ </p>
471
+ </div>
472
+
473
+ {/* Quick Presets */}
474
+ <div className="flex flex-wrap gap-1.5 bg-slate-900/60 p-1.5 rounded-xl border border-white/5 self-start sm:self-auto">
475
+ <span className="text-[10px] font-semibold text-slate-500 px-2 self-center">Preset:</span>
476
+ <button
477
+ onClick={() => addPresetCron('08', '00', 'Pagi Hari (08:00)')}
478
+ className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition"
479
+ >
480
+ 08:00
481
+ </button>
482
+ <button
483
+ onClick={() => addPresetCron('12', '00', 'Siang Hari (12:00)')}
484
+ className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition"
485
+ >
486
+ 12:00
487
+ </button>
488
+ <button
489
+ onClick={() => addPresetCron('17', '00', 'Sore Hari (17:00)')}
490
+ className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition"
491
+ >
492
+ 17:00
493
+ </button>
494
+ </div>
495
+ </div>
496
+
497
+ {/* Add Schedule Form */}
498
+ <form onSubmit={handleAddSchedule} className="grid grid-cols-1 md:grid-cols-12 gap-3 mb-6 bg-slate-900/40 p-4 rounded-xl border border-white/5">
499
+ <div className="md:col-span-3">
500
+ <label className="block text-[11px] font-medium text-slate-400 mb-1">Jam (WIB)</label>
501
+ <select
502
+ value={selectedHour}
503
+ onChange={(e) => setSelectedHour(e.target.value)}
504
+ className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none"
505
+ >
506
+ {Array.from({ length: 24 }).map((_, i) => (
507
+ <option key={i} value={i.toString().padStart(2, '0')} className="bg-slate-900">
508
+ {i.toString().padStart(2, '0')}
509
+ </option>
510
+ ))}
511
+ </select>
512
+ </div>
513
+ <div className="md:col-span-3">
514
+ <label className="block text-[11px] font-medium text-slate-400 mb-1">Menit</label>
515
+ <select
516
+ value={selectedMinute}
517
+ onChange={(e) => setSelectedMinute(e.target.value)}
518
+ className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none"
519
+ >
520
+ {Array.from({ length: 60 }).map((_, i) => (
521
+ <option key={i} value={i.toString().padStart(2, '0')} className="bg-slate-900">
522
+ {i.toString().padStart(2, '0')}
523
+ </option>
524
+ ))}
525
+ </select>
526
+ </div>
527
+ <div className="md:col-span-4">
528
+ <label className="block text-[11px] font-medium text-slate-400 mb-1">Label Jadwal</label>
529
+ <input
530
+ type="text"
531
+ value={newLabel}
532
+ onChange={(e) => setNewLabel(e.target.value)}
533
+ placeholder="Contoh: Scraping Pagi"
534
+ className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 placeholder:text-slate-600 focus:outline-none"
535
+ />
536
+ </div>
537
+ <div className="md:col-span-2 flex items-end">
538
+ <button
539
+ type="submit"
540
+ 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 shadow transition"
541
+ >
542
+ <Plus className="w-3.5 h-3.5 stroke-[3]" /> Tambah
543
+ </button>
544
+ </div>
545
+ </form>
546
+
547
+ {/* Schedules Table */}
548
+ <div className="overflow-x-auto">
549
+ <table className="w-full text-left border-collapse">
550
+ <thead>
551
+ <tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
552
+ <th className="pb-3 px-3">Label Jadwal</th>
553
+ <th className="pb-3 px-3">Waktu (WIB)</th>
554
+ <th className="pb-3 px-3">Estimasi Run Berikutnya</th>
555
+ <th className="pb-3 px-3 text-right">Aksi</th>
556
+ </tr>
557
+ </thead>
558
+ <tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
559
+ {schedules.length === 0 ? (
560
+ <tr>
561
+ <td colSpan={4} className="text-center py-8 text-slate-500 italic">
562
+ Belum ada jadwal yang didaftarkan. Gunakan form di atas untuk menambah jadwal.
563
+ </td>
564
+ </tr>
565
+ ) : (
566
+ schedules.map((item) => (
567
+ <tr key={item.id} className="hover:bg-slate-800/30 transition">
568
+ <td className="py-3 px-3 font-medium text-slate-200">
569
+ {item.label}
570
+ </td>
571
+ <td className="py-3 px-3">
572
+ <span className="font-mono text-teal-400 font-bold bg-teal-500/10 py-1 px-2 rounded-lg border border-teal-500/20">
573
+ {(() => {
574
+ const parts = item.cron_expression.split(' ');
575
+ if (parts.length >= 2 && !isNaN(parseInt(parts[0])) && !isNaN(parseInt(parts[1]))) {
576
+ return `${parts[1].padStart(2, '0')}:${parts[0].padStart(2, '0')}`;
577
+ }
578
+ return item.cron_expression;
579
+ })()}
580
+ </span>
581
+ </td>
582
+ <td className="py-3 px-3 text-slate-400">
583
+ {item.next_run ? new Date(item.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : '-'}
584
+ </td>
585
+ <td className="py-3 px-3 text-right">
586
+ <button
587
+ onClick={() => handleRemoveSchedule(item.id)}
588
+ className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition"
589
+ title="Hapus Jadwal"
590
+ >
591
+ <Trash2 className="w-4 h-4" />
592
+ </button>
593
+ </td>
594
+ </tr>
595
+ ))
596
+ )}
597
+ </tbody>
598
+ </table>
599
+ </div>
600
+
601
+ {/* Hint */}
602
+ <div className="mt-4 pt-4 border-t border-white/5 flex items-start gap-2 text-[11px] text-slate-500">
603
+ <Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-slate-400" />
604
+ <span>
605
+ Format standar Cron: <code>(Menit) (Jam) (Hari) (Bulan) (Hari/Minggu)</code>. Waktu server saat ini mengikuti zona waktu sistem lokal.
606
+ </span>
607
+ </div>
608
+ </div>
609
+
610
+ </div>
611
+
612
+ {/* RIGHT COLUMN: Terminal Logs */}
613
+ <div className="glass-panel rounded-2xl p-6 border border-white/5 flex flex-col h-[500px] lg:h-[700px]">
614
+
615
+ {/* Logs Header */}
616
+ <div className="flex items-center justify-between pb-4 mb-4 border-b border-white/5">
617
+ <h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
618
+ <Terminal className="w-4 h-4 text-indigo-400" />
619
+ Log Eksekusi Real-time
620
+ </h2>
621
+
622
+ {/* Controls */}
623
+ <div className="flex items-center gap-1.5">
624
+ <div className="flex bg-slate-900/80 rounded-lg p-0.5 border border-white/5">
625
+ {['ALL', 'INFO', 'WARNING', 'ERROR'].map((lvl) => (
626
+ <button
627
+ key={lvl}
628
+ onClick={() => setLogLevelFilter(lvl)}
629
+ className={`px-2 py-1 rounded-md text-[10px] font-bold transition ${
630
+ logLevelFilter === lvl
631
+ ? 'bg-slate-700 text-white'
632
+ : 'text-slate-500 hover:text-slate-300'
633
+ }`}
634
+ >
635
+ {lvl}
636
+ </button>
637
+ ))}
638
+ </div>
639
+ </div>
640
+ </div>
641
+
642
+ {/* Terminal Box */}
643
+ <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 flex flex-col space-y-2">
644
+ {logs.length === 0 ? (
645
+ <div className="m-auto text-slate-600 text-center italic">
646
+ [ Log buffer kosong / Menunggu aktivitas baru... ]
647
+ </div>
648
+ ) : (
649
+ logs.map((log, idx) => {
650
+ // Determine styling based on level
651
+ let levelColor = 'text-teal-400'
652
+ if (log.level === 'WARNING') levelColor = 'text-amber-400'
653
+ if (log.level === 'ERROR') levelColor = 'text-rose-400'
654
+
655
+ const timeStr = new Date(log.timestamp).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
656
+
657
+ return (
658
+ <div key={idx} className="leading-relaxed break-words hover:bg-white/[0.02] px-1 rounded transition-colors">
659
+ <span className="text-slate-600 select-none">[{timeStr}]</span>{' '}
660
+ <span className={`font-semibold ${levelColor} select-none`}>[{log.level}]</span>{' '}
661
+ <span className="text-slate-500 select-none">({log.source})</span>{' '}
662
+ <span className="text-slate-300">{log.message}</span>
663
+ </div>
664
+ )
665
+ })
666
+ )}
667
+ <div ref={logsEndRef} />
668
+ </div>
669
+
670
+ {/* Logs Footer status */}
671
+ <div className="mt-3 flex items-center justify-between text-[11px] text-slate-500 px-1">
672
+ <span className="flex items-center gap-1">
673
+ <span className="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse" /> Live polling aktif
674
+ </span>
675
+ <span>Total buffer: {logs.length} baris</span>
676
+ </div>
677
+ </div>
678
+
679
+ </main>
680
+
681
+ {/* --- STATS BREAKDOWN MODAL --- */}
682
+ {showStatsModal && selectedStats && (
683
+ <div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
684
+ <div className="glass-panel w-full max-w-2xl rounded-2xl p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
685
+
686
+ <button
687
+ onClick={() => setShowStatsModal(false)}
688
+ className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white rounded-lg transition"
689
+ >
690
+ <X className="w-5 h-5" />
691
+ </button>
692
+
693
+ <h3 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
694
+ <CheckCircle className="w-5 h-5 text-teal-400" /> Laporan Eksekusi Scraping
695
+ </h3>
696
+ <p className="text-xs text-slate-400 mb-6 pb-4 border-b border-white/5">
697
+ Breakdown lengkap hasil sinkronisasi harga komoditas dan pasar hari ini.
698
+ </p>
699
+
700
+ {/* Grid Summaries */}
701
+ <div className="grid grid-cols-4 gap-4 mb-6">
702
+ <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
703
+ <span className="text-[11px] text-slate-500 block">Insert Baru</span>
704
+ <span className="text-xl font-bold text-teal-400">{selectedStats.total_insert}</span>
705
+ </div>
706
+ <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
707
+ <span className="text-[11px] text-slate-500 block">Diperbarui</span>
708
+ <span className="text-xl font-bold text-indigo-400">{selectedStats.total_update}</span>
709
+ </div>
710
+ <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
711
+ <span className="text-[11px] text-slate-500 block">Data Lewat / Skip</span>
712
+ <span className="text-xl font-bold text-slate-400">{selectedStats.total_skip}</span>
713
+ </div>
714
+ <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
715
+ <span className="text-[11px] text-slate-500 block">Harga Rp 0</span>
716
+ <span className="text-xl font-bold text-amber-400">{selectedStats.total_harga_0}</span>
717
+ </div>
718
+ </div>
719
+
720
+ {/* Granular Lists */}
721
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
722
+
723
+ {/* Pasar Sukses */}
724
+ <div className="bg-slate-900/40 p-4 rounded-xl border border-white/5">
725
+ <span className="font-bold text-slate-300 block mb-2 text-[11px] uppercase tracking-wider text-teal-400 flex items-center gap-1.5">
726
+ <Check className="w-3.5 h-3.5" /> Pasar Tersinkronisasi ({selectedStats.pasar_berhasil?.length || 0})
727
+ </span>
728
+ {selectedStats.pasar_berhasil?.length > 0 ? (
729
+ <ul className="space-y-1 text-slate-300">
730
+ {selectedStats.pasar_berhasil.map((psr: string, i: number) => (
731
+ <li key={i} className="flex items-center gap-1.5">
732
+ <span className="w-1 h-1 rounded-full bg-teal-400" /> {psr}
733
+ </li>
734
+ ))}
735
+ </ul>
736
+ ) : (
737
+ <span className="text-slate-500 italic">- Tidak ada -</span>
738
+ )}
739
+ </div>
740
+
741
+ {/* Pasar Harga 0 */}
742
+ <div className="bg-slate-900/40 p-4 rounded-xl border border-white/5">
743
+ <span className="font-bold text-slate-300 block mb-2 text-[11px] uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
744
+ <AlertTriangle className="w-3.5 h-3.5" /> Pasar Harga Kosong/Nol ({selectedStats.pasar_harga_0?.length || 0})
745
+ </span>
746
+ {selectedStats.pasar_harga_0?.length > 0 ? (
747
+ <ul className="space-y-1 text-slate-400">
748
+ {selectedStats.pasar_harga_0.map((psr: string, i: number) => (
749
+ <li key={i} className="flex items-center gap-1.5">
750
+ <span className="w-1 h-1 rounded-full bg-amber-400" /> {psr}
751
+ </li>
752
+ ))}
753
+ </ul>
754
+ ) : (
755
+ <span className="text-slate-500 italic">- Tidak ada -</span>
756
+ )}
757
+ </div>
758
+
759
+ </div>
760
+
761
+ {/* Bottom info */}
762
+ <div className="mt-6 pt-4 border-t border-white/5 flex items-center justify-between text-slate-400 text-[11px]">
763
+ <span>Total Komoditas Diperbarui: <strong>{selectedStats.komoditas_berhasil_count || 0} item</strong></span>
764
+ <span>Komoditas Harga Nol: <strong>{selectedStats.komoditas_harga_0_count || 0} item</strong></span>
765
+ </div>
766
+
767
+ <div className="mt-6 text-right">
768
+ <button
769
+ onClick={() => setShowStatsModal(false)}
770
+ className="bg-slate-800 hover:bg-slate-700 text-slate-200 font-medium py-2 px-4 rounded-xl transition"
771
+ >
772
+ Tutup Jendela
773
+ </button>
774
+ </div>
775
+ </div>
776
+ </div>
777
+ )}
778
+
779
+ </div>
780
+ )
781
+ }
frontend/next.config.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
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
+
10
+ module.exports = nextConfig
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sikomo-scraper-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start"
9
+ },
10
+ "dependencies": {
11
+ "next": "14.1.0",
12
+ "react": "^18.2.0",
13
+ "react-dom": "^18.2.0",
14
+ "axios": "^1.6.5",
15
+ "lucide-react": "^0.312.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^20",
19
+ "@types/react": "^18",
20
+ "@types/react-dom": "^18",
21
+ "typescript": "^5",
22
+ "tailwindcss": "^3.4.0",
23
+ "autoprefixer": "^10.4.17",
24
+ "postcss": "^8.4.33"
25
+ }
26
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Placeholder to ensure public directory exists
frontend/tailwind.config.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: [
4
+ './app/**/*.{js,ts,jsx,tsx,mdx}',
5
+ ],
6
+ theme: {
7
+ extend: {},
8
+ },
9
+ plugins: [],
10
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules"]
27
+ }