Files
ccsite-manager/dashboard.py

647 lines
27 KiB
Python

#!/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)