StatistaAmeri/admin/admin_app.py

697 lines
35 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Didvan — standalone admin panel (independent FastAPI app, default port 8001).
Run: uvicorn admin_app:app --app-dir admin --port 8001
Auth: HTTP Basic. Set ADMIN_USER / ADMIN_PASSWORD env vars (defaults below).
Three sections:
- کاربران : registered users (name / email / phone) from the `users` table
- توکن منبع داده : view & update the scraping token (stored in app_config; sync.py reads it)
- سلامت سرویس‌ها : last-scrape recency per service + "test scrape" that validates the
SteelStatista token by fetching a sample symbol's latest price
"""
import os
import re
import secrets
import random
from datetime import datetime, timezone
import sqlite3
import requests
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Form, Query
from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel
DB_PATH = os.environ.get("DB_PATH", r"C:\Users\DATIS STAR\asianmetal.db")
# Where report PDFs live — must match the backend's REPORTS_DIR so uploads show
# up in the dashboard's reports tab (on the VPS both point at the shared /data disk).
REPORTS_DIR = os.environ.get("REPORTS_DIR", r"C:\Users\DATIS STAR\Desktop\asianmetal_reports")
MAX_PDF_BYTES = 30 * 1024 * 1024
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
# No insecure default — the admin panel exposes user PII + the data-source token,
# so a password MUST be provided via the environment (fail closed).
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
if not ADMIN_PASSWORD:
raise RuntimeError(
"ADMIN_PASSWORD environment variable is required (no default). "
"Set it before starting the admin app, e.g. $env:ADMIN_PASSWORD='a-strong-secret'."
)
DATA_API_BASE = "https://api.SteelStatista.com"
CATEGORY_IDS = [
9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53,
54, 55, 56, 57, 65, 67, 68, 70, 71, 72, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85,
87, 88, 89, 90, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105,
]
app = FastAPI(title="Didvan Admin")
security = HTTPBasic()
def require_admin(creds: HTTPBasicCredentials = Depends(security)) -> str:
ok = secrets.compare_digest(creds.username, ADMIN_USER) and secrets.compare_digest(
creds.password, ADMIN_PASSWORD
)
if not ok:
raise HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"})
return creds.username
def conn():
c = sqlite3.connect(DB_PATH, timeout=10)
c.row_factory = sqlite3.Row
c.execute("PRAGMA journal_mode=WAL")
return c
def _get_token():
try:
with conn() as c:
row = c.execute("SELECT value FROM app_config WHERE key='asianmetal_token'").fetchone()
if row and row["value"]:
return row["value"]
except Exception:
pass
return None
def _age_minutes(ts):
if not ts:
return None
try:
dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
return round((datetime.now(timezone.utc) - dt).total_seconds() / 60, 1)
except Exception:
return None
# ───────────────────────────── APIs ─────────────────────────────
@app.get("/api/users")
def api_users(_: str = Depends(require_admin)):
with conn() as c:
rows = c.execute(
"SELECT id, name, email, phone, organization, "
"COALESCE(status,'pending') AS status, created_at "
"FROM users ORDER BY (COALESCE(status,'pending')='pending') DESC, id DESC"
).fetchall()
return [dict(r) for r in rows]
def _set_user_status(uid: int, status: str, _: str = Depends(require_admin)):
with conn() as c:
cur = c.execute("UPDATE users SET status=? WHERE id=?", (status, uid))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
return {"ok": True, "id": uid, "status": status}
@app.post("/api/users/{uid}/approve")
def api_approve(uid: int, admin: str = Depends(require_admin)):
return _set_user_status(uid, "approved", admin)
@app.post("/api/users/{uid}/reject")
def api_reject(uid: int, admin: str = Depends(require_admin)):
# rejecting also kills any active session so access is revoked immediately
with conn() as c:
c.execute("DELETE FROM sessions WHERE user_id=?", (uid,))
return _set_user_status(uid, "rejected", admin)
class TokenIn(BaseModel):
token: str
@app.get("/api/token")
def api_token(_: str = Depends(require_admin)):
t = _get_token()
masked = (t[:14] + "" + t[-10:]) if t and len(t) > 30 else (t or "")
updated = None
try:
with conn() as c:
row = c.execute("SELECT value FROM app_config WHERE key='asianmetal_token_updated'").fetchone()
updated = row["value"] if row else None
except Exception:
pass
return {"set": bool(t), "masked": masked, "updated_at": updated}
@app.post("/api/token")
def api_set_token(data: TokenIn, _: str = Depends(require_admin)):
tok = (data.token or "").strip()
if not tok:
return {"ok": False, "error": "توکن خالی است"}
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
with conn() as c:
c.execute(
"INSERT INTO app_config(key,value) VALUES('asianmetal_token',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(tok,),
)
c.execute(
"INSERT INTO app_config(key,value) VALUES('asianmetal_token_updated',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(now,),
)
return {"ok": True, "updated_at": now}
def _count_change(c, table):
try:
row = c.execute(f"SELECT MAX(fetched_at) m, COUNT(*) n FROM {table}").fetchone()
return row["n"], row["m"]
except Exception:
return 0, None
@app.get("/api/health")
def api_health(_: str = Depends(require_admin)):
"""Service health. TGJU services use a heartbeat (last completed scrape cycle),
NOT MAX(fetched_at) — because dedup means the table only changes when a price
moves, so a quiet market would otherwise look like a dead scraper."""
out = []
with conn() as c:
hb = None
try:
row = c.execute("SELECT value FROM app_config WHERE key='tgju_last_scrape'").fetchone()
hb = row["value"] if row else None
except Exception:
pass
hb_age = _age_minutes(hb)
# TGJU bundle (currency/gold/coin) — freshness from the shared heartbeat
for table, label in [
("currency_prices", "بازار داخلی — ارز"),
("gold_prices", "بازار داخلی — طلا"),
("coin_prices", "بازار داخلی — سکه"),
]:
n, change = _count_change(c, table)
out.append({
"key": table, "label": label, "last": hb, "age_min": hb_age,
"count": n, "ok": hb_age is not None and hb_age <= 3, "changed": change,
})
# Commodity (Trading Economics) — its own scraper, table timestamp is fine
n, change = _count_change(c, "te_prices")
c_age = _age_minutes(change)
out.append({
"key": "commodity", "label": "کامودیتی", "last": change, "age_min": c_age,
"count": n, "ok": c_age is not None and c_age <= 90, "changed": change,
})
# Metals & steel — daily batch via sync.py (SteelStatista)
try:
row = c.execute("SELECT MAX(date) m, COUNT(*) n FROM prices").fetchone()
out.append({
"key": "metals", "label": "فلزات و فولاد (روزانه)", "last": row["m"],
"age_min": None, "count": row["n"], "ok": True, "daily": True,
})
except Exception:
pass
return out
def _yz_headers(tok):
return {
"Authorization": f"Bearer {tok}",
"accept": "application/json",
"lang": "fa",
"platform": "web",
"referer": "https://SteelStatista.com/",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
@app.post("/api/test-scrape")
def api_test_scrape(_: str = Depends(require_admin)):
"""Validate the data-source token by fetching latest prices for a random
sample of ~12 symbols across random categories."""
tok = _get_token()
if not tok:
return {"ok": False, "error": "توکنی تنظیم نشده — اول توکن را در تب «توکن» ذخیره کنید."}
headers = _yz_headers(tok)
try:
pool = []
for cid in random.sample(CATEGORY_IDS, min(6, len(CATEGORY_IDS))):
try:
r = requests.get(f"{DATA_API_BASE}/Symbol?categoryId={cid}", headers=headers, timeout=12)
if r.status_code == 401:
return {"ok": False, "error": "توکن منقضی یا نامعتبر است (401) — باید عوضش کنی."}
r.raise_for_status()
pool.extend(r.json() or [])
except Exception:
continue
if not pool:
return {"ok": False, "error": "هیچ نمادی برنگشت."}
items = []
for s in random.sample(pool, min(12, len(pool))):
try:
r2 = requests.get(f"{DATA_API_BASE}/Symbol/{s['id']}", headers=headers, timeout=12)
r2.raise_for_status()
prices = r2.json().get("symbolPrices", [])
if not prices:
continue
last = max(prices, key=lambda p: p["date"])
items.append({
"title": s.get("title"),
"date": last["date"][:10],
"mid": last.get("mid"),
"low": last.get("low"),
"high": last.get("high"),
})
except Exception:
continue
if not items:
return {"ok": False, "error": "قیمتی دریافت نشد."}
items.sort(key=lambda x: x["title"] or "")
return {"ok": True, "count": len(items), "items": items}
except Exception as e:
return {"ok": False, "error": f"خطا در اتصال به منبع داده: {e}"}
_DATASETS = {
"currency": ("currency_prices", "بازار داخلی — ارز", "fetched_at"),
"gold": ("gold_prices", "بازار داخلی — طلا/نقره", "fetched_at"),
"coin": ("coin_prices", "بازار داخلی — سکه", "fetched_at"),
"commodity": ("te_prices", "کامودیتی", "fetched_at"),
"metals": ("prices", "فلزات و فولاد", "date"),
}
@app.get("/api/reports")
def api_reports(_: str = Depends(require_admin)):
out = []
with conn() as c:
for key, (table, label, datecol) in _DATASETS.items():
try:
row = c.execute(
f"SELECT COUNT(*) n, MIN({datecol}) mn, MAX({datecol}) mx FROM {table}"
).fetchone()
out.append({"key": key, "label": label, "table": table,
"count": row["n"], "first": row["mn"], "last": row["mx"]})
except Exception:
out.append({"key": key, "label": label, "table": table,
"count": 0, "first": None, "last": None})
return out
@app.get("/api/export")
def api_export(dataset: str, _: str = Depends(require_admin)):
import csv
import io
from fastapi.responses import Response
if dataset not in _DATASETS:
raise HTTPException(status_code=404, detail="dataset not found")
table, _label, datecol = _DATASETS[dataset]
with conn() as c:
if dataset == "metals":
rows = c.execute(
"SELECT grp, category, title, date, low, mid, high FROM prices "
"WHERE date = (SELECT MAX(date) FROM prices) ORDER BY grp, category, title"
).fetchall()
else:
rows = c.execute(
f"SELECT * FROM {table} WHERE {datecol} = (SELECT MAX({datecol}) FROM {table})"
).fetchall()
buf = io.StringIO()
if rows:
w = csv.writer(buf)
w.writerow(rows[0].keys())
for r in rows:
w.writerow([r[k] for k in r.keys()])
return Response(
content="" + buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{dataset}_latest.csv"'},
)
# ─────────────────────── Report PDF upload ───────────────────────
def _ensure_api_usage_table(c):
c.execute("""
CREATE TABLE IF NOT EXISTS api_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partner TEXT,
token_hash TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
query TEXT,
status_code INTEGER,
client_ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL
)
""")
c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_usage_logs(created_at)")
c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_partner ON api_usage_logs(partner)")
c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_path ON api_usage_logs(path)")
@app.get("/api/api-usage")
def api_usage(limit: int = Query(100, ge=1, le=500), _: str = Depends(require_admin)):
with conn() as c:
_ensure_api_usage_table(c)
summary = c.execute("""
SELECT
COALESCE(partner, 'unknown') AS partner,
COUNT(*) AS requests,
SUM(CASE WHEN status_code BETWEEN 200 AND 399 THEN 1 ELSE 0 END) AS ok,
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors,
MAX(created_at) AS last_seen
FROM api_usage_logs
GROUP BY COALESCE(partner, 'unknown')
ORDER BY requests DESC, partner
""").fetchall()
rows = c.execute("""
SELECT id, partner, substr(token_hash, 1, 12) AS token_hash_prefix,
method, path, query, status_code, client_ip, user_agent, created_at
FROM api_usage_logs
ORDER BY id DESC
LIMIT ?
""", (limit,)).fetchall()
return {"summary": [dict(r) for r in summary], "rows": [dict(r) for r in rows]}
def _safe_folder(folder: str):
"""One path segment, no traversal, filesystem-safe."""
f = (folder or "").strip().replace("\\", "/").split("/")[0].strip(". ")
f = re.sub(r'[<>:"|?*\x00-\x1f]', "", f)
return f or None
@app.get("/api/report-folders")
def report_folders(_: str = Depends(require_admin)):
base = os.path.realpath(REPORTS_DIR)
if not os.path.isdir(base):
return {"dir": REPORTS_DIR, "folders": []}
out = []
for e in os.scandir(base):
if e.is_dir():
n = sum(1 for f in os.scandir(e.path) if f.is_file() and f.name.lower().endswith(".pdf"))
out.append({"name": e.name, "count": n})
out.sort(key=lambda x: x["name"])
return {"dir": REPORTS_DIR, "folders": out}
@app.post("/api/upload-report")
async def upload_report(folder: str = Form(...), file: UploadFile = File(...), _: str = Depends(require_admin)):
safe = _safe_folder(folder)
if not safe:
raise HTTPException(status_code=400, detail="نام پوشه نامعتبر است")
name = os.path.basename(file.filename or "")
if not name.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="فقط فایل PDF مجاز است")
name = re.sub(r"[^0-9A-Za-z ._()\-؀-ۿ]", "_", name)
data = await file.read()
if len(data) > MAX_PDF_BYTES:
raise HTTPException(status_code=400, detail="حجم فایل بیش از ۳۰ مگابایت است")
if data[:5] != b"%PDF-": # validate by content, not just extension
raise HTTPException(status_code=400, detail="محتوای فایل PDF معتبر نیست")
base = os.path.realpath(REPORTS_DIR)
dest_dir = os.path.realpath(os.path.join(base, safe))
if dest_dir != base and not dest_dir.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="مسیر نامعتبر")
os.makedirs(dest_dir, exist_ok=True)
with open(os.path.join(dest_dir, name), "wb") as fh:
fh.write(data)
return {"ok": True, "folder": safe, "name": name, "size": len(data)}
# ───────────────────────────── UI ─────────────────────────────
@app.get("/", response_class=HTMLResponse)
def home(_: str = Depends(require_admin)):
return _HTML
_HTML = """<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>پنل ادمین دیدوان</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<style>
:root{--bg:#f5f6fb;--surface:#fff;--ink:#1d2330;--muted:#5b6172;--border:#e4e6ef;--primary:#5b4bdb;--ok:#16a34a;--bad:#dc2626;--warn:#d97706;}
*{box-sizing:border-box}
body{margin:0;font-family:Vazirmatn,system-ui,sans-serif;background:var(--bg);color:var(--ink);}
header{background:var(--surface);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:10px;position:sticky;top:0;z-index:5}
.logo{width:34px;height:34px;border-radius:9px;background:rgba(91,75,219,.12);border:1px solid rgba(91,75,219,.35);display:grid;place-items:center;color:var(--primary);font-weight:700}
h1{font-size:16px;margin:0}
.sub{font-size:11px;color:var(--muted)}
.wrap{max-width:1000px;margin:0 auto;padding:24px}
.tabs{display:flex;gap:6px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:6px;width:fit-content;margin-bottom:20px}
.tab{padding:8px 18px;border:0;background:none;border-radius:8px;font:inherit;font-weight:600;color:var(--muted);cursor:pointer}
.tab.active{background:var(--primary);color:#fff}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin-bottom:16px;box-shadow:0 1px 2px rgba(16,24,40,.04)}
h2{font-size:15px;margin:0 0 14px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:right;padding:10px 12px;border-bottom:1px solid var(--border)}
th{font-size:11px;color:var(--muted);font-weight:600}
td.mono,th.mono{font-family:ui-monospace,monospace;direction:ltr;text-align:left}
.btn{background:var(--primary);color:#fff;border:0;border-radius:9px;padding:10px 18px;font:inherit;font-weight:600;cursor:pointer}
.btn:hover{filter:brightness(1.06)}
.btn.ghost{background:var(--surface);color:var(--ink);border:1px solid var(--border)}
textarea{width:100%;min-height:90px;border:1px solid var(--border);border-radius:10px;padding:12px;font-family:ui-monospace,monospace;font-size:12px;direction:ltr}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px}
.pill{font-size:11px;font-family:ui-monospace,monospace;padding:3px 9px;border-radius:999px;background:#eef0f8;color:var(--muted)}
.hidden{display:none}
.svc{display:flex;justify-content:space-between;align-items:center;padding:13px 0;border-bottom:1px solid var(--border)}
.svc:last-child{border-bottom:0}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-left:8px}
.dot.ok{background:var(--ok)} .dot.bad{background:var(--bad)} .dot.warn{background:var(--warn)}
.muted{color:var(--muted);font-size:12px}
.result{margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px}
.result.ok{background:#ecfdf3;color:#066d3a;border:1px solid #aee9c5}
.result.bad{background:#fef2f2;color:#a31515;border:1px solid #f3c0c0}
.empty{color:var(--muted);text-align:center;padding:30px}
</style>
</head>
<body>
<header>
<div class="logo">د</div>
<div><h1>پنل ادمین دیدوان</h1><div class="sub">سامانه جامع آمار و اطلاعات</div></div>
</header>
<div class="wrap">
<div class="tabs">
<button class="tab active" data-tab="users">کاربران</button>
<button class="tab" data-tab="token">توکن منبع داده</button>
<button class="tab" data-tab="health">سلامت سرویس‌ها</button>
<button class="tab" data-tab="reports">گزارشات</button>
<button class="tab" data-tab="apiusage">مصرف API</button>
<button class="tab" data-tab="upload">آپلود گزارش</button>
</div>
<section id="users" class="tabpane">
<div class="card">
<h2>کاربران ثبت‌نام‌شده <span id="ucount" class="pill"></span></h2>
<div id="utable"><div class="empty">در حال بارگذاری…</div></div>
</div>
</section>
<section id="token" class="tabpane hidden">
<div class="card">
<h2>توکن منبع داده</h2>
<p class="muted">توکن هر ۴ روز یک‌بار عوض می‌شود. توکن جدید را اینجا ذخیره کن تا اسکرپ فلزات و فولاد ادامه پیدا کند.</p>
<div class="row"><span class="pill">وضعیت: <b id="tkset">—</b></span><span class="pill" id="tkupd"></span></div>
<div class="row"><code class="pill" id="tkmask" style="direction:ltr"></code></div>
<textarea id="tkinput" placeholder="توکن جدید را اینجا بچسبان…"></textarea>
<div class="row">
<button class="btn" id="tksave">ذخیره توکن</button>
<button class="btn ghost" id="tktest">تست توکن (آخرین قیمت)</button>
</div>
<div id="tkresult"></div>
</div>
</section>
<section id="health" class="tabpane hidden">
<div class="card">
<h2>پایداری سرویس‌ها</h2>
<p class="muted">آخرین زمان اسکرپ هر سرویس. سبز = تازه، قرمز = قدیمی/متوقف.</p>
<div id="svclist"><div class="empty">در حال بررسی…</div></div>
<div class="row"><button class="btn ghost" id="hrefresh">بررسی مجدد</button>
<button class="btn" id="htest">تست اسکرپ آخرین قیمت</button></div>
<div id="hresult"></div>
</div>
</section>
<section id="reports" class="tabpane hidden">
<div class="card">
<h2>گزارش دیتاست‌های اسکرپ‌شده</h2>
<p class="muted">داده‌ها در دیتابیس ذخیره می‌شوند (نه فایل). از اینجا خروجی CSV (آخرین تاریخ) بگیر.</p>
<div id="rlist"><div class="empty">در حال بارگذاری…</div></div>
</div>
</section>
<section id="apiusage" class="tabpane hidden">
<div class="card">
<h2>مصرف API</h2>
<p class="muted">درخواست‌های `/api/v1` بر اساس پارتنر، مسیر، وضعیت و IP. توکن خام ذخیره یا نمایش داده نمی‌شود.</p>
<div id="apiusage-summary"><div class="empty">در حال بارگذاری…</div></div>
<div id="apiusage-list"></div>
<div class="row"><button class="btn ghost" id="apiusage-refresh">به‌روزرسانی</button></div>
</div>
</section>
<section id="upload" class="tabpane hidden">
<div class="card">
<h2>آپلود گزارش PDF</h2>
<p class="muted">منبع و دسته را انتخاب کن، فایل PDF را بده و آپلود — بدون تایپ.</p>
<div class="row">
<label class="muted" style="min-width:46px">منبع</label>
<select id="upsource" style="flex:1;min-width:200px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font:inherit"></select>
</div>
<div class="row">
<label class="muted" style="min-width:46px">دسته</label>
<select id="upcat" style="flex:1;min-width:200px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font:inherit"></select>
</div>
<div class="row">
<input id="upfile" type="file" accept="application/pdf" style="flex:1">
<button class="btn" id="upbtn">آپلود</button>
</div>
<div id="upresult"></div>
</div>
</section>
</div>
<script>
const $=s=>document.querySelector(s);
async function api(path,opts){const r=await fetch(path,opts);return r.json();}
function fmt(n){return n==null?'':new Intl.NumberFormat('fa-IR').format(n);}
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.tabpane').forEach(x=>x.classList.add('hidden'));
t.classList.add('active');$('#'+t.dataset.tab).classList.remove('hidden');
if(t.dataset.tab==='token')loadToken();
if(t.dataset.tab==='health')loadHealth();
if(t.dataset.tab==='reports')loadReports();
if(t.dataset.tab==='apiusage')loadApiUsage();
if(t.dataset.tab==='upload')loadFolders();
});
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function statusBadge(s){const m={pending:['در انتظار','#b45309','#fef3c7'],approved:['تأییدشده','#047857','#d1fae5'],rejected:['ردشده','#b91c1c','#fee2e2']}[s]||['','#555','#eee'];return `<span style="padding:2px 8px;border-radius:999px;font-size:12px;color:${m[1]};background:${m[2]}">${m[0]}</span>`;}
async function setStatus(id,action){
try{const r=await api('/api/users/'+id+'/'+action,{method:'POST'});if(r&&r.ok)loadUsers();}catch(e){}
}
async function loadUsers(){
try{const u=await api('/api/users');
const pend=u.filter(r=>(r.status||'pending')==='pending').length;
$('#ucount').textContent=u.length+' نفر'+(pend?(' · '+pend+' در انتظار'):'');
if(!u.length){$('#utable').innerHTML='<div class="empty">هنوز کسی ثبت‌نام نکرده است.</div>';return;}
let h='<table><thead><tr><th>#</th><th>نام</th><th>سازمان</th><th class="mono">ایمیل</th><th class="mono">موبایل</th><th>وضعیت</th><th>تاریخ</th><th>اقدام</th></tr></thead><tbody>';
u.forEach((r,i)=>{const s=r.status||'pending';let act='';
if(s!=='approved')act+=`<button class="btn" style="padding:4px 10px" onclick="setStatus(${r.id},'approve')">تأیید</button> `;
if(s!=='rejected')act+=`<button class="btn ghost" style="padding:4px 10px" onclick="setStatus(${r.id},'reject')">رد</button>`;
h+=`<tr><td>${i+1}</td><td>${esc(r.name)||''}</td><td>${esc(r.organization)||''}</td><td class="mono">${esc(r.email)||''}</td><td class="mono">${esc(r.phone)||''}</td><td>${statusBadge(s)}</td><td class="muted">${esc((r.created_at||'').replace('T',' ').replace('Z',''))}</td><td>${act}</td></tr>`;});
h+='</tbody></table>';$('#utable').innerHTML=h;
}catch(e){$('#utable').innerHTML='<div class="empty">خطا در بارگذاری کاربران.</div>';}
}
async function loadToken(){
try{const t=await api('/api/token');
$('#tkset').textContent=t.set?'تنظیم‌شده':'تنظیم نشده';
$('#tkmask').textContent=t.masked||'';
$('#tkupd').textContent=t.updated_at?('آخرین تغییر: '+t.updated_at.replace('T',' ').replace('Z','')):'';
}catch(e){}
}
$('#tksave').onclick=async()=>{
const tok=$('#tkinput').value.trim();if(!tok)return;
$('#tkresult').innerHTML='';
try{const r=await api('/api/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:tok})});
if(r.ok){$('#tkinput').value='';loadToken();$('#tkresult').innerHTML='<div class="result ok">توکن ذخیره شد. حالا «تست توکن» را بزن.</div>';}
else $('#tkresult').innerHTML='<div class="result bad">'+(r.error||'خطا')+'</div>';
}catch(e){$('#tkresult').innerHTML='<div class="result bad">خطا در ذخیره.</div>';}
};
$('#tktest').onclick=()=>runTest('#tkresult');
$('#htest').onclick=()=>runTest('#hresult');
async function runTest(target){
$(target).innerHTML='<div class="result">در حال تست… (چند ثانیه طول می‌کشد)</div>';
try{const r=await api('/api/test-scrape',{method:'POST'});
if(r.ok&&r.items){
let h='<div class="result ok">✅ توکن سالم است — '+r.count+' مورد رندوم در آخرین تاریخ:</div>';
h+='<table style="margin-top:10px"><thead><tr><th>کالا</th><th>تاریخ</th><th class="mono">میانگین</th><th class="mono">کف</th><th class="mono">سقف</th></tr></thead><tbody>';
r.items.forEach(it=>{h+='<tr><td>'+(it.title||'')+'</td><td class="muted">'+(it.date||'')+'</td><td class="mono">'+fmt(it.mid)+'</td><td class="mono">'+fmt(it.low)+'</td><td class="mono">'+fmt(it.high)+'</td></tr>';});
h+='</tbody></table>';
$(target).innerHTML=h;
} else if(r.ok){$(target).innerHTML='<div class="result ok">✅ توکن سالم است.'+(r.note?(' '+r.note):'')+'</div>';}
else $(target).innerHTML='<div class="result bad">❌ '+(r.error||'ناموفق')+'</div>';
}catch(e){$(target).innerHTML='<div class="result bad">❌ خطا در تست.</div>';}
}
async function loadReports(){
try{const d=await api('/api/reports');
let h='<table><thead><tr><th>دیتاست</th><th>رکوردها</th><th>از</th><th>تا</th><th>خروجی</th></tr></thead><tbody>';
d.forEach(s=>{h+='<tr><td><b>'+s.label+'</b> <span class="muted" style="direction:ltr">'+s.table+'</span></td><td>'+fmt(s.count)+'</td><td class="muted">'+(s.first||'')+'</td><td class="muted">'+(s.last||'')+'</td><td><a class="btn ghost" style="padding:5px 12px" href="/api/export?dataset='+s.key+'">CSV</a></td></tr>';});
h+='</tbody></table>';$('#rlist').innerHTML=h;
}catch(e){$('#rlist').innerHTML='<div class="empty">خطا در بارگذاری گزارش.</div>';}
}
async function loadApiUsage(){
try{const d=await api('/api/api-usage?limit=100');
let s='<table><thead><tr><th>پارتنر</th><th>درخواست‌ها</th><th>موفق</th><th>خطا</th><th>آخرین استفاده</th></tr></thead><tbody>';
(d.summary||[]).forEach(r=>{s+='<tr><td><b>'+(r.partner||'unknown')+'</b></td><td>'+fmt(r.requests)+'</td><td>'+fmt(r.ok)+'</td><td>'+fmt(r.errors)+'</td><td class="muted">'+(r.last_seen||'')+'</td></tr>';});
s+='</tbody></table>';
$('#apiusage-summary').innerHTML=(d.summary&&d.summary.length)?s:'<div class="empty">هنوز مصرفی ثبت نشده است.</div>';
let h='<table style="margin-top:14px"><thead><tr><th>زمان</th><th>پارتنر</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th class="mono">IP</th><th>مرورگر/کلاینت</th></tr></thead><tbody>';
(d.rows||[]).forEach(r=>{
const q=r.query?('?'+r.query):'';
const ok=(r.status_code||0)<400;
h+='<tr><td class="muted">'+(r.created_at||'')+'</td><td>'+(r.partner||'unknown')+'</td><td class="mono">'+(r.method||'')+'</td><td class="mono">'+esc((r.path||'')+q)+'</td><td><span class="pill" style="color:'+(ok?'var(--ok)':'var(--bad)')+'">'+(r.status_code||'')+'</span></td><td class="mono">'+esc(r.client_ip||'')+'</td><td class="muted">'+esc(r.user_agent||'')+'</td></tr>';
});
h+='</tbody></table>';
$('#apiusage-list').innerHTML=(d.rows&&d.rows.length)?h:'';
}catch(e){
$('#apiusage-summary').innerHTML='<div class="empty">خطا در بارگذاری مصرف API.</div>';
$('#apiusage-list').innerHTML='';
}
}
async function loadHealth(){
try{const h=await api('/api/health');
let html='';
h.forEach(s=>{
const cls=s.daily?'warn':(s.ok?'ok':'bad');
const when=s.daily?('آخرین تاریخ: '+(s.last||'')):(s.last?(`${s.age_min} دقیقه پیش`):'بدون داده');
const status=s.daily?'روزانه (دستی)':(s.ok?'تازه':'قدیمی/متوقف');
html+=`<div class="svc"><div><span class="dot ${cls}"></span><b>${s.label}</b> <span class="muted">(${fmt(s.count)} رکورد)</span></div><div class="muted">${status} · ${when}</div></div>`;
});
$('#svclist').innerHTML=html||'<div class="empty">داده‌ای نیست.</div>';
}catch(e){$('#svclist').innerHTML='<div class="empty">خطا در بررسی.</div>';}
}
$('#hrefresh').onclick=loadHealth;
$('#apiusage-refresh').onclick=loadApiUsage;
let FOLDERS=[];
async function loadFolders(){
try{const d=await api('/api/report-folders'); FOLDERS=d.folders||[];
FOLDERS.forEach(f=>{const i=f.name.indexOf(' - '); f.source=i>=0?f.name.slice(0,i):f.name; f.category=i>=0?f.name.slice(i+3):f.name;});
const sources=[...new Set(FOLDERS.map(f=>f.source))].sort();
$('#upsource').innerHTML=sources.length?sources.map(s=>`<option>${esc(s)}</option>`).join(''):'<option value="">(پوشه‌ای نیست)</option>';
fillCats();
}catch(e){}
}
function fillCats(){
const s=$('#upsource').value;
const cats=FOLDERS.filter(f=>f.source===s);
$('#upcat').innerHTML=cats.map(f=>`<option value="${esc(f.name)}">${esc(f.category)} (${f.count})</option>`).join('');
}
$('#upsource').onchange=fillCats;
$('#upbtn').onclick=async()=>{
const folder=$('#upcat').value; const f=$('#upfile').files[0];
if(!folder){$('#upresult').innerHTML='<div class="result bad">پوشه‌ای برای انتخاب نیست.</div>';return;}
if(!f){$('#upresult').innerHTML='<div class="result bad">یک فایل PDF انتخاب کن.</div>';return;}
const fd=new FormData(); fd.append('folder',folder); fd.append('file',f);
$('#upresult').innerHTML='<div class="result">در حال آپلود…</div>';
try{const r=await fetch('/api/upload-report',{method:'POST',body:fd}); const j=await r.json();
if(r.ok&&j.ok){$('#upresult').innerHTML='<div class="result ok">✅ آپلود شد: '+esc(j.name)+''+esc(j.folder)+'</div>'; $('#upfile').value=''; loadFolders();}
else $('#upresult').innerHTML='<div class="result bad">❌ '+esc(j.detail||j.error||'خطا')+'</div>';
}catch(e){$('#upresult').innerHTML='<div class="result bad">❌ خطا در آپلود.</div>';}
};
loadUsers();
</script>
</body>
</html>"""