ccsite-manager: fleet orchestrator, BTCPay auto-settlement, watchdog, dashboard
This commit is contained in:
646
dashboard.py
Normal file
646
dashboard.py
Normal file
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CCSite Fleet Dashboard — local aggregator running on MacBook.
|
||||
Polls all 10 shop sites + BTCPay Server and displays real-time stats.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
import requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# Import watchdog for health monitoring
|
||||
try:
|
||||
from watchdog import get_state as get_watchdog_state, run_all_checks as watchdog_scan
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
|
||||
SITES = {
|
||||
1: {"name": "VaultPass", "ip": "10.30.20.210", "emoji": "🎮"},
|
||||
2: {"name": "StreamPass", "ip": "10.30.20.211", "emoji": "🎬"},
|
||||
3: {"name": "KeyForge", "ip": "10.30.20.212", "emoji": "💻"},
|
||||
4: {"name": "SocialForge", "ip": "10.30.20.213", "emoji": "🐦"},
|
||||
5: {"name": "CardVault", "ip": "10.30.20.214", "emoji": "💳"},
|
||||
6: {"name": "CloudNexus", "ip": "10.30.20.217", "emoji": "☁️"},
|
||||
7: {"name": "LearnForge", "ip": "10.30.20.218", "emoji": "📚"},
|
||||
8: {"name": "PrivacyPass", "ip": "10.30.20.219", "emoji": "🛡️"},
|
||||
9: {"name": "CreatorForge", "ip": "10.30.20.220", "emoji": "🎨"},
|
||||
10: {"name": "FoodVault", "ip": "10.30.20.221", "emoji": "🍕"},
|
||||
}
|
||||
|
||||
# BTCPay config
|
||||
BTCPAY_URL = "https://10.30.20.140"
|
||||
BTCPAY_KEY = "6026288e2e315984661c748baafd509e81a75f22"
|
||||
|
||||
# Per-site BTCPay store IDs
|
||||
BTCPAY_STORES = {
|
||||
1: "AYZWEAt63TgY4QaxwNBawHASMKcimJabcZxSczRqZG81",
|
||||
2: "5pi94zmY3F26pTZJ99JfsFG9pb8atbz7fpALrPjS2BRq",
|
||||
3: "3mc58myPrkdN7hRFdsBwJxbVgBQQ3VvSYZygKQC3FiSG",
|
||||
4: "E1uZ5nmU6qSpMB8sna7fNdxZFiem9JmkxB9LdJkFN23B",
|
||||
5: "BcvaUvv8MM8if5dzqe5yLnNERG6EwjjmeMok54ATqjWf",
|
||||
6: "GvSsA9xaqymhswsiPo4APHMuT2sXCCtnBLgPWcHigxVx",
|
||||
7: "A2TXbpkpj88Wtj34dLS9yzjMgmcodysEaRhTjc3rFcUr",
|
||||
8: "9N8dTC99uXn3W3e2F9o8kVQ5ewijh87NevKmMATF1Jcd",
|
||||
9: "39BKRF3zQb6Gi8LiKo1US13bzJfhpCzkwoHP6pCyQFwD",
|
||||
10: "9vCjKrKbwgey4GH7DBAMa6FgkiApgHvd5khFpWSZzJL2",
|
||||
}
|
||||
|
||||
DB_PATH = "/Users/drjones/ccsite-manager/dashboard.db"
|
||||
BTC_ADDRESS = "35vTSjPCaqudcL5cj5ChpNDmqrpPFmEnss"
|
||||
app = Flask(__name__, static_folder='/Users/drjones/ccsite-manager', static_url_path='')
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER,
|
||||
timestamp REAL,
|
||||
users INTEGER,
|
||||
products INTEGER,
|
||||
orders INTEGER,
|
||||
revenue REAL,
|
||||
status TEXT,
|
||||
response_ms REAL
|
||||
)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER,
|
||||
timestamp REAL,
|
||||
event_type TEXT,
|
||||
detail TEXT
|
||||
)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS btcpay_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL,
|
||||
sync_pct REAL,
|
||||
blocks INTEGER,
|
||||
headers INTEGER,
|
||||
synced INTEGER,
|
||||
store_count INTEGER,
|
||||
stores_with_wallet INTEGER,
|
||||
total_invoices INTEGER
|
||||
)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS site_orders (
|
||||
site_id INTEGER, order_id INTEGER, product TEXT, amount REAL, qty INTEGER,
|
||||
status TEXT, email TEXT, code TEXT, date TEXT, ts REAL,
|
||||
PRIMARY KEY (site_id, order_id)
|
||||
)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS site_users (
|
||||
site_id INTEGER, user_id INTEGER, username TEXT, email TEXT, membership TEXT,
|
||||
balance REAL, orders INTEGER, joined TEXT, ts REAL,
|
||||
PRIMARY KEY (site_id, user_id)
|
||||
)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS site_txs (
|
||||
site_id INTEGER, tx_id INTEGER, user TEXT, amount REAL, method TEXT,
|
||||
type TEXT, date TEXT,
|
||||
PRIMARY KEY (site_id, tx_id)
|
||||
)''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_orders_date ON site_orders(date DESC)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_users_joined ON site_users(joined DESC)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_snap_site_time ON snapshots(site_id, timestamp)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_events_time ON events(timestamp DESC)')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# In-memory current state (updated by poller)
|
||||
state = {"sites": {}, "totals": {}, "events": [], "last_poll": None, "btcpay": {}}
|
||||
state_lock = threading.Lock()
|
||||
|
||||
def poll_site(site_id, info):
|
||||
"""Poll a single site for stats."""
|
||||
try:
|
||||
t0 = time.time()
|
||||
r = requests.get(f"http://{info['ip']}:5000/health", timeout=5)
|
||||
ms = (time.time() - t0) * 1000
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return {
|
||||
"site_id": site_id,
|
||||
"name": info["name"], "emoji": info["emoji"], "ip": info["ip"],
|
||||
"status": "online", "response_ms": round(ms),
|
||||
"users": data.get("users", 0),
|
||||
"products": data.get("products", 0),
|
||||
"site_name": data.get("site", "?"),
|
||||
"orders": data.get("orders_total", 0),
|
||||
"orders_today": data.get("orders_today", 0),
|
||||
"revenue": data.get("revenue_total", 0),
|
||||
"revenue_today": data.get("revenue_today", 0),
|
||||
"signups_today": data.get("signups_today", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"site_id": site_id, "name": info["name"], "emoji": info["emoji"], "ip": info["ip"],
|
||||
"status": "offline", "response_ms": 0, "users": 0, "products": 0, "site_name": "?",
|
||||
"orders": 0, "orders_today": 0, "revenue": 0, "revenue_today": 0, "signups_today": 0,
|
||||
"error": str(e)[:100],
|
||||
}
|
||||
|
||||
def poll_site_details(site_id, info):
|
||||
"""Fetch /api/dashboard-data from a shop and upsert orders/users/txs.
|
||||
Returns per-site detail summary or None on failure."""
|
||||
try:
|
||||
r = requests.get(f"http://{info['ip']}:5000/api/dashboard-data", timeout=8)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
now = time.time()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
for o in data.get("orders", []):
|
||||
c.execute("""INSERT OR REPLACE INTO site_orders
|
||||
(site_id, order_id, product, amount, qty, status, email, code, date, ts)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(site_id, o.get("id"), o.get("product", "?"), o.get("amount", 0),
|
||||
o.get("qty", 1), o.get("status", "?"), o.get("email", ""),
|
||||
o.get("code", ""), o.get("date", ""), now))
|
||||
for u in data.get("users", []):
|
||||
c.execute("""INSERT OR REPLACE INTO site_users
|
||||
(site_id, user_id, username, email, membership, balance, orders, joined, ts)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(site_id, u.get("id"), u.get("username", "?"), u.get("email", ""),
|
||||
u.get("membership", "free"), u.get("balance", 0), u.get("orders", 0),
|
||||
u.get("joined", ""), now))
|
||||
for t in data.get("transactions", []):
|
||||
c.execute("""INSERT OR REPLACE INTO site_txs
|
||||
(site_id, tx_id, user, amount, method, type, date)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(site_id, t.get("id"), t.get("user", "?"), t.get("amount", 0),
|
||||
t.get("method", ""), t.get("type", ""), t.get("date", "")))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
orders = data.get("orders", [])
|
||||
pending = sum(1 for o in orders if o.get("status") == "pending")
|
||||
return {
|
||||
"pending_orders": pending,
|
||||
"delivered_orders": sum(1 for o in orders if o.get("status") == "delivered"),
|
||||
"memberships": {u.get("membership", "free"): True for u in data.get("users", [])},
|
||||
"balance_total": round(sum(u.get("balance", 0) for u in data.get("users", [])), 2),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def poll_btcpay():
|
||||
"""Poll BTCPay Server for sync status, store data, and invoice counts."""
|
||||
try:
|
||||
# Server info (sync status)
|
||||
r = requests.get(f"{BTCPAY_URL}/api/v1/server/info",
|
||||
headers={"Authorization": f"token {BTCPAY_KEY}"},
|
||||
timeout=10, verify=False)
|
||||
if r.status_code != 200:
|
||||
return {"error": f"BTCPay returned {r.status_code}", "synced": False}
|
||||
|
||||
info = r.json()
|
||||
sync_pct = 0
|
||||
blocks = 0
|
||||
headers = 0
|
||||
for s in info.get("syncStatus", []):
|
||||
ni = s.get("nodeInformation", {})
|
||||
if ni.get("headers") and ni.get("blocks"):
|
||||
blocks = ni["blocks"]
|
||||
headers = ni["headers"]
|
||||
sync_pct = (blocks / headers) * 100 if headers else 0
|
||||
elif ni.get("verificationProgress"):
|
||||
sync_pct = ni["verificationProgress"] * 100
|
||||
|
||||
# Per-store data
|
||||
stores_data = {}
|
||||
total_invoices = 0
|
||||
stores_with_wallet = 0
|
||||
|
||||
for sid, store_id in BTCPAY_STORES.items():
|
||||
try:
|
||||
# Check payment methods
|
||||
pm_r = requests.get(f"{BTCPAY_URL}/api/v1/stores/{store_id}/payment-methods",
|
||||
headers={"Authorization": f"token {BTCPAY_KEY}"},
|
||||
timeout=5, verify=False)
|
||||
has_wallet = False
|
||||
if pm_r.status_code == 200:
|
||||
pms = pm_r.json()
|
||||
has_wallet = any(pm.get("enabled") for pm in pms if isinstance(pm, dict))
|
||||
|
||||
if has_wallet:
|
||||
stores_with_wallet += 1
|
||||
|
||||
# Count invoices
|
||||
inv_r = requests.get(f"{BTCPAY_URL}/api/v1/stores/{store_id}/invoices?take=100",
|
||||
headers={"Authorization": f"token {BTCPAY_KEY}"},
|
||||
timeout=5, verify=False)
|
||||
invoice_count = 0
|
||||
invoices_by_status = {}
|
||||
if inv_r.status_code == 200:
|
||||
invoices = inv_r.json()
|
||||
invoice_count = len(invoices)
|
||||
total_invoices += invoice_count
|
||||
for inv in invoices:
|
||||
status = inv.get("status", "unknown")
|
||||
invoices_by_status[status] = invoices_by_status.get(status, 0) + 1
|
||||
|
||||
stores_data[sid] = {
|
||||
"has_wallet": has_wallet,
|
||||
"invoice_count": invoice_count,
|
||||
"invoices_by_status": invoices_by_status,
|
||||
"store_id": store_id[:16] + "...",
|
||||
}
|
||||
except Exception as e:
|
||||
stores_data[sid] = {"has_wallet": False, "invoice_count": 0, "error": str(e)[:80]}
|
||||
|
||||
# Save snapshot to DB
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT INTO btcpay_snapshots (timestamp, sync_pct, blocks, headers, synced, store_count, stores_with_wallet, total_invoices) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(time.time(), round(sync_pct, 2), blocks, headers,
|
||||
1 if info.get("fullySynched") else 0, len(BTCPAY_STORES), stores_with_wallet, total_invoices)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"synced": info.get("fullySynched", False),
|
||||
"sync_pct": round(sync_pct, 2),
|
||||
"blocks": blocks,
|
||||
"headers": headers,
|
||||
"version": info.get("version", "?"),
|
||||
"stores_with_wallet": stores_with_wallet,
|
||||
"total_stores": len(BTCPAY_STORES),
|
||||
"total_invoices": total_invoices,
|
||||
"stores": stores_data,
|
||||
"error": None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:200], "synced": False, "sync_pct": 0, "stores": {}}
|
||||
|
||||
def poll_all():
|
||||
"""Poll all 10 sites + BTCPay and update state."""
|
||||
global state
|
||||
results = {}
|
||||
total_users = 0
|
||||
total_products = 0
|
||||
online = 0
|
||||
|
||||
for sid, info in SITES.items():
|
||||
result = poll_site(sid, info)
|
||||
details = poll_site_details(sid, info) if result["status"] == "online" else None
|
||||
if details:
|
||||
result["details"] = details
|
||||
results[sid] = result
|
||||
total_users += result["users"]
|
||||
total_products += result["products"]
|
||||
if result["status"] == "online":
|
||||
online += 1
|
||||
|
||||
# BTCPay poll
|
||||
btcpay_data = poll_btcpay()
|
||||
|
||||
# Store snapshots in DB
|
||||
now = time.time()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
for sid, r in results.items():
|
||||
c.execute(
|
||||
"INSERT INTO snapshots (site_id, timestamp, users, products, orders, revenue, status, response_ms) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(sid, now, r["users"], r["products"], r.get("orders", 0), r.get("revenue", 0), r["status"], r.get("response_ms", 0))
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with state_lock:
|
||||
old_state = state.get("sites", {})
|
||||
for sid, r in results.items():
|
||||
old_users = old_state.get(sid, {}).get("users", 0)
|
||||
old_orders = old_state.get(sid, {}).get("orders", 0)
|
||||
old_revenue = old_state.get(sid, {}).get("revenue", 0)
|
||||
|
||||
if r["users"] > old_users and old_users > 0:
|
||||
event_detail = f"{r['emoji']} {r['name']}: +{r['users'] - old_users} new signup(s)"
|
||||
c = sqlite3.connect(DB_PATH).cursor()
|
||||
c.execute("INSERT INTO events (site_id, timestamp, event_type, detail) VALUES (?,?,?,?)",
|
||||
(sid, now, "new_users", event_detail))
|
||||
c.connection.commit()
|
||||
c.connection.close()
|
||||
|
||||
if r["orders"] > old_orders and old_orders > 0:
|
||||
new_orders = r["orders"] - old_orders
|
||||
new_rev = r["revenue"] - old_revenue
|
||||
event_detail = f"{r['emoji']} {r['name']}: +{new_orders} order(s) | +${new_rev:.2f} revenue"
|
||||
c = sqlite3.connect(DB_PATH).cursor()
|
||||
c.execute("INSERT INTO events (site_id, timestamp, event_type, detail) VALUES (?,?,?,?)",
|
||||
(sid, now, "new_orders", event_detail))
|
||||
c.connection.commit()
|
||||
c.connection.close()
|
||||
|
||||
# BTCPay events
|
||||
old_btcpay = old_state.get("btcpay", {}) if isinstance(old_state, dict) else {}
|
||||
if btcpay_data.get("synced") and not old_btcpay.get("synced"):
|
||||
event_detail = "₿ BTCPay: Node fully synced! Wallets can be generated."
|
||||
c = sqlite3.connect(DB_PATH).cursor()
|
||||
c.execute("INSERT INTO events (site_id, timestamp, event_type, detail) VALUES (?,?,?,?)",
|
||||
(0, now, "btcpay_synced", event_detail))
|
||||
c.connection.commit()
|
||||
c.connection.close()
|
||||
|
||||
total_revenue = sum(r.get("revenue", 0) for r in results.values())
|
||||
total_orders = sum(r.get("orders", 0) for r in results.values())
|
||||
|
||||
state = {
|
||||
"sites": results,
|
||||
"totals": {
|
||||
"users": total_users,
|
||||
"products": total_products,
|
||||
"online": online,
|
||||
"offline": 10 - online,
|
||||
"orders": total_orders,
|
||||
"revenue": round(total_revenue, 2),
|
||||
},
|
||||
"last_poll": datetime.now().strftime("%H:%M:%S"),
|
||||
"last_poll_ts": now,
|
||||
"btcpay": btcpay_data,
|
||||
}
|
||||
|
||||
def poller_loop():
|
||||
"""Background thread that polls every 30 seconds."""
|
||||
while True:
|
||||
try:
|
||||
poll_all()
|
||||
except Exception as e:
|
||||
print(f"Poll error: {e}")
|
||||
time.sleep(30)
|
||||
|
||||
@app.route('/')
|
||||
def dashboard():
|
||||
return render_template('dashboard.html')
|
||||
|
||||
@app.route('/master')
|
||||
def master():
|
||||
return render_template('master.html')
|
||||
|
||||
@app.route('/btcpay')
|
||||
def btcpay_dash():
|
||||
"""Dedicated BTCPay management view."""
|
||||
return render_template('btcpay.html')
|
||||
|
||||
@app.route('/recon')
|
||||
def recon():
|
||||
"""Payment processor recon tool."""
|
||||
return app.send_static_file('payment-recon/index.html')
|
||||
|
||||
@app.route('/watchdog')
|
||||
def watchdog_page():
|
||||
"""Self-healing watchdog status page."""
|
||||
return render_template('watchdog.html')
|
||||
|
||||
@app.route('/api/watchdog')
|
||||
def api_watchdog():
|
||||
if WATCHDOG_AVAILABLE:
|
||||
return jsonify(get_watchdog_state())
|
||||
return jsonify({"error": "Watchdog not loaded"})
|
||||
|
||||
@app.route('/api/watchdog/run', methods=['POST'])
|
||||
def api_watchdog_run():
|
||||
"""Manually trigger a watchdog scan."""
|
||||
if WATCHDOG_AVAILABLE:
|
||||
watchdog_scan()
|
||||
return jsonify({"status": "ok", "result": get_watchdog_state()})
|
||||
return jsonify({"error": "Watchdog not available"})
|
||||
|
||||
@app.route('/api/state')
|
||||
def api_state():
|
||||
with state_lock:
|
||||
s = dict(state)
|
||||
|
||||
# Get recent events
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT site_id, timestamp, event_type, detail FROM events ORDER BY timestamp DESC LIMIT 50")
|
||||
events = [{"site_id": r[0], "timestamp": r[1], "type": r[2], "detail": r[3]} for r in c.fetchall()]
|
||||
conn.close()
|
||||
|
||||
s["events"] = events
|
||||
s["btc_address"] = BTC_ADDRESS
|
||||
|
||||
# Load BTC stats from blockchain monitor DB
|
||||
try:
|
||||
btc_conn = sqlite3.connect("/Users/drjones/ccsite-manager/btc_chain.db")
|
||||
bc = btc_conn.cursor()
|
||||
bc.execute("SELECT COUNT(*), COALESCE(SUM(amount_btc),0), COALESCE(SUM(amount_sats),0) FROM transactions")
|
||||
btc_count, btc_total, btc_sats = bc.fetchone()
|
||||
bc.execute("SELECT COUNT(*), COALESCE(SUM(amount_btc),0) FROM transactions WHERE first_seen > ?", (time.time() - 86400,))
|
||||
btc_day_count, btc_day_btc = bc.fetchone()
|
||||
bc.execute("SELECT txid, amount_btc, amount_sats, timestamp FROM transactions ORDER BY timestamp DESC LIMIT 5")
|
||||
btc_recent = [{"txid": r[0][:16]+"...", "btc": r[1], "sats": r[2], "ts": r[3]} for r in bc.fetchall()]
|
||||
btc_conn.close()
|
||||
s["btc_stats"] = {
|
||||
"total_txs": btc_count, "total_btc": round(btc_total or 0, 8),
|
||||
"total_sats": btc_sats or 0, "total_usd": round((btc_total or 0) * 68000, 2),
|
||||
"day_txs": btc_day_count, "day_btc": round(btc_day_btc or 0, 8),
|
||||
"day_usd": round((btc_day_btc or 0) * 68000, 2), "recent": btc_recent,
|
||||
}
|
||||
except:
|
||||
s["btc_stats"] = {"total_txs": 0, "total_btc": 0, "total_usd": 0, "day_btc": 0, "recent": []}
|
||||
|
||||
# Revenue history for chart (last 24h: per-site latest value per hour, summed)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
since = time.time() - 86400
|
||||
c.execute("""SELECT site_id, timestamp, revenue FROM snapshots
|
||||
WHERE timestamp > ? ORDER BY timestamp ASC""", (since,))
|
||||
per_site_hour = {}
|
||||
for sid, ts, rev in c.fetchall():
|
||||
per_site_hour[(sid, int(ts // 3600))] = rev # later rows overwrite -> latest wins
|
||||
hourly = defaultdict(float)
|
||||
for (sid, hr), rev in per_site_hour.items():
|
||||
hourly[hr] += rev
|
||||
history = [{"ts": hr * 3600, "revenue": round(rev, 2)} for hr, rev in sorted(hourly.items())]
|
||||
conn.close()
|
||||
s["history"] = history
|
||||
|
||||
# BTCPay history
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT timestamp, sync_pct FROM btcpay_snapshots
|
||||
WHERE timestamp > ? ORDER BY timestamp ASC LIMIT 100""", (since,))
|
||||
btcpay_history = [{"ts": r[0], "sync_pct": r[1]} for r in c.fetchall()]
|
||||
conn.close()
|
||||
s["btcpay_history"] = btcpay_history
|
||||
|
||||
return jsonify(s)
|
||||
|
||||
@app.route('/api/history')
|
||||
def api_history():
|
||||
"""Get historical data for charts."""
|
||||
hours = int(request.args.get('hours', 24))
|
||||
since = time.time() - (hours * 3600)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT site_id, timestamp, users, status FROM snapshots
|
||||
WHERE timestamp > ? ORDER BY timestamp ASC""", (since,))
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Group by site
|
||||
history = defaultdict(list)
|
||||
for row in rows:
|
||||
history[row[0]].append({
|
||||
"ts": row[1],
|
||||
"users": row[2],
|
||||
"status": row[3],
|
||||
})
|
||||
|
||||
# Convert to serializable format
|
||||
result = {}
|
||||
for sid, points in history.items():
|
||||
# Downsample to ~100 points max
|
||||
step = max(1, len(points) // 100)
|
||||
result[str(sid)] = points[::step]
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/orders')
|
||||
def api_orders():
|
||||
"""Cross-site live order feed, newest first."""
|
||||
limit = int(request.args.get('limit', 40))
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT site_id, product, amount, qty, status, email, code, date
|
||||
FROM site_orders ORDER BY date DESC LIMIT ?""", (limit,))
|
||||
rows = [{"site_id": r[0], "site": SITES.get(r[0], {}).get("name", "?"),
|
||||
"emoji": SITES.get(r[0], {}).get("emoji", ""),
|
||||
"product": r[1], "amount": r[2], "qty": r[3], "status": r[4],
|
||||
"email": r[5], "code": r[6], "date": r[7]} for r in c.fetchall()]
|
||||
conn.close()
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route('/api/signups')
|
||||
def api_signups():
|
||||
"""Cross-site signup feed, newest first, plus membership breakdown."""
|
||||
limit = int(request.args.get('limit', 40))
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT site_id, username, email, membership, balance, orders, joined
|
||||
FROM site_users ORDER BY joined DESC LIMIT ?""", (limit,))
|
||||
rows = [{"site_id": r[0], "site": SITES.get(r[0], {}).get("name", "?"),
|
||||
"emoji": SITES.get(r[0], {}).get("emoji", ""),
|
||||
"username": r[1], "email": r[2], "membership": r[3],
|
||||
"balance": r[4], "orders": r[5], "joined": r[6]} for r in c.fetchall()]
|
||||
c.execute("SELECT membership, COUNT(*) FROM site_users GROUP BY membership")
|
||||
tier_labels = {"0": "free", "1": "pro", "2": "elite", 0: "free", 1: "pro", 2: "elite"}
|
||||
tiers = {tier_labels.get(r[0], r[0] or "free"): r[1] for r in c.fetchall()}
|
||||
c.execute("SELECT site_id, COUNT(*) FROM site_users GROUP BY site_id")
|
||||
per_site = {str(r[0]): r[1] for r in c.fetchall()}
|
||||
conn.close()
|
||||
return jsonify({"recent": rows, "tiers": tiers, "per_site": per_site})
|
||||
|
||||
@app.route('/api/leaderboard')
|
||||
def api_leaderboard():
|
||||
"""Per-site ranked performance table."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
board = []
|
||||
for sid, info in SITES.items():
|
||||
c.execute("SELECT COUNT(*), COALESCE(SUM(amount*qty),0), COALESCE(AVG(amount*qty),0) FROM site_orders WHERE site_id=?", (sid,))
|
||||
n_orders, rev, aov = c.fetchone()
|
||||
c.execute("SELECT COUNT(*) FROM site_orders WHERE site_id=? AND status='pending'", (sid,))
|
||||
pending = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*) FROM site_users WHERE site_id=?", (sid,))
|
||||
n_users = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*) FROM site_orders WHERE site_id=? AND date > datetime('now','-1 day')", (sid,))
|
||||
orders_24h = c.fetchone()[0]
|
||||
board.append({"site_id": sid, "site": info["name"], "emoji": info["emoji"],
|
||||
"orders": n_orders, "revenue": round(rev, 2), "aov": round(aov, 2),
|
||||
"pending": pending, "users": n_users, "orders_24h": orders_24h})
|
||||
conn.close()
|
||||
board.sort(key=lambda x: x["revenue"], reverse=True)
|
||||
return jsonify(board)
|
||||
|
||||
@app.route('/api/top-products')
|
||||
def api_top_products():
|
||||
"""Best-selling products across the fleet by order count and revenue."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT product, COUNT(*), SUM(amount*qty), COUNT(DISTINCT site_id)
|
||||
FROM site_orders GROUP BY product ORDER BY COUNT(*) DESC, SUM(amount*qty) DESC LIMIT 15""")
|
||||
rows = [{"product": r[0], "orders": r[1], "revenue": round(r[2] or 0, 2),
|
||||
"sites": r[3]} for r in c.fetchall()]
|
||||
conn.close()
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route('/api/activity')
|
||||
def api_activity():
|
||||
"""Hourly buckets for the last 48h: orders, revenue, signups. Plus 14-day daily."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
hourly = {}
|
||||
c.execute("""SELECT substr(date,1,13), COUNT(*), SUM(amount*qty) FROM site_orders
|
||||
WHERE date > datetime('now','-2 day') GROUP BY substr(date,1,13)""")
|
||||
for r in c.fetchall():
|
||||
hourly[r[0]] = {"orders": r[1], "revenue": round(r[2] or 0, 2)}
|
||||
c.execute("""SELECT substr(joined,1,13), COUNT(*) FROM site_users
|
||||
WHERE joined > datetime('now','-2 day') GROUP BY substr(joined,1,13)""")
|
||||
for r in c.fetchall():
|
||||
hourly.setdefault(r[0], {})["signups"] = r[1]
|
||||
daily = {}
|
||||
c.execute("""SELECT substr(date,1,10), COUNT(*), SUM(amount*qty) FROM site_orders
|
||||
WHERE date > datetime('now','-14 day') GROUP BY substr(date,1,10)""")
|
||||
for r in c.fetchall():
|
||||
daily[r[0]] = {"orders": r[1], "revenue": round(r[2] or 0, 2)}
|
||||
c.execute("""SELECT substr(joined,1,10), COUNT(*) FROM site_users
|
||||
WHERE joined > datetime('now','-14 day') GROUP BY substr(joined,1,10)""")
|
||||
for r in c.fetchall():
|
||||
daily.setdefault(r[0], {})["signups"] = r[1]
|
||||
conn.close()
|
||||
return jsonify({"hourly": hourly, "daily": daily})
|
||||
|
||||
@app.route('/api/funnel')
|
||||
def api_funnel():
|
||||
"""Fleet conversion funnel + headline numbers."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM site_users")
|
||||
users = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*) FROM site_users WHERE orders > 0")
|
||||
buyers = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*), COALESCE(SUM(amount*qty),0) FROM site_orders")
|
||||
orders, revenue = c.fetchone()
|
||||
c.execute("SELECT COUNT(*) FROM site_orders WHERE status='pending'")
|
||||
pending = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*) FROM site_orders WHERE status='delivered'")
|
||||
delivered = c.fetchone()[0]
|
||||
c.execute("SELECT COALESCE(SUM(amount),0) FROM site_txs WHERE type='deposit'")
|
||||
deposits = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(DISTINCT email) FROM site_orders")
|
||||
unique_customers = c.fetchone()[0]
|
||||
conn.close()
|
||||
return jsonify({
|
||||
"users": users, "buyers": buyers, "orders": orders,
|
||||
"revenue": round(revenue, 2), "pending": pending, "delivered": delivered,
|
||||
"deposits": round(deposits, 2), "unique_customers": unique_customers,
|
||||
"conversion": round(100.0 * buyers / users, 1) if users else 0,
|
||||
"aov": round(revenue / orders, 2) if orders else 0,
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
poll_all()
|
||||
t = threading.Thread(target=poller_loop, daemon=True)
|
||||
t.start()
|
||||
# Start watchdog
|
||||
if WATCHDOG_AVAILABLE:
|
||||
import watchdog
|
||||
wt = threading.Thread(target=watchdog.watchdog_loop, daemon=True)
|
||||
wt.start()
|
||||
watchdog.run_all_checks()
|
||||
print(f" /watchdog — Self-healing health monitor")
|
||||
print(f"Dashboard starting on http://localhost:5050")
|
||||
print(f" / — Fleet dashboard")
|
||||
print(f" /master — Master control panel")
|
||||
print(f" /btcpay — BTCPay management")
|
||||
app.run(host='0.0.0.0', port=5050, debug=False)
|
||||
Reference in New Issue
Block a user