697 lines
35 KiB
Python
697 lines
35 KiB
Python
"""
|
||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>"""
|