Spaces:
Sleeping
Sleeping
anoderb commited on
Commit ·
4b529fd
0
Parent(s):
Fix: clean_harga logic and rename functions to match traceback
Browse files- .dockerignore +25 -0
- .gitignore +30 -0
- Dockerfile +46 -0
- README.md +23 -0
- backend/app/__init__.py +0 -0
- backend/app/config.py +33 -0
- backend/app/database.py +13 -0
- backend/app/logger.py +44 -0
- backend/app/main.py +143 -0
- backend/app/scheduler.py +133 -0
- backend/app/scraper.py +283 -0
- backend/requirements.txt +13 -0
- frontend/app/globals.css +55 -0
- frontend/app/layout.tsx +24 -0
- frontend/app/page.tsx +781 -0
- frontend/next.config.js +10 -0
- frontend/package.json +26 -0
- frontend/postcss.config.js +6 -0
- frontend/public/.gitkeep +1 -0
- frontend/tailwind.config.js +10 -0
- frontend/tsconfig.json +27 -0
.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 |
+
}
|