"""
app.py — ETP Distributed Scraper: Control Plane v5.1 (Item-Level Dashboard + Hash Status + Refresher Lock)
FastAPI + HTML/JS.
KEY CHANGES FROM v5:
1. DASHBOARD: Uses fixed get_dashboard_item_stats() RPC
- success_items and failed_items counted for ALL chunk statuses (not just completed/failed)
- This ensures: done + pending = total ALWAYS HOLDS
- No more 1,000-row .select() limit
- Mathematically perfect item-level progress
2. HASH STATUS: Shows current auth_hash status from system_tokens
- When it was last refreshed
- Whether it's active
3. REFRESHER LOCK STATUS: Shows which hash_refresher instance is active
- Instance ID
- Last heartbeat time
- If no refresher is running, shows warning
4. Removed direct sub_tasks .select() — all stats via RPC
"""
import os, time, threading, logging
from datetime import datetime, timezone
from supabase import create_client, Client
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
import uvicorn
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("ControlPlane")
SUPABASE_URL = os.environ["SUPABASE_URL"]
SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
SURGEON_TIMEOUT = int(os.environ.get("SURGEON_TIMEOUT_MINUTES", "5"))
WATCHDOG_INTERVAL = 300
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
app = FastAPI()
SERIES_MAP = {"0": "QL", "1": "ST", "2": "ML"}
# ─── BACKGROUND SURGEON (Auto-Cleaner) ──────────────────────────────────
def watchdog_loop():
log.info("Watchdog started. Will permanently delete dead workers every 5 mins.")
while True:
try:
res = supabase.rpc("surgeon_recover_dead_tasks", {"p_timeout_minutes": SURGEON_TIMEOUT}).execute()
recovered = res.data or 0
if recovered > 0:
log.info(f"Surgeon recovered {recovered} stuck chunks and wiped dead workers.")
except Exception as e:
log.error(f"Surgeon error: {e}")
time.sleep(WATCHDOG_INTERVAL)
threading.Thread(target=watchdog_loop, daemon=True).start()
# ─── API ROUTES ────────────────────────────────────────────────
@app.get("/api/dashboard")
def api_dashboard():
# 1. Fetch tasks (few rows, no limit issue)
tasks = supabase.table("main_tasks").select("*").order("priority").order("created_at").execute().data or []
# 2. Fetch item-level stats via RPC (server-side aggregation, no row limit)
# Fixed RPC: success_items + failed_items counted for ALL statuses
# This ensures: done + pending = total ALWAYS
stats_data = supabase.rpc("get_dashboard_item_stats").execute().data or []
stats_map = {}
for s in stats_data:
stats_map[s["main_task_id"]] = s
# 3. Compute global item-level stats
global_stats = {"total_items": 0, "pending_items": 0, "in_progress_items": 0, "success_items": 0, "failed_items": 0}
for t in tasks:
s = stats_map.get(t["id"], {})
t["item_stats"] = {
"total_items": s.get("total_items", 0) or 0,
"success_items": s.get("success_items", 0) or 0,
"failed_items": s.get("failed_items", 0) or 0,
"in_progress_items": s.get("in_progress_items", 0) or 0,
"pending_items": s.get("pending_items", 0) or 0,
}
for key in global_stats:
global_stats[key] += t["item_stats"].get(key, 0)
# 4. Fetch and filter live workers (HIDE IF DEAD > 5 MINS)
raw_workers = supabase.table("workers").select("*").order("started_at", desc=True).execute().data or []
live_workers = []
now_utc = datetime.now(timezone.utc)
for w in raw_workers:
try:
hb_str = w["last_heartbeat"].replace("Z", "+00:00")
hb_time = datetime.fromisoformat(hb_str)
seconds_dead = (now_utc - hb_time).total_seconds()
if seconds_dead <= 300:
w["seconds_dead"] = int(seconds_dead)
live_workers.append(w)
except Exception:
pass
# 5. Fetch System State
sys_config = supabase.table("system_config").select("*").eq("id", 1).execute().data
global_paused = sys_config[0]["is_globally_paused"] if sys_config else False
# 6. Fetch Auth Hash Status
hash_info = None
try:
hash_res = supabase.rpc("get_active_auth_hash").execute()
if hash_res.data and len(hash_res.data) > 0:
hash_info = hash_res.data[0]
except Exception:
pass
# 7. Fetch Refresher Lock Status
refresher_info = None
if sys_config:
sc = sys_config[0]
if sc.get("active_refresher_id"):
refresher_info = {
"instance_id": sc["active_refresher_id"],
"last_heartbeat": sc.get("refresher_last_heartbeat"),
}
return JSONResponse({
"tasks": tasks,
"workers": live_workers,
"global_stats": global_stats,
"hash_info": hash_info,
"refresher_info": refresher_info,
"global_paused": global_paused
})
@app.post("/api/global-pause")
async def api_global_pause(request: Request):
b = await request.json()
is_paused = bool(b.get("paused", False))
supabase.rpc("set_global_pause", {"p_paused": is_paused}).execute()
return JSONResponse({"ok": True, "paused": is_paused})
@app.post("/api/create-task")
async def api_create_task(request: Request):
try:
b = await request.json()
start_id = str(int(b["start_id"])).zfill(10)
end_id = str(int(b["end_id"])).zfill(10)
priority = int(b.get("priority", 100))
if start_id[:3] != end_id[:3]:
return JSONResponse({"ok": False, "error": "First 3 digits must match (year+series)."}, status_code=400)
year = f"20{start_id[:2]}"
series_digit = start_id[2]
series_label = SERIES_MAP.get(series_digit, series_digit)
series_name = f"{series_digit} - {series_label}"
supabase.table("main_tasks").insert({
"target_url_type": "eKhanij",
"series_name": series_name,
"start_etp_id": int(start_id),
"end_etp_id": int(end_id),
"chunk_size": int(b.get("chunk_size", 5000)),
"concurrency": int(b.get("concurrency", 300)),
"priority": priority
}).execute()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
@app.post("/api/tasks/{task_id}/action")
async def api_task_action(task_id: str, request: Request):
b = await request.json()
action = b.get("action")
try:
if action == "pause":
supabase.rpc("pause_main_task", {"p_main_id": task_id}).execute()
elif action == "resume":
supabase.rpc("resume_main_task", {"p_main_id": task_id}).execute()
elif action == "delete":
supabase.table("main_tasks").delete().eq("id", task_id).execute()
elif action == "priority":
supabase.rpc("reorder_task_priority", {"p_main_id": task_id, "p_new_priority": int(b.get("priority", 100))}).execute()
elif action == "retry":
supabase.table("sub_tasks").update({"status": "pending", "assigned_worker_id": None})\
.eq("status", "failed").eq("main_task_id", task_id).execute()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
@app.post("/api/surgeon")
def api_surgeon():
res = supabase.rpc("surgeon_recover_dead_tasks", {"p_timeout_minutes": 5}).execute()
return JSONResponse({"ok": True, "recovered": res.data or 0})
# ─── FRONTEND HTML (PREMIUM UI — ITEM-LEVEL STATS + REFRESHER LOCK) ────────
HTML_CONTENT = r"""
ETP Command Center
📊 Live Dashboard
➕ Manage Tasks
🤖 Launch Workers
Integrity Check:
Done: 0
+
Pending: 0
+
Active: 0
=
Total: 0
Tasks process strictly in order of Priority (Priority 1 processes before Priority 100).
If a chunk was already processed in the past, workers will automatically skip it (Delta Recovery).
🚀 Add to Queue
This MUST be running before workers can operate. It refreshes the auth_hash every 5 minutes and syncs the Data Lake.
Distributed Lock: If you accidentally start multiple instances, only the LATEST one runs — all older ones self-terminate.
# 1. Install required packages silently
!pip install -q supabase requests huggingface_hub urllib3 lxml
import os, urllib.request
# 2. Pipeline Credentials
os.environ["SUPABASE_URL"] = "YOUR_SUPABASE_URL"
os.environ["SUPABASE_SERVICE_KEY"] = "YOUR_SUPABASE_KEY"
os.environ["HF_TOKEN"] = "YOUR_HF_TOKEN"
os.environ["HF_REPO_ID"] = "YOUR_REPO"
# 3. Download and run hash refresher
REPO_NAME = "YOUR_REPO_NAME"
urllib.request.urlretrieve(f"https://raw.githubusercontent.com/YOUR_GITHUB/{REPO_NAME}/main/hash_refresher.py", "hash_refresher.py")
!python hash_refresher.py
Workers are now thin and stateless. They read the auth_hash from the database and perform 1-step GETs. No more heavy boot sync. No more race conditions. Launch as many as you want simultaneously.
# 1. Install required packages silently
!pip install -q supabase requests beautifulsoup4 huggingface_hub urllib3 lxml
import os, urllib.request
# 2. Pipeline Credentials
os.environ["SUPABASE_URL"] = "YOUR_SUPABASE_URL"
os.environ["SUPABASE_SERVICE_KEY"] = "YOUR_SUPABASE_KEY"
os.environ["HF_TOKEN"] = "YOUR_HF_TOKEN"
os.environ["HF_REPO_ID"] = "YOUR_REPO"
# 3. Download and run thin worker
REPO_NAME = "YOUR_REPO_NAME"
urllib.request.urlretrieve(f"https://raw.githubusercontent.com/YOUR_GITHUB/{REPO_NAME}/main/worker.py", "worker.py")
!python worker.py
"""
@app.get("/", response_class=HTMLResponse)
def root():
return HTML_CONTENT
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860)