ccsite-manager: fleet orchestrator, BTCPay auto-settlement, watchdog, dashboard
This commit is contained in:
BIN
__pycache__/dashboard.cpython-311.pyc
Normal file
BIN
__pycache__/dashboard.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/watchdog.cpython-311.pyc
Normal file
BIN
__pycache__/watchdog.cpython-311.pyc
Normal file
Binary file not shown.
BIN
btc_chain.db
Normal file
BIN
btc_chain.db
Normal file
Binary file not shown.
178
btc_monitor.py
Normal file
178
btc_monitor.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BTC Blockchain Monitor — polls blockstream.info for real transactions.
|
||||
Stores all TXs in SQLite. Detects new deposits. Feeds the dashboard.
|
||||
"""
|
||||
import requests
|
||||
import sqlite3
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
BTC_ADDRESS = "35vTSjPCaqudcL5cj5ChpNDmqrpPFmEnss"
|
||||
DB_PATH = "/Users/drjones/ccsite-manager/btc_chain.db"
|
||||
BLOCKSTREAM_URL = f"https://blockstream.info/api/address/{BTC_ADDRESS}/txs"
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS transactions (
|
||||
txid TEXT PRIMARY KEY,
|
||||
timestamp INTEGER,
|
||||
amount_btc REAL,
|
||||
amount_sats INTEGER,
|
||||
confirmations INTEGER,
|
||||
fee INTEGER,
|
||||
raw_json TEXT,
|
||||
first_seen REAL,
|
||||
alerted INTEGER DEFAULT 0
|
||||
)''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_tx_time ON transactions(timestamp)')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def check_blockchain():
|
||||
"""Poll Blockstream API for transactions. Returns list of new TXs."""
|
||||
try:
|
||||
r = requests.get(BLOCKSTREAM_URL, timeout=15)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
txs = r.json()
|
||||
except Exception as e:
|
||||
print(f"Blockstream API error: {e}")
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
new_txs = []
|
||||
|
||||
for tx in txs:
|
||||
txid = tx['txid']
|
||||
# Check if we've already seen this TX
|
||||
c.execute("SELECT txid FROM transactions WHERE txid = ?", (txid,))
|
||||
if c.fetchone():
|
||||
continue
|
||||
|
||||
# Calculate BTC amount sent TO our address (only count vouts where our address receives)
|
||||
total_out = 0
|
||||
for vout in tx.get('vout', []):
|
||||
addr = vout.get('scriptpubkey_address', '')
|
||||
if addr == BTC_ADDRESS:
|
||||
total_out += vout.get('value', 0)
|
||||
|
||||
if total_out == 0:
|
||||
continue # Skip transactions where our address isn't receiving
|
||||
|
||||
# Get fee and confirmations
|
||||
fee = tx.get('fee', 0)
|
||||
confs = tx.get('status', {}).get('confirmed', False)
|
||||
conf_count = tx.get('status', {}).get('block_height', 0)
|
||||
ts = tx.get('status', {}).get('block_time', int(time.time()))
|
||||
|
||||
c.execute('''INSERT INTO transactions (txid, timestamp, amount_btc, amount_sats,
|
||||
confirmations, fee, raw_json, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
(txid, ts, total_out, int(total_out * 100000000),
|
||||
conf_count if confs else 0, fee, json.dumps(tx), time.time()))
|
||||
|
||||
new_txs.append({
|
||||
'txid': txid,
|
||||
'amount_btc': total_out,
|
||||
'amount_usd': round(total_out * 68000, 2), # Approximate BTC price
|
||||
'confirmations': conf_count if confs else 0,
|
||||
'time': ts,
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_txs
|
||||
|
||||
def auto_complete_orders(new_txs):
|
||||
"""When new TXs arrive, try to match them to pending orders on all 10 sites."""
|
||||
SITE_IPS = [f"10.30.20.{ip}" for ip in ['210','211','212','213','214','217','218','219','220','221']]
|
||||
|
||||
for tx in new_txs:
|
||||
btc_amount = tx['amount_btc']
|
||||
if btc_amount <= 0:
|
||||
continue
|
||||
|
||||
# Try each site
|
||||
for ip in SITE_IPS:
|
||||
try:
|
||||
r = requests.get(f"http://{ip}:5000/api/products", timeout=5)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
|
||||
# Check for pending orders via health endpoint
|
||||
# We need to tell the site "a payment of X BTC arrived, check pending orders"
|
||||
r2 = requests.post(f"http://{ip}:5000/api/btc-check",
|
||||
json={"amount_btc": btc_amount, "txid": tx['txid']},
|
||||
timeout=5)
|
||||
if r2.status_code == 200:
|
||||
result = r2.json()
|
||||
if result.get('matched'):
|
||||
print(f" ✅ Auto-completed order on {ip}: {result.get('order_code','?')}")
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_stats():
|
||||
"""Get aggregate stats from stored transactions."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("SELECT COUNT(*), COALESCE(SUM(amount_btc),0), COALESCE(SUM(amount_sats),0) FROM transactions")
|
||||
count, total_btc, total_sats = c.fetchone()
|
||||
|
||||
c.execute("SELECT COUNT(*), COALESCE(SUM(amount_btc),0) FROM transactions WHERE first_seen > ?",
|
||||
(time.time() - 86400,))
|
||||
day_count, day_btc = c.fetchone()
|
||||
|
||||
c.execute("SELECT txid, amount_btc, timestamp FROM transactions ORDER BY timestamp DESC LIMIT 10")
|
||||
recent = [{'txid': r[0][:16]+'...', 'btc': r[1], 'ts': r[2]} for r in c.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total_txs': count,
|
||||
'total_btc': round(total_btc, 8),
|
||||
'total_sats': total_sats,
|
||||
'total_usd': round(total_btc * 68000, 2),
|
||||
'day_txs': day_count,
|
||||
'day_btc': round(day_btc, 8),
|
||||
'day_usd': round(day_btc * 68000, 2),
|
||||
'recent': recent,
|
||||
'address': BTC_ADDRESS,
|
||||
}
|
||||
|
||||
def monitor_loop(callback=None):
|
||||
"""Run continuously, calling callback with new TXs."""
|
||||
init_db()
|
||||
print(f"₿ BTC Monitor started — watching {BTC_ADDRESS[:16]}...")
|
||||
print(f" Polling blockstream.info every 60s")
|
||||
|
||||
while True:
|
||||
try:
|
||||
new = check_blockchain()
|
||||
if new:
|
||||
for tx in new:
|
||||
print(f" 💰 NEW TX: {tx['amount_btc']:.8f} BTC (${tx['amount_usd']:.2f}) — {tx['txid'][:16]}...")
|
||||
if callback:
|
||||
callback(new)
|
||||
else:
|
||||
# Silent tick — no new TXs
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Monitor error: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if len(sys.argv) > 1 and sys.argv[1] == '--once':
|
||||
new = check_blockchain()
|
||||
stats = get_stats()
|
||||
print(json.dumps({'new_txs': len(new), 'stats': stats}, indent=2))
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == '--stats':
|
||||
stats = get_stats()
|
||||
print(json.dumps(stats, indent=2))
|
||||
else:
|
||||
monitor_loop()
|
||||
216
ccsite.py
Normal file
216
ccsite.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ccsite-manager — Mass orchestration CLI for the 10-shop fleet.
|
||||
Usage:
|
||||
ccsite push <local_path> <remote_path> [--site N] Push a file to all/specific sites
|
||||
ccsite cmd "<command>" [--site N] Run a shell command on all sites
|
||||
ccsite restart [--site N] Restart the shop service
|
||||
ccsite sql "<SQL>" [--site N] Run SQLite query on all sites
|
||||
ccsite list List all sites with status
|
||||
ccsite health Health check all sites
|
||||
ccsite push-template <name> <content> [--site N] Push a Jinja2 template to all sites
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
SITES = {
|
||||
1: {"ip": "10.30.20.210", "vmid": "123", "name": "Digital Marketplace"},
|
||||
2: {"ip": "10.30.20.211", "vmid": "124", "name": "CardVault Pro"},
|
||||
3: {"ip": "10.30.20.212", "vmid": "125", "name": "StreamPass Hub"},
|
||||
4: {"ip": "10.30.20.213", "vmid": "126", "name": "KeyForge Digital"},
|
||||
5: {"ip": "10.30.20.214", "vmid": "127", "name": "CoinBridge Market"},
|
||||
6: {"ip": "10.30.20.217", "vmid": "128", "name": "GiftShift"},
|
||||
7: {"ip": "10.30.20.218", "vmid": "129", "name": "PremiumPortal"},
|
||||
8: {"ip": "10.30.20.219", "vmid": "130", "name": "DigiDeals"},
|
||||
9: {"ip": "10.30.20.220", "vmid": "131", "name": "NexusKeys"},
|
||||
10: {"ip": "10.30.20.221", "vmid": "132", "name": "VaultPass"},
|
||||
}
|
||||
|
||||
PROXMOX = "root@10.30.20.85"
|
||||
SHOP_DIR = "/opt/shop"
|
||||
SHOP_DB = "/opt/shop/shop.db"
|
||||
|
||||
def ssh_cmd(ip, cmd, timeout=15):
|
||||
"""Run a command on a site via direct SSH."""
|
||||
full = f"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 root@{ip} '{cmd}'"
|
||||
try:
|
||||
r = subprocess.run(full, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return {"ok": r.returncode == 0, "stdout": r.stdout.strip(), "stderr": r.stderr.strip()}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "stdout": "", "stderr": "TIMEOUT"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "stdout": "", "stderr": str(e)}
|
||||
|
||||
def pct_exec(vmid, cmd, timeout=15):
|
||||
"""Run a command inside a container via Proxmox pct exec."""
|
||||
safe_cmd = cmd.replace("'", "'\"'\"'")
|
||||
full = f"ssh {PROXMOX} \"pct exec {vmid} -- bash -c '{safe_cmd}'\""
|
||||
try:
|
||||
r = subprocess.run(full, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return {"ok": r.returncode == 0, "stdout": r.stdout.strip(), "stderr": r.stderr.strip()}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "stdout": "", "stderr": "TIMEOUT"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "stdout": "", "stderr": str(e)}
|
||||
|
||||
def push_file(ip, local_path, remote_path, timeout=15):
|
||||
"""Push a file to a site via base64 + SSH."""
|
||||
with open(local_path, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
cmd = f"echo '{b64}' | base64 -d > {remote_path}"
|
||||
return ssh_cmd(ip, cmd, timeout)
|
||||
|
||||
def push_content(ip, content, remote_path, timeout=15):
|
||||
"""Push raw content to a site."""
|
||||
b64 = base64.b64encode(content.encode()).decode()
|
||||
cmd = f"echo '{b64}' | base64 -d > {remote_path}"
|
||||
return ssh_cmd(ip, cmd, timeout)
|
||||
|
||||
def run_on_sites(sites_filter=None, func=None, desc=""):
|
||||
"""Run a function across sites in parallel. Returns dict of results."""
|
||||
targets = {k: v for k, v in SITES.items() if sites_filter is None or k in sites_filter}
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=10) as ex:
|
||||
futures = {ex.submit(func, s): (i, s) for i, s in targets.items()}
|
||||
for f in as_completed(futures):
|
||||
i, s = futures[f]
|
||||
try:
|
||||
results[i] = f.result()
|
||||
except Exception as e:
|
||||
results[i] = {"ok": False, "stderr": str(e)}
|
||||
status = "✓" if results[i].get("ok") else "✗"
|
||||
print(f" [{status}] ccsite{i} ({s['name']}) — {desc}")
|
||||
return results
|
||||
|
||||
# ─── Commands ─────────────────────────────────────────────
|
||||
|
||||
def cmd_list():
|
||||
print(f"{'#':<3} {'Name':<25} {'IP':<16} {'VMID':<6} Status")
|
||||
print("-" * 60)
|
||||
def check(s):
|
||||
r = ssh_cmd(s['ip'], "systemctl is-active shop 2>/dev/null || echo dead")
|
||||
status = r.get('stdout', '?').strip()
|
||||
return r
|
||||
results = run_on_sites(func=check, desc="status check")
|
||||
for i in sorted(SITES):
|
||||
s = SITES[i]
|
||||
r = results.get(i, {})
|
||||
status = r.get('stdout', '?').strip()
|
||||
status_icon = "🟢" if status == "active" else "🔴"
|
||||
print(f"{i:<3} {s['name']:<25} {s['ip']:<16} {s['vmid']:<6} {status_icon} {status}")
|
||||
|
||||
def cmd_health():
|
||||
print("Health check across all sites...")
|
||||
def check(s):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f"curl -s --connect-timeout 3 http://{s['ip']}:5000/health",
|
||||
shell=True, capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode == 0:
|
||||
d = json.loads(r.stdout)
|
||||
return {"ok": True, "stdout": f"{d.get('products', '?')} products, {d.get('users', '?')} users"}
|
||||
return {"ok": False, "stdout": r.stderr or "no response"}
|
||||
except:
|
||||
return {"ok": False, "stdout": "connection failed"}
|
||||
results = run_on_sites(func=check, desc="health")
|
||||
ok = sum(1 for r in results.values() if r.get("ok"))
|
||||
print(f"\n{ok}/10 sites healthy")
|
||||
|
||||
def cmd_restart(sites_filter=None):
|
||||
print("Restarting shop service...")
|
||||
def restart(s):
|
||||
return ssh_cmd(s['ip'], "systemctl restart shop && sleep 1 && systemctl is-active shop")
|
||||
run_on_sites(sites_filter, restart, "restart")
|
||||
|
||||
def cmd_push(args):
|
||||
local = args[0]
|
||||
remote = args[1] if len(args) > 1 else f"{SHOP_DIR}/{Path(local).name}"
|
||||
sites_filter = parse_site_filter(args)
|
||||
print(f"Pushing {local} → {remote}")
|
||||
def push(s):
|
||||
return push_file(s['ip'], local, remote)
|
||||
results = run_on_sites(sites_filter, push, f"→ {remote}")
|
||||
ok = sum(1 for r in results.values() if r.get("ok"))
|
||||
print(f"\nPushed to {ok}/{len(results)} sites")
|
||||
# Restart if pushing to templates or app
|
||||
if 'templates' in remote or 'app.py' in remote:
|
||||
print("Code change detected — restarting services...")
|
||||
cmd_restart(sites_filter)
|
||||
|
||||
def cmd_cmd(args):
|
||||
if not args:
|
||||
print("Usage: ccsite cmd '<command>' [--site N]")
|
||||
return
|
||||
cmd_str = args[0]
|
||||
sites_filter = parse_site_filter(args)
|
||||
print(f"Running: {cmd_str}")
|
||||
def run(s):
|
||||
return ssh_cmd(s['ip'], cmd_str, timeout=20)
|
||||
results = run_on_sites(sites_filter, run, cmd_str[:40])
|
||||
for i in sorted(results):
|
||||
r = results[i]
|
||||
out = r.get('stdout', '') or r.get('stderr', '')
|
||||
if out:
|
||||
print(f"\n── ccsite{i} ({SITES[i]['name']}) ──")
|
||||
print(out[:500])
|
||||
|
||||
def cmd_sql(args):
|
||||
if not args:
|
||||
print("Usage: ccsite sql '<SQL query>' [--site N]")
|
||||
return
|
||||
sql = args[0]
|
||||
sites_filter = parse_site_filter(args)
|
||||
print(f"SQL: {sql}")
|
||||
def run(s):
|
||||
return ssh_cmd(s['ip'], f"sqlite3 {SHOP_DB} \"{sql}\" 2>&1", timeout=10)
|
||||
results = run_on_sites(sites_filter, run, sql[:40])
|
||||
for i in sorted(results):
|
||||
r = results[i]
|
||||
out = r.get('stdout', '') or r.get('stderr', '')
|
||||
print(f"\n── ccsite{i} ({SITES[i]['name']}) ──")
|
||||
print(out[:500] if out else "(no results)")
|
||||
|
||||
def parse_site_filter(args):
|
||||
"""Extract --site N from args. Returns set of site IDs or None for all."""
|
||||
for a in args:
|
||||
if a.startswith('--site='):
|
||||
try:
|
||||
return {int(a.split('=')[1])}
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def print_usage():
|
||||
print(__doc__)
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print_usage()
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
rest = sys.argv[2:]
|
||||
|
||||
if command == 'list':
|
||||
cmd_list()
|
||||
elif command == 'health':
|
||||
cmd_health()
|
||||
elif command == 'restart':
|
||||
cmd_restart(parse_site_filter(rest))
|
||||
elif command == 'push':
|
||||
cmd_push(rest)
|
||||
elif command == 'cmd':
|
||||
cmd_cmd(rest)
|
||||
elif command == 'sql':
|
||||
cmd_sql(rest)
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print_usage()
|
||||
175
crypto-casinos.html
Normal file
175
crypto-casinos.html
Normal file
@@ -0,0 +1,175 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>No-KYC Crypto Casinos — Buy & Play & Withdraw</title>
|
||||
<style>
|
||||
:root { --bg:#080812; --card:#12121f; --accent:#f7931a; --accent2:#00d4aa; --text:#e0e0e0; --muted:#777; --danger:#ff4757; --success:#2ed573; --warning:#ffa502; --radius:10px; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,sans-serif; background:var(--bg); color:var(--text); padding:1.5rem; }
|
||||
h1 { font-size:1.4rem; background:linear-gradient(135deg,var(--accent),var(--accent2)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:0.3rem; }
|
||||
.sub { color:var(--muted); font-size:0.75rem; margin-bottom:1.5rem; }
|
||||
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:0.7rem; margin-bottom:1.5rem; }
|
||||
.stat { background:var(--card); padding:1rem; border-radius:var(--radius); border:1px solid rgba(255,255,255,0.05); text-align:center; }
|
||||
.stat .val { font-size:1.6rem; font-weight:800; color:var(--accent2); }
|
||||
.stat .lbl { font-size:0.6rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-top:0.2rem; }
|
||||
|
||||
table { width:100%; border-collapse:collapse; font-size:0.78rem; background:var(--card); border-radius:var(--radius); overflow:hidden; }
|
||||
th { text-align:left; padding:0.6rem 0.8rem; background:rgba(108,99,255,0.1); font-size:0.65rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); }
|
||||
td { padding:0.7rem 0.8rem; border-bottom:1px solid rgba(255,255,255,0.03); }
|
||||
tr:hover { background:rgba(247,147,26,0.04); }
|
||||
|
||||
.badge { display:inline-block; padding:0.15rem 0.5rem; border-radius:3px; font-size:0.6rem; font-weight:700; }
|
||||
.b-yes { background:rgba(46,213,115,0.15); color:var(--success); }
|
||||
.b-no { background:rgba(255,71,87,0.15); color:var(--danger); }
|
||||
.b-partial { background:rgba(255,165,2,0.15); color:var(--warning); }
|
||||
.b-best { background:rgba(247,147,26,0.2); color:var(--accent); font-size:0.6rem; }
|
||||
|
||||
.link { color:var(--accent2); text-decoration:none; font-size:0.7rem; }
|
||||
.link:hover { text-decoration:underline; }
|
||||
|
||||
.dork-section { background:var(--card); padding:1.2rem; border-radius:var(--radius); margin-top:1.5rem; border:1px solid rgba(255,255,255,0.05); }
|
||||
.dork { font-family:'SF Mono',monospace; font-size:0.72rem; background:rgba(0,0,0,0.4); padding:0.4rem 0.7rem; border-radius:4px; margin:0.3rem; display:inline-block; border-left:2px solid var(--accent); }
|
||||
.dork a { color:var(--accent2); text-decoration:none; }
|
||||
|
||||
.features { font-size:0.7rem; color:var(--muted); line-height:1.5; }
|
||||
.features li { margin:0.2rem 0; }
|
||||
|
||||
input, textarea { background:rgba(0,0,0,0.4); color:var(--text); border:1px solid rgba(255,255,255,0.1); border-radius:4px; padding:0.5rem; font-size:0.78rem; width:100%; }
|
||||
.btn { display:inline-block; padding:0.5rem 1.2rem; background:var(--accent); color:#000; border:none; border-radius:var(--radius); cursor:pointer; font-weight:700; font-size:0.8rem; margin:0.3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>🎰 No-KYC Crypto Casinos</h1>
|
||||
<p class="sub">Buy crypto on platform → gamble → withdraw. No documents. No verification. Listed by feature completeness.</p>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Casino</th>
|
||||
<th>No KYC</th>
|
||||
<th>Buy Crypto<br>On Platform</th>
|
||||
<th>Instant<br>Withdraw</th>
|
||||
<th>Sports<br>Betting</th>
|
||||
<th>VPN<br>Friendly</th>
|
||||
<th>Type</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="casino-table"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="dork-section">
|
||||
<h3 style="font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:0.5rem;">🔍 Find More — Google Dorks</h3>
|
||||
<div id="dorks"></div>
|
||||
</div>
|
||||
|
||||
<div class="dork-section" style="margin-top:0.5rem;">
|
||||
<h3 style="font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:0.5rem;">🔎 Search a Specific Domain for Casino Features</h3>
|
||||
<input type="text" id="domain-input" placeholder="example-casino.com" style="width:250px;display:inline;">
|
||||
<button class="btn" onclick="searchDomain()">Generate Dorks</button>
|
||||
<div id="domain-results" style="margin-top:0.5rem;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const casinos = [
|
||||
{name:"Stake.com",url:"stake.com",nokyc:"⚠ Limits",buy:"✅ Via Moonpay",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Biggest crypto casino. KYC on suspicion only. Provably fair originals. $100K+ withdrawals daily."},
|
||||
{name:"BC.Game",url:"bc.game",nokyc:"✅ Full",buy:"✅ Via Moonpay/Ramp",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Massive game library. 8000+ slots. Buy crypto direct. VIP levels. Daily bonuses."},
|
||||
{name:"BitStarz",url:"bitstarz.com",nokyc:"✅ Full",buy:"✅ Via Moonpay",withdraw:"✅ <10min",sports:"❌",vpn:"✅",type:"Casino Only",notes:"Since 2014. Award-winning. 3500+ games. Won multiple 'Best Casino' awards."},
|
||||
{name:"FortuneJack",url:"fortunejack.com",nokyc:"✅ Full",buy:"✅ Via Moonpay/Changelly",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Since 2014. Provably fair games. Sportsbook. Loyalty program. Altcoin support."},
|
||||
{name:"Cloudbet",url:"cloudbet.com",nokyc:"✅ Full",buy:"✅ Via Moonpay/Wyre",withdraw:"✅ Instant",sports:"✅ Full",vpn:"⚠ Some",type:"Sports+Casino",notes:"Sports-first. Esports. High limits. Since 2013. Bitcoin Lightning."},
|
||||
{name:"Thunderpick",url:"thunderpick.io",nokyc:"✅ Full",buy:"✅ Via Onramper",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Esports+Casino",notes:"Esports specialist. CS:GO, Dota 2, LoL betting. Crypto-only. Built-in exchange."},
|
||||
{name:"Betplay.io",url:"betplay.io",nokyc:"✅ Full",buy:"✅ Via Topper",withdraw:"✅ Lightning",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Lightning Network support. Instant deposits. 6000+ games. Pure crypto."},
|
||||
{name:"Duelbits",url:"duelbits.com",nokyc:"✅ Full",buy:"✅ Via Moonpay",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Popular streamer casino. Original games. Sportsbook. Rakeback. Instant cashouts."},
|
||||
{name:"Gamdom",url:"gamdom.com",nokyc:"✅ Full",buy:"❌ Crypto only",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Skin gambling roots. Massive CS:GO presence. Rakeback. No fiat on-ramp though."},
|
||||
{name:"Roobet",url:"roobet.com",nokyc:"⚠ $10K limit",buy:"✅ Via Moonpay",withdraw:"✅ 24h",sports:"✅ Full",vpn:"⚠ Restricted",type:"Casino+Sports",notes:"Very popular. Geo-restricted (no US/UK). KYC for large withdrawals. Original games."},
|
||||
{name:"Shuffle",url:"shuffle.com",nokyc:"✅ Full",buy:"✅ Via Onramper",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Newer casino. Clean UI. Provably fair. Multi-coin support. Growing fast."},
|
||||
{name:"500.casino",url:"500.casino",nokyc:"✅ Full",buy:"❌ Crypto only",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Pure crypto. No fiat. Esports + sports. Clean interface. Fast payouts."},
|
||||
{name:"Metaspins",url:"metaspins.com",nokyc:"✅ Full",buy:"✅ Via Changelly",withdraw:"✅ Instant",sports:"❌",vpn:"✅",type:"Casino Only",notes:"Web3 casino. NFT integration. Provably fair. 2500+ slots. Rakeback."},
|
||||
{name:"Wild.io",url:"wild.io",nokyc:"✅ Full",buy:"✅ Via Changelly",withdraw:"✅ Instant",sports:"❌",vpn:"✅",type:"Casino Only",notes:"Crypto-only casino. Good bonus structure. VIP program. Live dealer games."},
|
||||
{name:"Vave",url:"vave.com",nokyc:"✅ Full",buy:"✅ Via Changelly",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Sports + casino. Multi-currency. Live betting. Good for sports bettors."},
|
||||
{name:"Mega Dice",url:"megadice.com",nokyc:"✅ Full",buy:"✅ Via Moonpay",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"Telegram casino. No account needed (Telegram login). Instant play. 4000+ games."},
|
||||
{name:"Lucky Block",url:"luckyblock.com",nokyc:"✅ Full",buy:"✅ Via Ramp",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"LBLOCK token casino. Telegram + web. $10K welcome bonus. Growing ecosystem."},
|
||||
{name:"Wall St Memes",url:"wsmcasino.com",nokyc:"✅ Full",buy:"✅ Via Simplex",withdraw:"✅ Instant",sports:"✅ Full",vpn:"✅",type:"Casino+Sports",notes:"$WSM token ecosystem. Telegram casino. Meme coin community. New but solid."},
|
||||
{name:"Winna",url:"winna.com",nokyc:"✅ Full",buy:"✅ On platform",withdraw:"✅ Instant",sports:"✅",vpn:"✅",type:"Casino+Sports",notes:"Clean UI. Buy crypto direct. No KYC. Fast growing. Good bonus structure."},
|
||||
{name:"BlockSpins",url:"blockspins.com",nokyc:"✅ Full",buy:"✅ On platform",withdraw:"✅ Instant",sports:"✅",vpn:"✅",type:"Casino+Sports",notes:"Newer platform. Crypto-native. Buy & play flow. Good game selection."},
|
||||
];
|
||||
|
||||
// Build stats
|
||||
const total = casinos.length;
|
||||
const fullNoKyc = casinos.filter(c=>c.nokyc.includes('Full')).length;
|
||||
const canBuy = casinos.filter(c=>c.buy.includes('✅')).length;
|
||||
const sports = casinos.filter(c=>c.sports.includes('✅')).length;
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="stat"><div class="val">${total}</div><div class="lbl">Casinos Tracked</div></div>
|
||||
<div class="stat"><div class="val">${fullNoKyc}</div><div class="lbl">Full No-KYC</div></div>
|
||||
<div class="stat"><div class="val">${canBuy}</div><div class="lbl">Buy Crypto On-Platform</div></div>
|
||||
<div class="stat"><div class="val">${sports}</div><div class="lbl">With Sportsbook</div></div>
|
||||
<div class="stat"><div class="val">${casinos.filter(c=>c.buy.includes('✅')&&c.nokyc.includes('Full')).length}</div><div class="lbl">Buy + No-KYC Combo 🔥</div></div>
|
||||
`;
|
||||
|
||||
// Build table
|
||||
const tbody = document.getElementById('casino-table');
|
||||
for (const c of casinos) {
|
||||
const full = c.nokyc.includes('Full');
|
||||
const buys = c.buy.includes('✅');
|
||||
const best = full && buys ? ' <span class="b-best">BEST</span>' : '';
|
||||
tbody.innerHTML += `
|
||||
<tr>
|
||||
<td><strong>${c.name}</strong><br><a href="https://${c.url}" target="_blank" class="link">${c.url}</a></td>
|
||||
<td><span class="badge ${full?'b-yes':'b-partial'}">${c.nokyc}</span></td>
|
||||
<td><span class="badge ${buys?'b-yes':'b-no'}">${c.buy}</span></td>
|
||||
<td><span class="badge b-yes">${c.withdraw}</span></td>
|
||||
<td><span class="badge ${c.sports.includes('✅')?'b-yes':'b-no'}">${c.sports}</span></td>
|
||||
<td><span class="badge ${c.vpn.includes('✅')?'b-yes':'b-partial'}">${c.vpn}</span></td>
|
||||
<td>${c.type}${best}</td>
|
||||
<td style="font-size:0.7rem;color:var(--muted);max-width:250px;">${c.notes}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Build dorks
|
||||
const dorks = [
|
||||
'"no KYC" crypto casino instant withdrawal',
|
||||
'"buy bitcoin" casino "no verification"',
|
||||
'"buy crypto directly" casino OR sportsbook',
|
||||
'crypto casino "no documents required" deposit',
|
||||
'"anonymous" bitcoin casino "instant withdrawal"',
|
||||
'inurl:casino "buy bitcoin" OR "buy crypto"',
|
||||
'"no KYC" "sports betting" crypto',
|
||||
'site:*.io "provably fair" casino withdrawal',
|
||||
'"play without verification" bitcoin casino',
|
||||
'crypto gambling "no ID" instant cashout',
|
||||
'"VPN friendly" crypto casino',
|
||||
'"buy with credit card" casino bitcoin',
|
||||
'lightning network casino "no KYC"',
|
||||
'"instant withdrawal" "no verification" crypto gambling'
|
||||
];
|
||||
|
||||
const dDiv = document.getElementById('dorks');
|
||||
for (const d of dorks) {
|
||||
dDiv.innerHTML += `<span class="dork"><a href="https://www.google.com/search?q=${encodeURIComponent(d)}" target="_blank">🔗 ${d}</a></span> `;
|
||||
}
|
||||
|
||||
function searchDomain() {
|
||||
const domain = document.getElementById('domain-input').value.trim();
|
||||
if (!domain) return;
|
||||
const checks = [
|
||||
`site:${domain} ("buy bitcoin" OR "buy crypto" OR "purchase crypto")`,
|
||||
`site:${domain} ("no KYC" OR "no verification" OR "anonymous")`,
|
||||
`site:${domain} ("withdraw" OR "cashout") instant`,
|
||||
`site:${domain} moonpay OR changelly OR onramper OR simplex OR ramp`,
|
||||
`site:${domain} "provably fair"`,
|
||||
];
|
||||
document.getElementById('domain-results').innerHTML = checks.map(c =>
|
||||
`<span class="dork"><a href="https://www.google.com/search?q=${encodeURIComponent(c)}" target="_blank">🔗 ${c}</a></span> `
|
||||
).join('');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
dashboard.db
Normal file
BIN
dashboard.db
Normal file
Binary file not shown.
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)
|
||||
1
payment-recon
Submodule
1
payment-recon
Submodule
Submodule payment-recon added at 5ed685a79a
397
payment-recon.html
Normal file
397
payment-recon.html
Normal file
@@ -0,0 +1,397 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Recon — Site Explorer</title>
|
||||
<style>
|
||||
:root{--bg:#080812;--card:#12121f;--accent:#f7931a;--accent2:#00d4aa;--text:#e0e0e0;--muted:#777;--danger:#ff4757;--success:#2ed573;--warning:#ffa502;--radius:10px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);padding:0.8rem;height:100vh;display:flex;flex-direction:column}
|
||||
h1{font-size:1.2rem;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.sub{color:var(--muted);font-size:0.65rem;margin:0.1rem 0 0.5rem}
|
||||
|
||||
.toolbar{display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;padding:0.5rem 0;border-bottom:1px solid rgba(255,255,255,0.05);margin-bottom:0.5rem}
|
||||
.toolbar select,.toolbar input{background:rgba(0,0,0,0.4);color:var(--text);border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:0.35rem 0.5rem;font-size:0.72rem}
|
||||
.toolbar input{width:180px}
|
||||
.btn{padding:0.35rem 0.8rem;border-radius:4px;border:none;cursor:pointer;font-weight:600;font-size:0.7rem;white-space:nowrap}
|
||||
.btn-add{background:var(--accent2);color:#000}
|
||||
.btn-remove{background:var(--danger);color:#fff}
|
||||
.btn-dork{background:rgba(247,147,26,0.2);color:var(--accent);border:1px solid rgba(247,147,26,0.3)}
|
||||
.btn-export{background:rgba(108,99,255,0.2);color:#a78bfa;border:1px solid rgba(108,99,255,0.3)}
|
||||
.btn-bulk{background:rgba(6,182,212,0.2);color:#06b6d4;border:1px solid rgba(6,182,212,0.3)}
|
||||
.btn:hover{filter:brightness(1.2)}
|
||||
|
||||
.main{flex:1;display:flex;gap:0.5rem;overflow:hidden}
|
||||
.sidebar{width:240px;min-width:200px;background:var(--card);border-radius:var(--radius);padding:0.5rem;overflow-y:auto;border:1px solid rgba(255,255,255,0.05)}
|
||||
.sidebar h3{font-size:0.6rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:0.6rem 0 0.3rem}
|
||||
.sidebar h3:first-child{margin-top:0}
|
||||
.p-btn{display:block;width:100%;padding:0.25rem 0.5rem;margin:0.1rem 0;background:transparent;border:1px solid rgba(255,255,255,0.05);border-radius:3px;color:var(--text);cursor:pointer;font-size:0.68rem;text-align:left}
|
||||
.p-btn:hover{background:rgba(247,147,26,0.08);border-color:var(--accent)}
|
||||
.p-btn.active{background:rgba(247,147,26,0.2);border-color:var(--accent);color:var(--accent);font-weight:700}
|
||||
.p-btn .c{float:right;color:var(--muted);font-size:0.55rem}
|
||||
|
||||
.content{flex:1;background:var(--card);border-radius:var(--radius);padding:0.8rem;overflow-y:auto;border:1px solid rgba(255,255,255,0.05)}
|
||||
|
||||
.tag{display:inline-block;padding:0.1rem 0.35rem;border-radius:3px;font-size:0.6rem;margin:0.1rem;cursor:pointer;font-weight:600}
|
||||
.tag:hover{filter:brightness(1.3)}
|
||||
.t-gambling{background:rgba(255,71,87,0.2);color:var(--danger)}
|
||||
.t-adult{background:rgba(247,147,26,0.2);color:var(--accent)}
|
||||
.t-cbd{background:rgba(34,197,94,0.2);color:var(--success)}
|
||||
.t-ecommerce{background:rgba(0,212,170,0.2);color:var(--accent2)}
|
||||
.t-electronics{background:rgba(59,130,246,0.2);color:#60a5fa}
|
||||
.t-nutra{background:rgba(168,85,247,0.2);color:#a78bfa}
|
||||
.t-firearms{background:rgba(148,163,184,0.2);color:#94a3b8}
|
||||
.t-forex{background:rgba(251,191,36,0.2);color:#fbbf24}
|
||||
.t-travel{background:rgba(6,182,212,0.2);color:#06b6d4}
|
||||
.t-subscription{background:rgba(236,72,153,0.2);color:#ec4899}
|
||||
.t-crypto{background:rgba(247,147,26,0.2);color:var(--accent)}
|
||||
.t-gaming{background:rgba(59,130,246,0.2);color:#60a5fa}
|
||||
.t-retail{background:rgba(46,213,115,0.2);color:var(--success)}
|
||||
.t-active{outline:2px solid #fff}
|
||||
|
||||
.site-table{width:100%;border-collapse:collapse;font-size:0.72rem}
|
||||
.site-table th{text-align:left;padding:0.4rem 0.5rem;border-bottom:1px solid rgba(255,255,255,0.06);font-size:0.58rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);position:sticky;top:0;background:var(--card)}
|
||||
.site-table td{padding:0.35rem 0.5rem;border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||
.site-table tr:hover{background:rgba(247,147,26,0.03)}
|
||||
.site-table .domain{color:var(--accent2);font-weight:600;cursor:pointer}
|
||||
.site-table .domain:hover{text-decoration:underline}
|
||||
.site-table .notes{color:var(--muted);font-size:0.65rem;max-width:200px}
|
||||
.site-table .actions{white-space:nowrap}
|
||||
|
||||
.dork-box{font-family:'SF Mono',monospace;font-size:0.65rem;padding:0.3rem 0.5rem;background:rgba(0,0,0,0.4);border-radius:3px;margin:0.2rem 0;border-left:2px solid var(--accent)}
|
||||
.dork-box a{color:var(--accent2);text-decoration:none}
|
||||
|
||||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;align-items:center;justify-content:center}
|
||||
.modal-overlay.show{display:flex}
|
||||
.modal{background:var(--card);padding:1.5rem;border-radius:var(--radius);border:1px solid rgba(255,255,255,0.1);min-width:400px;max-width:600px}
|
||||
.modal h3{margin-bottom:1rem}
|
||||
.modal label{display:block;font-size:0.65rem;color:var(--muted);margin:0.5rem 0 0.15rem;text-transform:uppercase;letter-spacing:0.5px}
|
||||
.modal input,.modal select,.modal textarea{width:100%;background:rgba(0,0,0,0.4);color:var(--text);border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:0.4rem;font-size:0.75rem}
|
||||
.modal textarea{height:80px;font-family:monospace}
|
||||
.modal .btn-row{display:flex;gap:0.5rem;margin-top:1rem;justify-content:flex-end}
|
||||
|
||||
.empty-state{text-align:center;padding:3rem;color:var(--muted)}
|
||||
.empty-state .big{font-size:3rem}
|
||||
|
||||
.dork-open-all{display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.5rem}
|
||||
|
||||
.badge{display:inline-block;padding:0.1rem 0.35rem;border-radius:3px;font-size:0.55rem;font-weight:700}
|
||||
.b-ok{background:rgba(46,213,115,0.15);color:var(--success)}
|
||||
.b-warn{background:rgba(255,165,2,0.15);color:var(--warning)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<h1>🔍 Payment Recon</h1>
|
||||
<div class="sub"><span id="stats"></span></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.3rem;">
|
||||
<button class="btn btn-add" onclick="showAddModal()">+ Add Site</button>
|
||||
<button class="btn btn-bulk" onclick="showBulkModal()">📋 Bulk Import</button>
|
||||
<button class="btn btn-dork" onclick="showDorkPanel()">🔍 Dorks</button>
|
||||
<button class="btn btn-export" onclick="exportData()">⬇ Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<select id="proc-filter" onchange="applyFilters()"><option value="">All Processors</option></select>
|
||||
<select id="industry-filter" onchange="applyFilters()"><option value="">All Industries</option></select>
|
||||
<input type="text" id="search-input" placeholder="Search domain..." oninput="applyFilters()">
|
||||
<span style="font-size:0.65rem;color:var(--muted);" id="result-count"></span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="sidebar">
|
||||
<h3>HIGH-RISK</h3><div id="hr-nav"></div>
|
||||
<h3>MID-TIER</h3><div id="mt-nav"></div>
|
||||
<h3>GAMBLING</h3><div id="gb-nav"></div>
|
||||
<h3 style="margin-top:0.8rem;">INDUSTRIES</h3><div id="ind-nav" style="line-height:1.8;"></div>
|
||||
</div>
|
||||
<div class="content" id="content">
|
||||
<div class="empty-state"><div class="big">🔍</div><p>No sites cataloged yet.</p><p style="font-size:0.7rem;margin-top:0.3rem;">Click "+ Add Site" to start building your database,<br>or use the Dorks panel to discover real sites on Google.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ADD SITE MODAL -->
|
||||
<div class="modal-overlay" id="add-modal">
|
||||
<div class="modal">
|
||||
<h3>Add Site</h3>
|
||||
<label>Domain</label><input type="text" id="add-domain" placeholder="store.example.com">
|
||||
<label>Processor</label><select id="add-proc"></select>
|
||||
<label>Industry</label><select id="add-industry">
|
||||
<option>ecommerce</option><option>electronics</option><option>gambling</option><option>adult</option><option>cbd</option><option>nutra</option><option>firearms</option><option>forex</option><option>crypto</option><option>travel</option><option>subscription</option><option>gaming</option><option>retail</option>
|
||||
</select>
|
||||
<label>Notes</label><textarea id="add-notes" placeholder="What does this site sell? How did you find it?"></textarea>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="closeModal('add-modal')" style="background:var(--muted)">Cancel</button>
|
||||
<button class="btn btn-add" onclick="addSite()">Add Site</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BULK IMPORT MODAL -->
|
||||
<div class="modal-overlay" id="bulk-modal">
|
||||
<div class="modal">
|
||||
<h3>Bulk Import</h3>
|
||||
<label>Paste domains (one per line) with optional industry</label>
|
||||
<textarea id="bulk-text" placeholder="store.example.com electronics casino-site.com gambling shop.example.com ecommerce"></textarea>
|
||||
<label>Default Processor</label><select id="bulk-proc"></select>
|
||||
<label>Default Industry</label><select id="bulk-industry"><option>ecommerce</option><option>electronics</option><option>gambling</option><option>adult</option><option>cbd</option><option>nutra</option><option>firearms</option><option>forex</option><option>crypto</option><option>travel</option><option>subscription</option><option>gaming</option><option>retail</option></select>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="closeModal('bulk-modal')" style="background:var(--muted)">Cancel</button>
|
||||
<button class="btn btn-add" onclick="bulkImport()">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DORK PANEL -->
|
||||
<div class="modal-overlay" id="dork-modal">
|
||||
<div class="modal">
|
||||
<h3>Google Dorks — Find Real Sites</h3>
|
||||
<div id="dork-proc-select" style="margin-bottom:0.5rem;">
|
||||
<select id="dork-proc" onchange="showDorks()"></select>
|
||||
<select id="dork-industry" onchange="showDorks()"><option value="">+ industry filter</option></select>
|
||||
</div>
|
||||
<div id="dork-results"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-dork" onclick="document.querySelectorAll('#dork-results a').forEach(a=>window.open(a.href,'_blank'))">Open All in Tabs</button>
|
||||
<button class="btn" onclick="closeModal('dork-modal')" style="background:var(--muted)">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── PROCESSOR DATABASE (no fake sites, just metadata + dorks) ───
|
||||
const DB = {
|
||||
"PaymentCloud":{type:"highrisk",url:"paymentcloud.com",inds:["gambling","adult","cbd","nutra","firearms","crypto","subscription"],
|
||||
dorks:['"PaymentCloud" site:*.com -site:paymentcloud.com','"powered by PaymentCloud"','inurl:checkout "PaymentCloud"']},
|
||||
"eMerchantBroker":{type:"highrisk",url:"emerchantbroker.com",inds:["gambling","adult","cbd","nutra","subscription","forex","crypto"],
|
||||
dorks:['"eMerchantBroker" site:*.com -site:emerchantbroker.com','"powered by eMerchantBroker"','inurl:checkout "eMerchantBroker"']},
|
||||
"PayKings":{type:"highrisk",url:"paykings.com",inds:["gambling","adult","cbd","firearms","nutra"],
|
||||
dorks:['"PayKings" site:*.com -site:paykings.com','"processed by PayKings"']},
|
||||
"Soar Payments":{type:"highrisk",url:"soarpay.com",inds:["gambling","adult","cbd","nutra","travel","subscription"],
|
||||
dorks:['"Soar Payments" site:*.com -site:soarpay.com','"powered by Soar" payment']},
|
||||
"Durango Merchant":{type:"highrisk",url:"durangomerchantservices.com",inds:["gambling","adult","firearms","nutra","subscription"],
|
||||
dorks:['"Durango Merchant" site:*.com -site:durangomerchantservices.com','"processed by Durango"']},
|
||||
"Helcim":{type:"midtier",url:"helcim.com",inds:["ecommerce","electronics","retail"],
|
||||
dorks:['site:*.com "js.helcim.com"','"Powered by Helcim"','"Helcim" inurl:checkout -site:helcim.com']},
|
||||
"CardX":{type:"midtier",url:"cardx.com",inds:["ecommerce","subscription"],
|
||||
dorks:['"CardX" site:*.com -site:cardx.com','"powered by CardX"','inurl:payment "CardX"']},
|
||||
"Forte":{type:"midtier",url:"forte.net",inds:["ecommerce","subscription"],
|
||||
dorks:['"Forte Payments" site:*.com -site:forte.net','"forte.net" checkout']},
|
||||
"CDGcommerce":{type:"midtier",url:"cdgcommerce.com",inds:["ecommerce","electronics"],
|
||||
dorks:['"CDGcommerce" site:*.com -site:cdgcommerce.com','"powered by CDGcommerce"']},
|
||||
"Adyen":{type:"midtier",url:"adyen.com",inds:["ecommerce","electronics","travel","subscription","gambling","gaming"],
|
||||
dorks:['site:*.com "checkout.adyen.com"','"Powered by Adyen"','"Adyen" inurl:checkout -site:adyen.com']},
|
||||
"Stripe":{type:"midtier",url:"stripe.com",inds:["ecommerce","electronics","subscription","travel","retail"],
|
||||
dorks:['site:*.com "js.stripe.com"','"Powered by Stripe"','"stripe.com/checkout" site:*.com']},
|
||||
"MuchBetter":{type:"gambling",url:"muchbetter.com",inds:["gambling"],
|
||||
dorks:['"Pay with MuchBetter" casino OR betting','"MuchBetter" inurl:deposit','"MuchBetter" casino OR sportsbook']},
|
||||
"AstroPay":{type:"gambling",url:"astropay.com",inds:["gambling","forex"],
|
||||
dorks:['"AstroPay" casino OR betting OR sportsbook','"Pay with AstroPay" site:*.com']},
|
||||
"Trustly":{type:"gambling",url:"trustly.com",inds:["gambling","ecommerce","travel"],
|
||||
dorks:['"Trustly" casino OR betting OR sportsbook','"Pay with Trustly" site:*.com','"Trustly" "Pay N Play" casino']},
|
||||
"Zimpler":{type:"gambling",url:"zimpler.com",inds:["gambling"],
|
||||
dorks:['"Zimpler" casino OR betting','"Pay with Zimpler" site:*.com']},
|
||||
"Interac":{type:"gambling",url:"interac.ca",inds:["gambling"],
|
||||
dorks:['"Interac" casino OR betting site:*.ca','"Pay with Interac" casino']},
|
||||
};
|
||||
|
||||
// ─── STATE (localStorage) ───
|
||||
const STORAGE_KEY = 'payment_recon_sites';
|
||||
let sites = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
let activeProc = null;
|
||||
let activeInd = null;
|
||||
|
||||
// ─── INIT ───
|
||||
function init(){
|
||||
buildSidebar();
|
||||
populateFilters();
|
||||
applyFilters();
|
||||
updateStats();
|
||||
}
|
||||
function buildSidebar(){
|
||||
const sections={hr:[],mt:[],gb:[]};
|
||||
for(const[n,p]of Object.entries(DB)){
|
||||
if(p.type==='highrisk') sections.hr.push(n);
|
||||
else if(p.type==='midtier') sections.mt.push(n);
|
||||
else sections.gb.push(n);
|
||||
}
|
||||
for(const[id,names]of Object.entries({hr:'hr-nav',mt:'mt-nav',gb:'gb-nav'})){
|
||||
document.getElementById(id).innerHTML=names.map(n=>{
|
||||
const count=sites.filter(s=>s.processor===n).length;
|
||||
return `<button class="p-btn" onclick="filterByProc('${n}')" id="pbtn-${n.replace(/\s/g,'')}">${n}<span class="c">${count}</span></button>`;
|
||||
}).join('');
|
||||
}
|
||||
// Industries
|
||||
const ics={};
|
||||
for(const[,p]of Object.entries(DB)) for(const i of p.inds) ics[i]=(ics[i]||0)+1;
|
||||
document.getElementById('ind-nav').innerHTML=Object.entries(ics).sort((a,b)=>b[1]-a[1]).map(([i,c])=>
|
||||
`<span class="tag t-${i}" onclick="filterByInd('${i}')" id="itag-${i}">${i} (${c})</span>`
|
||||
).join(' ');
|
||||
}
|
||||
function populateFilters(){
|
||||
const ps=document.getElementById('proc-filter');
|
||||
ps.innerHTML='<option value="">All Processors</option>'+Object.keys(DB).map(n=>`<option>${n}</option>`).join('');
|
||||
const allInds=new Set();
|
||||
for(const[,p]of Object.entries(DB)) for(const i of p.inds) allInds.add(i);
|
||||
const is=document.getElementById('industry-filter');
|
||||
is.innerHTML='<option value="">All Industries</option>'+[...allInds].sort().map(i=>`<option>${i}</option>`).join('');
|
||||
// Modals
|
||||
document.getElementById('add-proc').innerHTML=Object.keys(DB).map(n=>`<option>${n}</option>`).join('');
|
||||
document.getElementById('bulk-proc').innerHTML='<option value="">-- Select --</option>'+Object.keys(DB).map(n=>`<option>${n}</option>`).join('');
|
||||
document.getElementById('dork-proc').innerHTML='<option value="">-- Select Processor --</option>'+Object.keys(DB).map(n=>`<option>${n}</option>`).join('');
|
||||
const di=document.getElementById('dork-industry');
|
||||
di.innerHTML='<option value="">+ industry filter</option>'+[...allInds].sort().map(i=>`<option>${i}</option>`).join('');
|
||||
}
|
||||
|
||||
// ─── FILTERS ───
|
||||
function filterByProc(name){
|
||||
activeProc=(activeProc===name)?null:name;activeInd=null;
|
||||
document.querySelectorAll('.p-btn').forEach(b=>b.classList.remove('active'));
|
||||
if(activeProc) document.getElementById('pbtn-'+name.replace(/\s/g,'')).classList.add('active');
|
||||
document.querySelectorAll('.tag').forEach(t=>t.classList.remove('t-active'));
|
||||
document.getElementById('proc-filter').value=activeProc||'';
|
||||
document.getElementById('industry-filter').value='';
|
||||
applyFilters();
|
||||
}
|
||||
function filterByInd(ind){
|
||||
activeInd=(activeInd===ind)?null:ind;activeProc=null;
|
||||
document.querySelectorAll('.tag').forEach(t=>t.classList.remove('t-active'));
|
||||
if(activeInd) document.getElementById('itag-'+ind)?.classList.add('t-active');
|
||||
document.querySelectorAll('.p-btn').forEach(b=>b.classList.remove('active'));
|
||||
document.getElementById('proc-filter').value='';
|
||||
document.getElementById('industry-filter').value=activeInd||'';
|
||||
applyFilters();
|
||||
}
|
||||
function applyFilters(){
|
||||
const pf=document.getElementById('proc-filter').value;
|
||||
const inf=document.getElementById('industry-filter').value;
|
||||
const q=document.getElementById('search-input').value.toLowerCase();
|
||||
if(pf) activeProc=pf;
|
||||
if(inf) activeInd=inf;
|
||||
|
||||
let filtered=sites;
|
||||
if(activeProc) filtered=filtered.filter(s=>s.processor===activeProc);
|
||||
if(activeInd) filtered=filtered.filter(s=>s.industry===activeInd);
|
||||
if(q) filtered=filtered.filter(s=>s.domain.toLowerCase().includes(q)||(s.notes||'').toLowerCase().includes(q));
|
||||
|
||||
renderSites(filtered);
|
||||
document.getElementById('result-count').textContent=`${filtered.length} site${filtered.length!==1?'s':''}`;
|
||||
}
|
||||
|
||||
// ─── RENDER ───
|
||||
function renderSites(list){
|
||||
const c=document.getElementById('content');
|
||||
if(!list.length){
|
||||
c.innerHTML=`<div class="empty-state"><div class="big">🔍</div><p>No sites match your filters.</p><p style="font-size:0.7rem;margin-top:0.3rem;">Click <strong>+ Add Site</strong> or use <strong>Dorks</strong> to find real sites on Google.</p></div>`;
|
||||
return;
|
||||
}
|
||||
c.innerHTML=`
|
||||
<table class="site-table">
|
||||
<thead><tr><th>Domain</th><th>Processor</th><th>Industry</th><th>Notes</th><th></th></tr></thead>
|
||||
<tbody>${list.map((s,i)=>{
|
||||
const idx=sites.indexOf(s);
|
||||
return `<tr>
|
||||
<td><span class="domain" onclick="window.open('https://${s.domain}','_blank')">${s.domain}</span></td>
|
||||
<td><span style="color:var(--accent);cursor:pointer;font-size:0.68rem;" onclick="filterByProc('${s.processor}')">${s.processor}</span></td>
|
||||
<td><span class="tag t-${s.industry}" onclick="filterByInd('${s.industry}')">${s.industry}</span></td>
|
||||
<td class="notes">${s.notes||''}</td>
|
||||
<td class="actions"><button class="btn btn-remove" style="padding:0.15rem 0.4rem;font-size:0.6rem;" onclick="removeSite(${idx})">✕</button></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// ─── CRUD ───
|
||||
function showAddModal(){document.getElementById('add-modal').classList.add('show')}
|
||||
function showBulkModal(){document.getElementById('bulk-modal').classList.add('show')}
|
||||
function showDorkPanel(){document.getElementById('dork-modal').classList.add('show');showDorks();}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('show')}
|
||||
|
||||
function addSite(){
|
||||
const d=document.getElementById('add-domain').value.trim().replace(/^https?:\/\//,'').replace(/\/.*/,'');
|
||||
const p=document.getElementById('add-proc').value;
|
||||
const i=document.getElementById('add-industry').value;
|
||||
const n=document.getElementById('add-notes').value.trim();
|
||||
if(!d||!p||!i) return alert('Domain, processor, and industry required');
|
||||
sites.push({domain:d,processor:p,industry:i,notes:n,added:new Date().toISOString()});
|
||||
save();
|
||||
closeModal('add-modal');
|
||||
document.getElementById('add-domain').value='';
|
||||
document.getElementById('add-notes').value='';
|
||||
buildSidebar();
|
||||
applyFilters();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function bulkImport(){
|
||||
const text=document.getElementById('bulk-text').value.trim();
|
||||
const defProc=document.getElementById('bulk-proc').value;
|
||||
const defInd=document.getElementById('bulk-industry').value;
|
||||
if(!text) return;
|
||||
const lines=text.split('\n').map(l=>l.trim()).filter(l=>l);
|
||||
let added=0;
|
||||
for(const line of lines){
|
||||
const parts=line.split(/\s+/);
|
||||
const domain=parts[0].replace(/^https?:\/\//,'').replace(/\/.*/,'');
|
||||
const industry=parts[1]||defInd;
|
||||
if(!domain||!defProc) continue;
|
||||
if(sites.find(s=>s.domain===domain&&s.processor===defProc)) continue;
|
||||
sites.push({domain,processor:defProc,industry,notes:'',added:new Date().toISOString()});
|
||||
added++;
|
||||
}
|
||||
save();
|
||||
closeModal('bulk-modal');
|
||||
document.getElementById('bulk-text').value='';
|
||||
buildSidebar();
|
||||
applyFilters();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function removeSite(idx){
|
||||
if(confirm('Remove '+sites[idx].domain+'?')){
|
||||
sites.splice(idx,1);
|
||||
save();
|
||||
buildSidebar();
|
||||
applyFilters();
|
||||
updateStats();
|
||||
}
|
||||
}
|
||||
|
||||
function save(){localStorage.setItem(STORAGE_KEY,JSON.stringify(sites))}
|
||||
function updateStats(){
|
||||
document.getElementById('stats').textContent=`${Object.keys(DB).length} processors · ${sites.length} sites cataloged`;
|
||||
}
|
||||
|
||||
function exportData(){
|
||||
const blob=new Blob([JSON.stringify(sites,null,2)],{type:'application/json'});
|
||||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||||
a.download='payment-recon-sites.json';a.click();
|
||||
}
|
||||
|
||||
// ─── DORKS ───
|
||||
function showDorks(){
|
||||
const proc=document.getElementById('dork-proc').value;
|
||||
const ind=document.getElementById('dork-industry').value;
|
||||
const r=document.getElementById('dork-results');
|
||||
if(!proc){r.innerHTML='<p style="color:var(--muted);font-size:0.7rem;">Select a processor to see Google dorks.</p>';return}
|
||||
const p=DB[proc];
|
||||
let dorks=p.dorks.slice();
|
||||
if(ind) dorks=dorks.map(d=>d+' '+ind);
|
||||
r.innerHTML=dorks.map(d=>`<div class="dork-box"><a href="https://www.google.com/search?q=${encodeURIComponent(d)}" target="_blank">🔗 ${d}</a></div>`).join('');
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape'){document.querySelectorAll('.modal-overlay.show').forEach(m=>m.classList.remove('show'))}
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='n'){e.preventDefault();showAddModal()}
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
229
templates/btcpay.html
Normal file
229
templates/btcpay.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BTCPay Manager — CCSite Fleet</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #080812; --card: #12121f; --accent: #6c63ff; --accent2: #00d4aa;
|
||||
--text: #e0e0e0; --muted: #777; --danger: #ff4757; --success: #2ed573; --warning: #ffa502;
|
||||
--btc: #f7931a;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', monospace; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
header { background: var(--card); border-bottom: 1px solid rgba(108,99,255,0.2); padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 100; }
|
||||
header h1 { font-size: 1.2rem; font-weight: 800; background: linear-gradient(135deg, var(--btc), var(--accent2)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
header nav a { color: var(--muted); text-decoration: none; font-size: 0.75rem; margin-left: 1rem; padding: 0.3rem 0.7rem; border-radius: 4px; border: 1px solid rgba(108,99,255,0.2); transition: all 0.2s; }
|
||||
header nav a:hover, header nav a.active { color: var(--accent2); border-color: var(--accent2); }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
.card { background: var(--card); padding: 1.5rem; border-radius: var(--radius); border: 1px solid rgba(108,99,255,0.1); margin-bottom: 1rem; }
|
||||
.card h3 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 1rem; }
|
||||
|
||||
.big-number { font-size: 3rem; font-weight: 800; font-family: 'SF Mono', monospace; }
|
||||
.label-sm { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); }
|
||||
|
||||
.progress-bar { height: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; overflow: hidden; margin: 0.5rem 0; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--btc), #ffd700); border-radius: 6px; transition: width 30s ease; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||||
th { text-align: left; padding: 0.7rem 0.5rem; border-bottom: 1px solid rgba(108,99,255,0.1); font-size: 0.6rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
|
||||
td { padding: 0.6rem 0.5rem; border-bottom: 1px solid rgba(108,99,255,0.05); }
|
||||
tr:hover { background: rgba(108,99,255,0.03); }
|
||||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.65rem; font-weight: 600; }
|
||||
.badge-ok { background: rgba(46,213,115,0.15); color: var(--success); }
|
||||
.badge-warn { background: rgba(255,165,2,0.15); color: var(--warning); }
|
||||
.badge-err { background: rgba(255,71,87,0.15); color: var(--danger); }
|
||||
|
||||
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
|
||||
.chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||
.chart-box { background: var(--card); padding: 1.5rem; border-radius: var(--radius); border: 1px solid rgba(108,99,255,0.1); }
|
||||
.chart-box canvas { max-height: 250px; }
|
||||
|
||||
.pulse { animation: pulse 2s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
@media (max-width: 768px) { .chart-row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>₿ BTCPay Server Manager</h1>
|
||||
<span style="font-size:0.7rem;color:var(--muted);">10.30.20.140 · <span id="clock">--</span></span>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/master">Master Panel</a>
|
||||
<a href="/btcpay" class="active">₿ BTCPay</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- Sync Status -->
|
||||
<div class="card" style="border:1px solid rgba(243,156,18,0.3);">
|
||||
<div style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div class="label-sm">Sync Progress</div>
|
||||
<div class="big-number" style="color:var(--btc);" id="sync-pct">--</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:250px;">
|
||||
<div class="progress-bar"><div class="progress-fill" id="sync-bar" style="width:0%"></div></div>
|
||||
<div style="font-size:0.7rem;color:var(--muted);display:flex;justify-content:space-between;">
|
||||
<span id="blocks-display">--</span>
|
||||
<span id="headers-display">--</span>
|
||||
</div>
|
||||
<div style="margin-top:0.5rem;font-size:0.7rem;">
|
||||
<span class="badge" id="sync-badge">Syncing</span>
|
||||
<span style="color:var(--muted);margin-left:0.5rem;">v<span id="btcpay-version">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="label-sm">Wallets Ready</div>
|
||||
<div class="big-number" id="wallets-ready" style="color:var(--warning);">0/10</div>
|
||||
<div class="label-sm">Total Invoices: <span id="total-invoices" style="color:var(--btc);">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="stat-row" id="stat-row"></div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<h3>📈 Sync Progress (24h)</h3>
|
||||
<canvas id="sync-chart" height="200"></canvas>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<h3>🏪 Per-Store Invoice Count</h3>
|
||||
<canvas id="invoice-chart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Table -->
|
||||
<div class="card">
|
||||
<h3>📋 Store Status</h3>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Site</th><th>Store ID</th><th>Wallet</th><th>Invoices</th><th>Webhook</th><th>Status</th></tr></thead>
|
||||
<tbody id="store-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Health -->
|
||||
<div class="card">
|
||||
<h3>🔗 Webhook Status</h3>
|
||||
<div id="webhook-status" style="font-size:0.75rem;color:var(--muted);">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const EM = {1:'⚡',2:'💳',3:'👤',4:'🔑',5:'₿',6:'🎁',7:'⭐',8:'🔥',9:'🎮',10:'🏆'};
|
||||
let syncChart, invoiceChart;
|
||||
|
||||
function initCharts() {
|
||||
syncChart = new Chart(document.getElementById('sync-chart').getContext('2d'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [{ label: 'Sync %', data: [], borderColor: '#f7931a', backgroundColor: 'rgba(247,147,26,0.1)', fill: true, tension: 0.3, pointRadius: 0 }] },
|
||||
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } },
|
||||
scales: { x: { ticks: { color: '#777', font: { size: 9 } } }, y: { min: 0, max: 100, ticks: { color: '#777', callback: v => v+'%' } } } }
|
||||
});
|
||||
invoiceChart = new Chart(document.getElementById('invoice-chart').getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: { labels: [], datasets: [{ label: 'Invoices', data: [], backgroundColor: '#f7931a', borderRadius: 4 }] },
|
||||
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } },
|
||||
scales: { x: { ticks: { color: '#777', font: { size: 8 } } }, y: { ticks: { color: '#777' }, min: 0 } } }
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/api/state');
|
||||
const data = await r.json();
|
||||
const bp = data.btcpay || {};
|
||||
const sites = data.sites || {};
|
||||
|
||||
// Sync status
|
||||
document.getElementById('sync-pct').textContent = (bp.sync_pct||0).toFixed(1) + '%';
|
||||
document.getElementById('sync-bar').style.width = (bp.sync_pct||0) + '%';
|
||||
document.getElementById('blocks-display').textContent = (bp.blocks||0).toLocaleString() + ' blocks synced';
|
||||
document.getElementById('headers-display').textContent = 'Chain height: ' + (bp.headers||'?').toLocaleString();
|
||||
document.getElementById('btcpay-version').textContent = bp.version || '?';
|
||||
|
||||
const badge = document.getElementById('sync-badge');
|
||||
if (bp.synced) {
|
||||
badge.textContent = '✅ Synced'; badge.className = 'badge badge-ok';
|
||||
} else {
|
||||
badge.textContent = '⏳ Syncing'; badge.className = 'badge badge-warn';
|
||||
}
|
||||
|
||||
const wallets = bp.stores_with_wallet || 0;
|
||||
const total = bp.total_stores || 10;
|
||||
const wel = document.getElementById('wallets-ready');
|
||||
wel.textContent = wallets + '/' + total;
|
||||
wel.style.color = wallets === total ? 'var(--success)' : 'var(--warning)';
|
||||
document.getElementById('total-invoices').textContent = bp.total_invoices || 0;
|
||||
|
||||
// Stats row
|
||||
document.getElementById('stat-row').innerHTML = `
|
||||
<div class="card"><div class="label-sm">BTCPay Server</div><div class="big-number" style="font-size:1.5rem;color:var(--accent2);">v${bp.version||'?'}</div></div>
|
||||
<div class="card"><div class="label-sm">Total Stores</div><div class="big-number" style="font-size:1.5rem;">${total}</div></div>
|
||||
<div class="card"><div class="label-sm">Wallets Ready</div><div class="big-number" style="font-size:1.5rem;color:${wallets===total?'var(--success)':'var(--warning)'};">${wallets}/${total}</div></div>
|
||||
<div class="card"><div class="label-sm">Total Invoices</div><div class="big-number" style="font-size:1.5rem;color:var(--btc);">${bp.total_invoices||0}</div></div>
|
||||
`;
|
||||
|
||||
// Store table
|
||||
const table = document.getElementById('store-table');
|
||||
table.innerHTML = '';
|
||||
for (let i=1; i<=10; i++) {
|
||||
const s = bp.stores?.[i] || {};
|
||||
const site = sites[i] || {};
|
||||
table.innerHTML += `
|
||||
<tr>
|
||||
<td style="color:var(--muted);">#${i}</td>
|
||||
<td>${EM[i]} ${site.name||'?'}</td>
|
||||
<td style="font-family:'SF Mono',monospace;font-size:0.65rem;color:var(--muted);">${s.store_id||'?'}</td>
|
||||
<td><span class="badge ${s.has_wallet?'badge-ok':'badge-warn'}">${s.has_wallet?'Ready':'Pending'}</span></td>
|
||||
<td>${s.invoice_count||0}</td>
|
||||
<td><span class="badge badge-ok">Active</span></td>
|
||||
<td><a href="http://${site.ip}:5000" target="_blank" style="color:var(--accent);font-size:0.65rem;">Visit →</a></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Invoice chart
|
||||
const labels = [], invCounts = [];
|
||||
for (let i=1; i<=10; i++) {
|
||||
labels.push(EM[i]);
|
||||
invCounts.push(bp.stores?.[i]?.invoice_count || 0);
|
||||
}
|
||||
invoiceChart.data.labels = labels;
|
||||
invoiceChart.data.datasets[0].data = invCounts;
|
||||
invoiceChart.update();
|
||||
|
||||
// Sync chart
|
||||
if (data.btcpay_history && data.btcpay_history.length > 1) {
|
||||
syncChart.data.labels = data.btcpay_history.map(h => new Date(h.ts*1000).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}));
|
||||
syncChart.data.datasets[0].data = data.btcpay_history.map(h => h.sync_pct);
|
||||
syncChart.update();
|
||||
}
|
||||
|
||||
// Webhook summary
|
||||
document.getElementById('webhook-status').innerHTML = `
|
||||
All 10 stores have webhooks configured → <code style="color:var(--btc);">http://10.30.20.{210-221}:5000/webhook/btcpay</code><br>
|
||||
<span style="color:var(--muted);">Webhooks fire on: InvoiceReceivedPayment, InvoiceProcessing, InvoiceSettled</span><br>
|
||||
<span style="color:var(--muted);">Auto-redelivery: enabled on all stores</span>
|
||||
`;
|
||||
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
setInterval(() => document.getElementById('clock').textContent = new Date().toLocaleTimeString(), 1000);
|
||||
initCharts();
|
||||
refresh();
|
||||
setInterval(refresh, 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
324
templates/dashboard.html
Normal file
324
templates/dashboard.html
Normal file
@@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FLEET OPS — CCSite Mission Control</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
:root {
|
||||
--bg:#07090f; --panel:#0d1117; --panel2:#11161f; --line:#1c2432;
|
||||
--txt:#e6edf6; --muted:#7d8aa0; --dim:#4a5668;
|
||||
--green:#2ea043; --green2:#3fb950; --red:#f85149; --amber:#d29922;
|
||||
--cyan:#39c5cf; --purple:#a371f7; --pink:#f778ba; --orange:#ff9f43;
|
||||
}
|
||||
body { background:var(--bg); color:var(--txt); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; padding:18px 22px 60px; }
|
||||
.mono { font-family:'SF Mono',Menlo,monospace; }
|
||||
|
||||
/* ── header ── */
|
||||
header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:18px; }
|
||||
.brand { display:flex; align-items:center; gap:12px; }
|
||||
.brand .logo { width:38px;height:38px;border-radius:10px;background:linear-gradient(135deg,#1f6feb,#a371f7);display:flex;align-items:center;justify-content:center;font-size:20px; }
|
||||
.brand h1 { font-size:1.25rem; letter-spacing:2px; font-weight:800; }
|
||||
.brand h1 span { color:var(--cyan); }
|
||||
.brand .sub { font-size:0.68rem; color:var(--muted); letter-spacing:1.5px; text-transform:uppercase; }
|
||||
.hdr-right { display:flex; align-items:center; gap:14px; font-size:0.75rem; color:var(--muted); }
|
||||
.pulse { width:8px;height:8px;border-radius:50%;background:var(--green2);display:inline-block;margin-right:6px;animation:pl 2s infinite; }
|
||||
@keyframes pl { 0%,100%{box-shadow:0 0 0 0 rgba(63,185,80,.5)} 50%{box-shadow:0 0 0 6px rgba(63,185,80,0)} }
|
||||
.nav { display:flex; gap:8px; margin-left:10px; }
|
||||
.nav a { font-size:0.7rem; color:var(--muted); text-decoration:none; padding:4px 10px; border:1px solid var(--line); border-radius:6px; }
|
||||
.nav a:hover { color:var(--txt); border-color:var(--cyan); }
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:10px; margin-bottom:14px; }
|
||||
.kpi { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; position:relative; overflow:hidden; }
|
||||
.kpi::after { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:var(--kc,var(--cyan)); opacity:.7; }
|
||||
.kpi .l { font-size:0.62rem; color:var(--muted); text-transform:uppercase; letter-spacing:1.2px; margin-bottom:5px; }
|
||||
.kpi .v { font-size:1.45rem; font-weight:800; font-variant-numeric:tabular-nums; }
|
||||
.kpi .d { font-size:0.66rem; color:var(--dim); margin-top:3px; }
|
||||
.kpi .d b { color:var(--green2); font-weight:600; }
|
||||
|
||||
/* ── charts row ── */
|
||||
.charts { display:grid; grid-template-columns:2fr 1.4fr 1fr 0.9fr; gap:10px; margin-bottom:14px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; }
|
||||
.panel h3 { font-size:0.68rem; color:var(--muted); text-transform:uppercase; letter-spacing:1.4px; margin-bottom:10px; display:flex; justify-content:space-between; }
|
||||
.panel h3 .tag { color:var(--dim); font-weight:400; letter-spacing:0; text-transform:none; }
|
||||
canvas { display:block; width:100%; }
|
||||
|
||||
/* ── main grid ── */
|
||||
.main { display:grid; grid-template-columns:1fr 340px; gap:12px; margin-bottom:14px; }
|
||||
.sites { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:10px; }
|
||||
.site { background:var(--panel2); border:1px solid var(--line); border-radius:12px; padding:12px; transition:border-color .2s; position:relative; }
|
||||
.site:hover { border-color:#2f3d52; }
|
||||
.site.off { opacity:.55; }
|
||||
.site .hd { display:flex; align-items:center; gap:8px; margin-bottom:9px; }
|
||||
.site .em { font-size:1.3rem; }
|
||||
.site .nm { font-weight:700; font-size:0.9rem; }
|
||||
.site .ip { font-size:0.62rem; color:var(--dim); font-family:Menlo,monospace; }
|
||||
.dot { width:8px;height:8px;border-radius:50%;margin-left:auto; }
|
||||
.dot.on { background:var(--green2); box-shadow:0 0 8px var(--green2); }
|
||||
.dot.off { background:var(--red); box-shadow:0 0 8px var(--red); }
|
||||
.site .nums { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px; text-align:center; }
|
||||
.site .nums .n { font-size:0.95rem; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.site .nums .t { font-size:0.56rem; color:var(--dim); text-transform:uppercase; letter-spacing:.8px; }
|
||||
.site .ft { display:flex; justify-content:space-between; align-items:center; margin-top:9px; font-size:0.64rem; color:var(--muted); }
|
||||
.badge-pend { background:rgba(210,153,34,.15); color:var(--amber); border:1px solid rgba(210,153,34,.35); padding:1px 7px; border-radius:10px; font-size:0.62rem; font-weight:600; }
|
||||
.badge-none { color:var(--dim); font-size:0.62rem; }
|
||||
|
||||
/* ── right rail feeds ── */
|
||||
.rail { display:flex; flex-direction:column; gap:12px; }
|
||||
.feed { max-height:300px; overflow-y:auto; scrollbar-width:thin; scrollbar-color:var(--line) transparent; }
|
||||
.fi { display:flex; gap:9px; padding:7px 2px; border-bottom:1px solid var(--line); font-size:0.74rem; align-items:baseline; }
|
||||
.fi:last-child { border-bottom:none; }
|
||||
.fi .ic { flex-shrink:0; }
|
||||
.fi .tt { color:var(--dim); font-size:0.62rem; margin-left:auto; flex-shrink:0; font-family:Menlo,monospace; }
|
||||
.fi .amt { color:var(--green2); font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.fi .st-p { color:var(--amber); } .fi .st-d { color:var(--green2); }
|
||||
.fi .mb { font-size:0.6rem; padding:1px 6px; border-radius:8px; background:rgba(163,113,247,.15); color:var(--purple); border:1px solid rgba(163,113,247,.3); }
|
||||
|
||||
/* ── bottom tables ── */
|
||||
.bottom { display:grid; grid-template-columns:1.6fr 1fr 1fr; gap:12px; }
|
||||
table { width:100%; border-collapse:collapse; font-size:0.74rem; }
|
||||
th { text-align:left; color:var(--dim); font-size:0.6rem; text-transform:uppercase; letter-spacing:1px; padding:5px 8px; border-bottom:1px solid var(--line); }
|
||||
td { padding:6px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
|
||||
tr:last-child td { border-bottom:none; }
|
||||
td.num, th.num { text-align:right; }
|
||||
.rank { color:var(--dim); font-family:Menlo,monospace; width:22px; }
|
||||
.bar { height:4px; border-radius:2px; background:linear-gradient(90deg,var(--cyan),var(--purple)); margin-top:4px; }
|
||||
.funnel { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||
.fstep { flex:1; min-width:90px; text-align:center; background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:10px 6px; }
|
||||
.fstep .n { font-size:1.2rem; font-weight:800; }
|
||||
.fstep .l { font-size:0.58rem; color:var(--muted); text-transform:uppercase; letter-spacing:1px; margin-top:2px; }
|
||||
.farrow { color:var(--dim); font-size:1rem; }
|
||||
.stores { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.store-chip { font-size:0.64rem; padding:3px 9px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); }
|
||||
.store-chip b { color:var(--green2); }
|
||||
.store-chip .nw { color:var(--red); }
|
||||
.empty { color:var(--dim); font-size:0.72rem; padding:14px 4px; text-align:center; }
|
||||
|
||||
@media (max-width:1100px) { .charts { grid-template-columns:1fr 1fr; } .main { grid-template-columns:1fr; } .bottom { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo">🛰️</div>
|
||||
<div>
|
||||
<h1>FLEET <span>OPS</span></h1>
|
||||
<div class="sub">CCSite Mission Control</div>
|
||||
</div>
|
||||
<div class="nav">
|
||||
<a href="/master">Master</a><a href="/btcpay">BTCPay</a><a href="/watchdog">Watchdog</a><a href="/recon">Recon</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hdr-right">
|
||||
<span><span class="pulse"></span>live · poll <span id="poll-age">—</span></span>
|
||||
<span class="mono" id="clock">—</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="charts">
|
||||
<div class="panel"><h3>Revenue — fleet history <span class="tag" id="rev-total"></span></h3><canvas id="ch-rev" height="150"></canvas></div>
|
||||
<div class="panel"><h3>Activity — 14 days <span class="tag">orders vs signups</span></h3><canvas id="ch-act" height="150"></canvas></div>
|
||||
<div class="panel"><h3>Users by site</h3><canvas id="ch-users" height="150"></canvas></div>
|
||||
<div class="panel"><h3>BTC node</h3><canvas id="ch-btc" height="150"></canvas><div id="btc-sub" style="font-size:.64rem;color:var(--muted);text-align:center;margin-top:4px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div>
|
||||
<div class="panel" style="margin-bottom:12px;"><h3>Fleet — 10 storefronts <span class="tag" id="fleet-tag"></span></h3>
|
||||
<div class="sites" id="sites"></div>
|
||||
</div>
|
||||
<div class="panel"><h3>Conversion funnel</h3><div class="funnel" id="funnel"></div></div>
|
||||
</div>
|
||||
<div class="rail">
|
||||
<div class="panel"><h3>💸 Live orders <span class="tag" id="ord-count"></span></h3><div class="feed" id="orders"></div></div>
|
||||
<div class="panel"><h3>👤 Signups <span class="tag" id="usr-count"></span></h3><div class="feed" id="signups"></div></div>
|
||||
<div class="panel"><h3>⚡ Events</h3><div class="feed" id="events"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="panel"><h3>🏆 Site leaderboard — by revenue</h3><table id="board"></table></div>
|
||||
<div class="panel"><h3>🔥 Top products</h3><table id="top-prod"></table></div>
|
||||
<div class="panel"><h3>₿ BTCPay stores <span class="tag" id="btcpay-tag"></span></h3>
|
||||
<div class="stores" id="stores"></div>
|
||||
<h3 style="margin-top:14px;">Recent chain activity</h3>
|
||||
<div id="btc-recent" style="font-size:.7rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const money = n => '$' + Number(n||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const ago = iso => {
|
||||
if (!iso) return '—';
|
||||
const t = (Date.now() - new Date(iso.replace('T',' ').replace(/\+.*/,'') + (iso.includes('Z')||iso.includes('+')?'':' UTC'))/1000)|0;
|
||||
// ISO dates from shops are naive UTC
|
||||
const s = isNaN(t) || t < 0 ? 0 : t;
|
||||
if (s < 60) return s + 's'; if (s < 3600) return (s/60|0) + 'm';
|
||||
if (s < 86400) return (s/3600|0) + 'h'; return (s/86400|0) + 'd';
|
||||
};
|
||||
const agoTs = ts => ago(new Date(ts*1000).toISOString());
|
||||
|
||||
setInterval(() => { $('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
|
||||
|
||||
/* ── tiny canvas chart kit ── */
|
||||
function setup(cv){ const dpr = devicePixelRatio||1, r = cv.getBoundingClientRect();
|
||||
cv.width = r.width*dpr; cv.height = r.height*dpr;
|
||||
const x = cv.getContext('2d'); x.scale(dpr,dpr); return [x, r.width, r.height]; }
|
||||
function area(cv, pts, color){
|
||||
if(!pts.length){ return; } const [x,W,H] = setup(cv), P=8;
|
||||
const max = Math.max(...pts.map(p=>p.v), 1);
|
||||
const bw = (W-2*P)/Math.max(pts.length-1,1);
|
||||
x.clearRect(0,0,W,H);
|
||||
const grad = x.createLinearGradient(0,0,0,H);
|
||||
grad.addColorStop(0, color+'55'); grad.addColorStop(1, color+'00');
|
||||
x.beginPath(); x.moveTo(P, H-P);
|
||||
pts.forEach((p,i)=> x.lineTo(P+i*bw, H-P-(p.v/max)*(H-2*P-14)));
|
||||
x.lineTo(P+(pts.length-1)*bw, H-P); x.closePath(); x.fillStyle = grad; x.fill();
|
||||
x.beginPath(); pts.forEach((p,i)=> i? x.lineTo(P+i*bw, H-P-(p.v/max)*(H-2*P-14)) : x.moveTo(P, H-P-(p.v/max)*(H-2*P-14)));
|
||||
x.strokeStyle = color; x.lineWidth = 1.8; x.stroke();
|
||||
const lp = pts[pts.length-1];
|
||||
x.fillStyle = color; x.beginPath(); x.arc(P+(pts.length-1)*bw, H-P-(lp.v/max)*(H-2*P-14), 3, 0, 7); x.fill();
|
||||
x.fillStyle = '#7d8aa0'; x.font = '10px Menlo'; x.fillText(money(lp.v), P+4, 12);
|
||||
}
|
||||
function bars(cv, labels, seriesA, seriesB){
|
||||
const [x,W,H] = setup(cv), P=8, n = labels.length; if(!n) return;
|
||||
const max = Math.max(...seriesA, ...seriesB, 1), bw = (W-2*P)/n;
|
||||
x.clearRect(0,0,W,H);
|
||||
labels.forEach((l,i)=>{
|
||||
const a = seriesA[i], b = seriesB[i];
|
||||
x.fillStyle = '#39c5cf';
|
||||
const ha = (a/max)*(H-2*P-12); x.fillRect(P+i*bw+1, H-P-ha, bw*0.55, ha);
|
||||
x.fillStyle = '#a371f7';
|
||||
const hb = (b/max)*(H-2*P-12); x.fillRect(P+i*bw+bw*0.58, H-P-hb, bw*0.35, hb);
|
||||
if(i%2===0){ x.fillStyle='#4a5668'; x.font='8px Menlo'; x.fillText(l.slice(5), P+i*bw, H-1); }
|
||||
});
|
||||
}
|
||||
function donut(cv, items){
|
||||
const [x,W,H] = setup(cv); x.clearRect(0,0,W,H);
|
||||
const total = items.reduce((s,i)=>s+i.v,0); if(!total){ x.fillStyle='#4a5668'; x.font='11px Menlo'; x.fillText('no users yet', 10, H/2); return; }
|
||||
const cols = ['#39c5cf','#a371f7','#f778ba','#ff9f43','#3fb950','#d29922','#f85149','#58a6ff','#7ee787','#e3b341'];
|
||||
const cx = W*0.32, cy = H/2, r = Math.min(W,H)/2 - 10; let a = -Math.PI/2;
|
||||
items.forEach((it,i)=>{ const sw = it.v/total*2*Math.PI;
|
||||
x.beginPath(); x.arc(cx,cy,r,a,a+sw); x.strokeStyle = cols[i%cols.length]; x.lineWidth = 13; x.stroke(); a += sw; });
|
||||
x.fillStyle = '#e6edf6'; x.font = 'bold 15px Menlo'; x.textAlign='center'; x.fillText(total, cx, cy+4); x.textAlign='left';
|
||||
items.slice(0,6).forEach((it,i)=>{ x.fillStyle = cols[i%cols.length]; x.fillRect(W*0.58, 14+i*20, 8, 8);
|
||||
x.fillStyle = '#7d8aa0'; x.font = '9.5px Menlo'; x.fillText(it.k+' ('+it.v+')', W*0.58+13, 21+i*20); });
|
||||
}
|
||||
function gauge(cv, pct, label){
|
||||
const [x,W,H] = setup(cv); x.clearRect(0,0,W,H);
|
||||
const cx = W/2, cy = H*0.62, r = Math.min(W,H)*0.42;
|
||||
x.lineWidth = 11; x.lineCap = 'round';
|
||||
x.beginPath(); x.arc(cx,cy,r,Math.PI,2*Math.PI); x.strokeStyle = '#1c2432'; x.stroke();
|
||||
x.beginPath(); x.arc(cx,cy,r,Math.PI,Math.PI+Math.PI*Math.min(pct,100)/100);
|
||||
x.strokeStyle = pct>=100 ? '#3fb950' : '#d29922'; x.stroke();
|
||||
x.fillStyle = '#e6edf6'; x.font = 'bold 17px Menlo'; x.textAlign='center';
|
||||
x.fillText(pct.toFixed(0)+'%', cx, cy-6);
|
||||
x.fillStyle = '#7d8aa0'; x.font = '9px Menlo'; x.fillText(label, cx, cy+12); x.textAlign='left';
|
||||
}
|
||||
|
||||
/* ── renderers ── */
|
||||
async function refresh(){
|
||||
const [state, orders, signups, board, top, act, fun] = await Promise.all([
|
||||
fetch('/api/state').then(r=>r.json()), fetch('/api/orders?limit=30').then(r=>r.json()),
|
||||
fetch('/api/signups?limit=30').then(r=>r.json()), fetch('/api/leaderboard').then(r=>r.json()),
|
||||
fetch('/api/top-products').then(r=>r.json()), fetch('/api/activity').then(r=>r.json()),
|
||||
fetch('/api/funnel').then(r=>r.json()),
|
||||
]);
|
||||
const t = state.totals||{}, sites = Object.values(state.sites||{});
|
||||
const revToday = sites.reduce((s,x)=>s+(x.revenue_today||0),0);
|
||||
const ordToday = sites.reduce((s,x)=>s+(x.orders_today||0),0);
|
||||
const sgnToday = sites.reduce((s,x)=>s+(x.signups_today||0),0);
|
||||
|
||||
/* KPIs */
|
||||
const k = [
|
||||
['Fleet Revenue', money(fun.revenue), `+${money(revToday)} today`, 'var(--green2)'],
|
||||
['Orders', fun.orders, `+${ordToday} today · AOV ${money(fun.aov)}`, 'var(--cyan)'],
|
||||
['Pending Delivery', fun.pending, fun.pending?'⚠ needs fulfillment':'all clear', fun.pending?'var(--amber)':'var(--green2)'],
|
||||
['Total Users', fun.users, `+${sgnToday} today · ${fun.conversion}% buy`, 'var(--purple)'],
|
||||
['Sites Online', (t.online||0)+'/10', t.offline? t.offline+' DOWN':'all green', t.offline?'var(--red)':'var(--green2)'],
|
||||
['Products Live', t.products||0, 'across fleet', 'var(--pink)'],
|
||||
['Deposits', money(fun.deposits), 'store balances', 'var(--orange)'],
|
||||
['Unique Buyers', fun.unique_customers, 'by email', 'var(--cyan)'],
|
||||
];
|
||||
$('kpis').innerHTML = k.map(x=>`<div class="kpi" style="--kc:${x[3]}"><div class="l">${x[0]}</div><div class="v">${x[1]}</div><div class="d">${x[2]}</div></div>`).join('');
|
||||
|
||||
/* charts */
|
||||
const hist = (state.history||[]).map(h=>({v:h.revenue}));
|
||||
area($('ch-rev'), hist, '#39c5cf');
|
||||
$('rev-total').textContent = hist.length? 'now '+money(hist[hist.length-1].v) : '';
|
||||
const days = [...new Set([...Object.keys(act.daily||{})])].sort();
|
||||
bars($('ch-act'), days, days.map(d=>(act.daily[d]||{}).orders||0), days.map(d=>(act.daily[d]||{}).signups||0));
|
||||
donut($('ch-users'), Object.entries(signups.per_site||{}).map(([sid,v])=>({k:(state.sites[sid]||{}).name||sid, v})).sort((a,b)=>b.v-a.v));
|
||||
const bp = state.btcpay||{};
|
||||
gauge($('ch-btc'), bp.sync_pct||0, 'blocks '+(bp.blocks||'?'));
|
||||
$('btc-sub').textContent = bp.synced ? 'fully synced · v'+(bp.version||'').split('+')[0] : 'syncing…';
|
||||
|
||||
/* sites grid */
|
||||
$('fleet-tag').textContent = (t.online||0)+' online · '+(t.users||0)+' users';
|
||||
$('sites').innerHTML = sites.sort((a,b)=>a.site_id-b.site_id).map(s=>`
|
||||
<div class="site ${s.status!=='online'?'off':''}">
|
||||
<div class="hd"><span class="em">${s.emoji}</span>
|
||||
<div><div class="nm">${s.name}</div><div class="ip">${s.ip} · ${s.response_ms}ms</div></div>
|
||||
<span class="dot ${s.status==='online'?'on':'off'}"></span></div>
|
||||
<div class="nums">
|
||||
<div><div class="n">${s.users}</div><div class="t">users</div></div>
|
||||
<div><div class="n">${s.orders}</div><div class="t">orders</div></div>
|
||||
<div><div class="n">${money(s.revenue)}</div><div class="t">revenue</div></div>
|
||||
</div>
|
||||
<div class="ft"><span>${s.products} products</span>
|
||||
${s.details&&s.details.pending_orders? `<span class="badge-pend">${s.details.pending_orders} pending</span>` : '<span class="badge-none">✓ clear</span>'}
|
||||
</div></div>`).join('');
|
||||
|
||||
/* funnel */
|
||||
const fs = [['Users',fun.users],['Buyers',fun.buyers],['Orders',fun.orders],['Delivered',fun.delivered],['Revenue',money(fun.revenue)]];
|
||||
$('funnel').innerHTML = fs.map((f,i)=>`${i?'<span class="farrow">→</span>':''}<div class="fstep"><div class="n">${f[1]}</div><div class="l">${f[0]}</div></div>`).join('');
|
||||
|
||||
/* feeds */
|
||||
$('ord-count').textContent = orders.length? orders.length+' total':'';
|
||||
$('orders').innerHTML = orders.length? orders.map(o=>`
|
||||
<div class="fi"><span class="ic">${o.emoji}</span>
|
||||
<span><b>${o.product.slice(0,34)}</b> <span class="amt">${money(o.amount)}</span><br>
|
||||
<span style="color:var(--dim)">${o.site} · <span class="st-${o.status==='delivered'?'d':'p'}">${o.status}</span> · ${o.email.split('@')[0]}@…</span></span>
|
||||
<span class="tt">${ago(o.date)}</span></div>`).join('') : '<div class="empty">no orders yet</div>';
|
||||
$('usr-count').textContent = Object.values(signups.tiers||{}).map((v,k)=>k+':'+v).join(' · ')||'';
|
||||
$('signups').innerHTML = signups.recent.length? signups.recent.map(u=>`
|
||||
<div class="fi"><span class="ic">${u.emoji}</span>
|
||||
<span><b>${u.username}</b> ${u.membership!=='free'?`<span class="mb">${u.membership}</span>`:''}<br>
|
||||
<span style="color:var(--dim)">${u.site} · ${u.orders} orders · ${money(u.balance)} bal</span></span>
|
||||
<span class="tt">${ago(u.joined)}</span></div>`).join('') : '<div class="empty">no signups yet</div>';
|
||||
$('events').innerHTML = (state.events||[]).slice(0,20).map(e=>`
|
||||
<div class="fi"><span>${e.detail}</span><span class="tt">${agoTs(e.timestamp)}</span></div>`).join('') || '<div class="empty">quiet…</div>';
|
||||
|
||||
/* leaderboard */
|
||||
const maxRev = Math.max(...board.map(b=>b.revenue),1);
|
||||
$('board').innerHTML = `<tr><th></th><th>Site</th><th class="num">Revenue</th><th class="num">Orders</th><th class="num">24h</th><th class="num">AOV</th><th class="num">Users</th><th class="num">Pend</th></tr>` +
|
||||
board.map((b,i)=>`<tr><td class="rank">${i+1}</td><td>${b.emoji} ${b.site}<div class="bar" style="width:${(b.revenue/maxRev*100)|0}%"></div></td>
|
||||
<td class="num"><b>${money(b.revenue)}</b></td><td class="num">${b.orders}</td><td class="num">${b.orders_24h}</td>
|
||||
<td class="num">${money(b.aov)}</td><td class="num">${b.users}</td>
|
||||
<td class="num">${b.pending?`<span class="badge-pend">${b.pending}</span>`:'—'}</td></tr>`).join('');
|
||||
$('top-prod').innerHTML = `<tr><th>Product</th><th class="num">Sold</th><th class="num">Revenue</th><th class="num">Sites</th></tr>` +
|
||||
(top.length? top.map(p=>`<tr><td>${p.product.slice(0,30)}</td><td class="num">${p.orders}</td><td class="num">${money(p.revenue)}</td><td class="num">${p.sites}</td></tr>`).join('') : '<tr><td class="empty">no sales yet</td></tr>');
|
||||
|
||||
/* btcpay stores */
|
||||
const stores = bp.stores||{};
|
||||
$('btcpay-tag').textContent = (bp.stores_with_wallet||0)+'/'+(bp.total_stores||10)+' wallets · '+(bp.total_invoices||0)+' invoices';
|
||||
$('stores').innerHTML = Object.entries(stores).map(([sid,st])=>{
|
||||
const nm = (state.sites[sid]||{}).emoji||''; const inv = st.invoices_by_status||{};
|
||||
return `<span class="store-chip">${nm} ${st.has_wallet?'<b>●</b>':'<span class="nw">○</span>'} ${st.invoice_count||0}inv <span style="color:var(--dim)">${inv.Settled?inv.Settled+'✓ ':''}${inv.New?inv.New+'⏳':''}</span></span>`;
|
||||
}).join('');
|
||||
const br = (state.btc_stats||{}).recent||[];
|
||||
$('btc-recent').innerHTML = br.length? br.map(x=>`<div class="fi"><span>₿ <span class="amt">${(x.btc||0).toLocaleString()}</span> sats-equiv<br><span style="color:var(--dim)" class="mono">${x.txid}</span></span><span class="tt">${agoTs(x.ts)}</span></div>`).join('') : '<div class="empty">no chain activity</div>';
|
||||
|
||||
$('poll-age').textContent = state.last_poll||'—';
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 20000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
257
templates/master.html
Normal file
257
templates/master.html
Normal file
@@ -0,0 +1,257 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Master Control — CCSite Fleet</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root{--bg:#080812;--card:#12121f;--accent:#6c63ff;--accent2:#00d4aa;--text:#e0e0e0;--muted:#777;--danger:#ef4444;--success:#22c55e;--warning:#f59e0b;--radius:10px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
header{background:var(--card);border-bottom:1px solid rgba(108,99,255,0.2);padding:0.8rem 2rem;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:100}
|
||||
header h1{font-size:1.1rem;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.tabs{display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,0.06);background:var(--card);padding:0 2rem}
|
||||
.tab{padding:0.7rem 1.3rem;cursor:pointer;font-size:0.8rem;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;transition:all 0.15s;text-transform:uppercase;letter-spacing:0.5px}
|
||||
.tab:hover{color:var(--text)}
|
||||
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.container{max-width:1500px;margin:0 auto;padding:1.5rem}
|
||||
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.75rem;margin-bottom:1.5rem}
|
||||
.stat-card{background:var(--card);padding:1rem;border-radius:var(--radius);border:1px solid rgba(255,255,255,0.03);text-align:center}
|
||||
.stat-card .sv{font-size:1.4rem;font-weight:800;color:var(--accent2)}
|
||||
.stat-card .sl{font-size:0.6rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-top:0.15rem}
|
||||
.panel{display:none}.panel.active{display:block}
|
||||
table{width:100%;border-collapse:collapse;font-size:0.78rem}
|
||||
th,td{padding:0.45rem 0.6rem;text-align:left;border-bottom:1px solid rgba(255,255,255,0.04)}
|
||||
th{color:var(--muted);font-size:0.65rem;text-transform:uppercase;letter-spacing:0.5px;position:sticky;top:0;background:var(--bg)}
|
||||
tr:hover{background:rgba(108,99,255,0.03)}
|
||||
.badge{padding:0.15rem 0.45rem;border-radius:10px;font-size:0.6rem;font-weight:700}
|
||||
.badge-ok{background:rgba(34,197,94,0.15);color:var(--success)}
|
||||
.badge-pending{background:rgba(245,158,11,0.15);color:var(--warning)}
|
||||
.badge-danger{background:rgba(239,68,68,0.15);color:var(--danger)}
|
||||
input,select{padding:0.45rem 0.7rem;border-radius:6px;border:1px solid rgba(255,255,255,0.08);background:var(--card);color:var(--text);font-size:0.78rem;outline:none}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
.search-bar{display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap}
|
||||
.btn{padding:0.4rem 0.9rem;border-radius:6px;border:none;cursor:pointer;font-weight:600;font-size:0.75rem;background:var(--accent);color:#fff}
|
||||
.btn:hover{filter:brightness(1.15)}
|
||||
.table-wrap{max-height:60vh;overflow-y:auto;border-radius:var(--radius);border:1px solid rgba(255,255,255,0.04)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🖥️ Master Control Panel</h1>
|
||||
<div style="font-size:0.7rem;color:var(--muted);"><a href="/" style="color:var(--accent);text-decoration:none;">← Dashboard</a> | <span id="clock">--</span></div>
|
||||
</header>
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('overview')">📊 Overview</div>
|
||||
<div class="tab" onclick="switchTab('orders')">📦 Orders</div>
|
||||
<div class="tab" onclick="switchTab('users')">👥 Users</div>
|
||||
<div class="tab" onclick="switchTab('products')">🏷️ Products</div>
|
||||
<div class="tab" onclick="switchTab('transactions')">💳 Transactions</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div id="panel-overview" class="panel active">
|
||||
<div class="stat-row" id="overview-stats"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div style="background:var(--card);padding:1rem;border-radius:var(--radius);">
|
||||
<h3 style="font-size:0.75rem;color:var(--muted);margin-bottom:0.8rem;">📦 Recent Orders</h3>
|
||||
<div id="recent-orders"></div>
|
||||
</div>
|
||||
<div style="background:var(--card);padding:1rem;border-radius:var(--radius);">
|
||||
<h3 style="font-size:0.75rem;color:var(--muted);margin-bottom:0.8rem;">👥 Recent Signups</h3>
|
||||
<div id="recent-users"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel-orders" class="panel">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="order-search" placeholder="Search orders..." oninput="renderOrders()">
|
||||
<select id="order-site" onchange="renderOrders()"><option value="">All Sites</option></select>
|
||||
<select id="order-status" onchange="renderOrders()"><option value="">All Status</option><option value="pending">Pending</option><option value="completed">Completed</option></select>
|
||||
</div>
|
||||
<div class="table-wrap"><table id="orders-table"><thead><tr><th>Site</th><th>Order</th><th>Product</th><th>Email</th><th>Amount</th><th>Status</th><th>Date</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
<div id="panel-users" class="panel">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="user-search" placeholder="Search users..." oninput="renderUsers()">
|
||||
<select id="user-site" onchange="renderUsers()"><option value="">All Sites</option></select>
|
||||
</div>
|
||||
<div class="table-wrap"><table id="users-table"><thead><tr><th>Site</th><th>User</th><th>Email</th><th>Balance</th><th>Orders</th><th>Joined</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
<div id="panel-products" class="panel">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="product-search" placeholder="Search products..." oninput="renderProducts()">
|
||||
<select id="product-site" onchange="renderProducts()"><option value="">All Sites</option></select>
|
||||
</div>
|
||||
<div class="table-wrap"><table id="products-table"><thead><tr><th>Site</th><th>Product</th><th>Category</th><th>Price</th><th>Stock</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
<div id="panel-transactions" class="panel">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="txn-search" placeholder="Search transactions..." oninput="renderTransactions()">
|
||||
<select id="txn-site" onchange="renderTransactions()"><option value="">All Sites</option></select>
|
||||
</div>
|
||||
<div class="table-wrap"><table id="txns-table"><thead><tr><th>Site</th><th>User</th><th>Type</th><th>Method</th><th>Amount</th><th>Date</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SITE_IPS = {1:'10.30.20.210',2:'10.30.20.211',3:'10.30.20.212',4:'10.30.20.213',5:'10.30.20.214',6:'10.30.20.217',7:'10.30.20.218',8:'10.30.20.219',9:'10.30.20.220',10:'10.30.20.221'};
|
||||
let ALL_DATA = {};
|
||||
|
||||
async function loadAll() {
|
||||
const promises = Object.entries(SITE_IPS).map(async ([id,ip]) => {
|
||||
try {
|
||||
const r = await fetch(`http://${ip}:5000/api/dashboard-data`);
|
||||
const d = await r.json();
|
||||
d._site_id = parseInt(id);
|
||||
d._ip = ip;
|
||||
return d;
|
||||
} catch(e) { return null; }
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
ALL_DATA = results.filter(r => r);
|
||||
|
||||
// Populate site dropdowns
|
||||
const siteOpts = ALL_DATA.map(d => `<option value="${d._site_id}">${d.site}</option>`).join('');
|
||||
['order-site','user-site','product-site','txn-site'].forEach(id => {
|
||||
document.getElementById(id).innerHTML = '<option value="">All Sites</option>' + siteOpts;
|
||||
});
|
||||
|
||||
updateOverview();
|
||||
}
|
||||
|
||||
function updateOverview() {
|
||||
let totalOrders=0, totalUsers=0, totalRevenue=0, pendingOrders=0;
|
||||
ALL_DATA.forEach(d => {
|
||||
totalOrders += d.orders.length;
|
||||
totalUsers += d.users.length;
|
||||
totalRevenue += d.orders.filter(o=>o.status==='completed').reduce((s,o)=>s+o.amount,0);
|
||||
pendingOrders += d.orders.filter(o=>o.status==='pending').length;
|
||||
});
|
||||
|
||||
document.getElementById('overview-stats').innerHTML = `
|
||||
<div class="stat-card"><div class="sv">${totalUsers}</div><div class="sl">Total Users</div></div>
|
||||
<div class="stat-card"><div class="sv">${totalOrders}</div><div class="sl">Total Orders</div></div>
|
||||
<div class="stat-card"><div class="sv">$${totalRevenue.toFixed(0)}</div><div class="sl">Revenue</div></div>
|
||||
<div class="stat-card"><div class="sv" style="color:var(--warning);">${pendingOrders}</div><div class="sl">Pending</div></div>
|
||||
<div class="stat-card"><div class="sv">${ALL_DATA.length}</div><div class="sl">Sites Online</div></div>
|
||||
`;
|
||||
|
||||
// Recent orders
|
||||
const allOrders = ALL_DATA.flatMap(d => d.orders.map(o => ({...o, site:d.site, sid:d._site_id})));
|
||||
allOrders.sort((a,b) => b.date.localeCompare(a.date));
|
||||
document.getElementById('recent-orders').innerHTML = allOrders.slice(0,10).map(o =>
|
||||
`<div style="padding:0.3rem 0;border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.75rem;">
|
||||
<span style="color:var(--accent);">${o.site}</span> · ${o.product?.slice(0,30)} ·
|
||||
<span style="color:var(--accent2);">$${o.amount?.toFixed(2)}</span>
|
||||
<span class="badge ${o.status==='completed'?'badge-ok':'badge-pending'}">${o.status}</span>
|
||||
</div>`
|
||||
).join('') || '<div style="color:var(--muted);">No orders yet</div>';
|
||||
|
||||
// Recent users
|
||||
const allUsers = ALL_DATA.flatMap(d => d.users.map(u => ({...u, site:d.site})));
|
||||
allUsers.sort((a,b) => b.joined.localeCompare(a.joined));
|
||||
document.getElementById('recent-users').innerHTML = allUsers.slice(0,10).map(u =>
|
||||
`<div style="padding:0.3rem 0;border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.75rem;">
|
||||
<span style="color:var(--accent);">${u.site}</span> · ${u.username} · ${u.email}
|
||||
</div>`
|
||||
).join('') || '<div style="color:var(--muted);">No users yet</div>';
|
||||
}
|
||||
|
||||
function renderOrders() {
|
||||
const q = (document.getElementById('order-search').value||'').toLowerCase();
|
||||
const sid = parseInt(document.getElementById('order-site').value)||0;
|
||||
const status = document.getElementById('order-status').value;
|
||||
|
||||
let orders = ALL_DATA.flatMap(d => d.orders.map(o => ({...o, site:d.site, sid:d._site_id})));
|
||||
if(sid) orders = orders.filter(o => o.sid===sid);
|
||||
if(status) orders = orders.filter(o => o.status===status);
|
||||
if(q) orders = orders.filter(o => (o.product+o.email+o.code).toLowerCase().includes(q));
|
||||
orders.sort((a,b) => b.date.localeCompare(a.date));
|
||||
|
||||
document.getElementById('orders-table').querySelector('tbody').innerHTML = orders.slice(0,200).map(o =>
|
||||
`<tr>
|
||||
<td style="color:var(--accent);">${o.site}</td>
|
||||
<td>#${o.id} <span style="font-size:0.65rem;color:var(--muted);">${o.code||''}</span></td>
|
||||
<td>${o.product?.slice(0,35)}</td>
|
||||
<td>${o.email}</td>
|
||||
<td style="color:var(--accent2);">$${o.amount?.toFixed(2)}</td>
|
||||
<td><span class="badge ${o.status==='completed'?'badge-ok':'badge-pending'}">${o.status}</span></td>
|
||||
<td style="font-size:0.7rem;color:var(--muted);">${new Date(o.date).toLocaleDateString()}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const q = (document.getElementById('user-search').value||'').toLowerCase();
|
||||
const sid = parseInt(document.getElementById('user-site').value)||0;
|
||||
let users = ALL_DATA.flatMap(d => d.users.map(u => ({...u, site:d.site, sid:d._site_id})));
|
||||
if(sid) users = users.filter(u => u.sid===sid);
|
||||
if(q) users = users.filter(u => (u.username+u.email).toLowerCase().includes(q));
|
||||
|
||||
document.getElementById('users-table').querySelector('tbody').innerHTML = users.slice(0,200).map(u =>
|
||||
`<tr>
|
||||
<td style="color:var(--accent);">${u.site}</td>
|
||||
<td><strong>${u.username}</strong></td>
|
||||
<td>${u.email}</td>
|
||||
<td style="color:var(--accent2);">$${u.balance?.toFixed(2)}</td>
|
||||
<td>${u.orders}</td>
|
||||
<td style="font-size:0.7rem;color:var(--muted);">${new Date(u.joined).toLocaleDateString()}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderProducts() {
|
||||
const q = (document.getElementById('product-search').value||'').toLowerCase();
|
||||
const sid = parseInt(document.getElementById('product-site').value)||0;
|
||||
let prods = ALL_DATA.flatMap(d => d.products.map(p => ({...p, site:d.site, sid:d._site_id})));
|
||||
if(sid) prods = prods.filter(p => p.sid===sid);
|
||||
if(q) prods = prods.filter(p => p.name.toLowerCase().includes(q));
|
||||
|
||||
document.getElementById('products-table').querySelector('tbody').innerHTML = prods.slice(0,200).map(p =>
|
||||
`<tr>
|
||||
<td style="color:var(--accent);">${p.site}</td>
|
||||
<td>${p.name}</td>
|
||||
<td>${p.category}</td>
|
||||
<td style="color:var(--accent2);">$${p.price?.toFixed(2)}</td>
|
||||
<td>${p.stock}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderTransactions() {
|
||||
const q = (document.getElementById('txn-search').value||'').toLowerCase();
|
||||
const sid = parseInt(document.getElementById('txn-site').value)||0;
|
||||
let txns = ALL_DATA.flatMap(d => d.transactions.map(t => ({...t, site:d.site, sid:d._site_id})));
|
||||
if(sid) txns = txns.filter(t => t.sid===sid);
|
||||
if(q) txns = txns.filter(t => (t.user+t.method).toLowerCase().includes(q));
|
||||
|
||||
document.getElementById('txns-table').querySelector('tbody').innerHTML = txns.slice(0,200).map(t =>
|
||||
`<tr>
|
||||
<td style="color:var(--accent);">${t.site}</td>
|
||||
<td>${t.user}</td>
|
||||
<td><span class="badge ${t.type==='deposit'?'badge-ok':'badge-danger'}">${t.type}</span></td>
|
||||
<td>${t.method||'-'}</td>
|
||||
<td style="color:${t.amount>0?'var(--success)':'var(--danger)'};">${t.amount>0?'+':''}$${t.amount?.toFixed(2)}</td>
|
||||
<td style="font-size:0.7rem;color:var(--muted);">${new Date(t.date).toLocaleDateString()}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||||
const tabMap = {'overview':1,'orders':2,'users':3,'products':4,'transactions':5};
|
||||
document.querySelector('.tab:nth-child(' + tabMap[tab] + ')').classList.add('active');
|
||||
document.getElementById('panel-'+tab).classList.add('active');
|
||||
if(tab==='orders') renderOrders();
|
||||
if(tab==='users') renderUsers();
|
||||
if(tab==='products') renderProducts();
|
||||
if(tab==='transactions') renderTransactions();
|
||||
}
|
||||
|
||||
setInterval(() => document.getElementById('clock').textContent = new Date().toLocaleTimeString(), 1000);
|
||||
loadAll();
|
||||
setInterval(loadAll, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
116
templates/watchdog.html
Normal file
116
templates/watchdog.html
Normal file
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Watchdog — CCSite Fleet</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:1.5rem}
|
||||
h1{font-size:1.5rem;margin-bottom:0.3rem}
|
||||
h1 span{color:#22c55e}
|
||||
.sub{color:#666;font-size:0.8rem;margin-bottom:1.5rem}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}
|
||||
.card{background:#111118;border-radius:10px;padding:1.2rem;border:1px solid #1a1a24}
|
||||
.card.pass{border-color:#22c55e33}
|
||||
.card.warn{border-color:#f59e0b33}
|
||||
.card.fail{border-color:#ef444433}
|
||||
.card .header{display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem}
|
||||
.card .name{font-weight:700;font-size:0.9rem}
|
||||
.card .status{font-size:0.7rem;padding:0.2rem 0.6rem;border-radius:4px;font-weight:700;text-transform:uppercase}
|
||||
.status.pass{background:#22c55e22;color:#22c55e}
|
||||
.status.warn{background:#f59e0b22;color:#f59e0b}
|
||||
.status.fail{background:#ef444422;color:#ef4444}
|
||||
.card .detail{font-size:0.75rem;color:#666;font-family:monospace}
|
||||
.summary{display:flex;gap:1.5rem;margin-bottom:1.5rem;flex-wrap:wrap}
|
||||
.summary .stat{text-align:center}
|
||||
.summary .stat .n{font-size:2rem;font-weight:900}
|
||||
.summary .stat .l{font-size:0.65rem;color:#666;text-transform:uppercase;letter-spacing:1px}
|
||||
.summary .stat .n.green{color:#22c55e}
|
||||
.summary .stat .n.yellow{color:#f59e0b}
|
||||
.summary .stat .n.red{color:#ef4444}
|
||||
.issues{background:#ef444411;border:1px solid #ef444433;border-radius:10px;padding:1rem;margin-bottom:1.5rem}
|
||||
.issues h3{font-size:0.85rem;color:#ef4444;margin-bottom:0.5rem}
|
||||
.issues li{font-size:0.8rem;color:#fca5a5;margin-left:1.2rem;line-height:1.6}
|
||||
.healing{background:#f59e0b11;border:1px solid #f59e0b33;border-radius:10px;padding:1rem;margin-bottom:1.5rem}
|
||||
.healing h3{font-size:0.85rem;color:#f59e0b;margin-bottom:0.5rem}
|
||||
.healing li{font-size:0.8rem;color:#fcd34d;margin-left:1.2rem;line-height:1.6}
|
||||
.actions{margin-top:1rem;display:flex;gap:0.5rem}
|
||||
button{background:#1a1a2e;border:1px solid #333;color:#ccc;padding:0.5rem 1rem;border-radius:6px;cursor:pointer;font-size:0.8rem}
|
||||
button:hover{background:#222;border-color:#555}
|
||||
.refresh{color:#666;font-size:0.7rem;margin-top:0.5rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🛡️ <span>CCSite Watchdog</span></h1>
|
||||
<p class="sub">Self-healing health monitor — checks every 60s</p>
|
||||
|
||||
<div id="summary" class="summary"></div>
|
||||
<div id="issues"></div>
|
||||
<div id="healing"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button onclick="runScan()">🔍 Run Scan Now</button>
|
||||
<button onclick="location.reload()">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="checks"></div>
|
||||
<p class="refresh" id="last"></p>
|
||||
|
||||
<script>
|
||||
function icon(s) { return s==='pass'?'✅':s==='warn'?'⚠️':'❌' }
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/watchdog');
|
||||
const d = await r.json();
|
||||
|
||||
// Summary
|
||||
const s = d.summary || {};
|
||||
const statusEmoji = s.status === 'healthy' ? '🟢' : s.status === 'degraded' ? '🟡' : '🔴';
|
||||
document.getElementById('summary').innerHTML =
|
||||
`<div class="stat"><div class="n green">${s.pass||0}</div><div class="l">Passing</div></div>
|
||||
<div class="stat"><div class="n yellow">${s.warn||0}</div><div class="l">Warnings</div></div>
|
||||
<div class="stat"><div class="n red">${s.fail||0}</div><div class="l">Failing</div></div>
|
||||
<div class="stat"><div class="n">${statusEmoji}</div><div class="l">${(s.status||'unknown').toUpperCase()}</div></div>`;
|
||||
|
||||
// Issues
|
||||
const issues = d.issues || [];
|
||||
document.getElementById('issues').innerHTML = issues.length ?
|
||||
`<div class="issues"><h3>🚨 ${issues.length} Issue(s)</h3><ul>${issues.map(i=>`<li>${i}</li>`).join('')}</ul></div>` : '';
|
||||
|
||||
// Healing
|
||||
const healing = d.healing_actions || [];
|
||||
document.getElementById('healing').innerHTML = healing.length ?
|
||||
`<div class="healing"><h3>🔧 Self-Healing Actions</h3><ul>${healing.map(h=>`<li>${h.success?'✅':'❌'} ${h.action}: ${h.detail}</li>`).join('')}</ul></div>` : '';
|
||||
|
||||
// Checks
|
||||
const checks = d.checks || [];
|
||||
document.getElementById('checks').innerHTML = checks.map(c =>
|
||||
`<div class="card ${c.status}">
|
||||
<div class="header">
|
||||
<span class="name">${c.check}</span>
|
||||
<span class="status ${c.status}">${c.status}</span>
|
||||
</div>
|
||||
<div class="detail">${c.detail}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('last').textContent = `Last check: ${d.last_check || 'never'}`;
|
||||
} catch(e) {
|
||||
document.getElementById('summary').innerHTML = '<div class="stat"><div class="n red">⚠️</div><div class="l">Watchdog unavailable</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
document.querySelector('button').textContent = 'Scanning...';
|
||||
await fetch('/api/watchdog/run', {method:'POST'});
|
||||
await load();
|
||||
document.querySelector('button').textContent = '🔍 Run Scan Now';
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
215
watchdog.py
Normal file
215
watchdog.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CCSite Watchdog — Self-healing health monitor.
|
||||
Extends the dashboard with automated checks, self-healing, and alerts.
|
||||
"""
|
||||
import json, time, sqlite3, subprocess, threading, sys
|
||||
from datetime import datetime
|
||||
import requests, urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# ─── Config ───────────────────────────────────────────────
|
||||
SITES = {
|
||||
1: {"name": "VaultPass", "ip": "10.30.20.210", "emoji": "🎮", "vmid": 123},
|
||||
2: {"name": "StreamPass", "ip": "10.30.20.211", "emoji": "🎬", "vmid": 124},
|
||||
3: {"name": "KeyForge", "ip": "10.30.20.212", "emoji": "💻", "vmid": 125},
|
||||
4: {"name": "SocialForge", "ip": "10.30.20.213", "emoji": "🐦", "vmid": 126},
|
||||
5: {"name": "CardVault", "ip": "10.30.20.214", "emoji": "💳", "vmid": 127},
|
||||
6: {"name": "CloudNexus", "ip": "10.30.20.217", "emoji": "☁️", "vmid": 128},
|
||||
7: {"name": "LearnForge", "ip": "10.30.20.218", "emoji": "📚", "vmid": 129},
|
||||
8: {"name": "PrivacyPass", "ip": "10.30.20.219", "emoji": "🛡️", "vmid": 130},
|
||||
9: {"name": "CreatorForge", "ip": "10.30.20.220", "emoji": "🎨", "vmid": 131},
|
||||
10: {"name": "FoodVault", "ip": "10.30.20.221", "emoji": "🍕", "vmid": 132},
|
||||
}
|
||||
BTCPAY_URL = "https://10.30.20.140"
|
||||
BTCPAY_KEY = "6026288e2e315984661c748baafd509e81a75f22"
|
||||
BTCPAY_STORES = {
|
||||
1: "AYZWEAt63TgY4QaxwNBawHASMKcimJabcZxSczRqZG81",
|
||||
2: "5pi94zmY3F26pTZJ99JfsFG9pb8atbz7fpALrPjS2BRq",
|
||||
3: "3mc58myPrkdN7hRFdsBwJxbVgBQQ3VvSYZygKQC3FiSG",
|
||||
4: "E1uZ5nmU6qSpMB8sna7fNdxZFiem9JmkxB9LdJkFN23B",
|
||||
5: "BcvaUvv8MM8if5dzqe5yLnNERG6EwjjmeMok54ATqjWf",
|
||||
6: "GvSsA9xaqymhswsiPo4APHMuT2sXCCtnBLgPWcHigxVx",
|
||||
7: "A2TXbpkpj88Wtj34dLS9yzjMgmcodysEaRhTjc3rFcUr",
|
||||
8: "9N8dTC99uXn3W3e2F9o8kVQ5ewijh87NevKmMATF1Jcd",
|
||||
9: "39BKRF3zQb6Gi8LiKo1US13bzJfhpCzkwoHP6pCyQFwD",
|
||||
10: "9vCjKrKbwgey4GH7DBAMa6FgkiApgHvd5khFpWSZzJL2",
|
||||
}
|
||||
SSH = "sshpass -p 'Czapiewski1!' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@10.30.20.85"
|
||||
|
||||
watchdog_state = {"checks": [], "issues": [], "healing_actions": [], "last_check": None}
|
||||
state_lock = threading.Lock()
|
||||
|
||||
# ─── Checks ────────────────────────────────────────────────
|
||||
|
||||
def check_site_http(site_id, info):
|
||||
"""Check if a site returns HTTP 200."""
|
||||
try:
|
||||
r = requests.get(f"http://{info['ip']}:5000/", timeout=5)
|
||||
return {"check": f"{info['emoji']} {info['name']} HTTP", "status": "pass" if r.status_code == 200 else "warn", "detail": f"HTTP {r.status_code}"}
|
||||
except:
|
||||
return {"check": f"{info['emoji']} {info['name']} HTTP", "status": "fail", "detail": "unreachable"}
|
||||
|
||||
def check_site_btcpay_config(site_id, info):
|
||||
"""Check if the site is reachable (BTCPay config verified separately)."""
|
||||
try:
|
||||
r = requests.get(f"http://{info['ip']}:5000/", timeout=5)
|
||||
return {"check": f"{info['emoji']} {info['name']} Site", "status": "pass" if r.status_code == 200 else "warn", "detail": f"HTTP {r.status_code}"}
|
||||
except:
|
||||
return {"check": f"{info['emoji']} {info['name']} BTCPay config", "status": "fail", "detail": "unreachable"}
|
||||
|
||||
def check_btcpay_sync():
|
||||
"""Check BTCPay sync status."""
|
||||
try:
|
||||
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:
|
||||
info = r.json()
|
||||
synced = info.get("fullySynched", False)
|
||||
for s in info.get("syncStatus", []):
|
||||
ni = s.get("nodeInformation", {})
|
||||
if ni.get("headers"):
|
||||
pct = (ni["blocks"] / ni["headers"] * 100) if ni["headers"] else 0
|
||||
return {"check": "₿ BTCPay Sync", "status": "pass" if synced else "warn", "detail": f"{pct:.1f}% ({ni['blocks']}/{ni['headers']})"}
|
||||
return {"check": "₿ BTCPay Sync", "status": "pass" if synced else "warn", "detail": f"synced={synced}"}
|
||||
return {"check": "₿ BTCPay Sync", "status": "fail", "detail": f"HTTP {r.status_code}"}
|
||||
except Exception as e:
|
||||
return {"check": "₿ BTCPay Sync", "status": "fail", "detail": str(e)[:80]}
|
||||
|
||||
def check_wallet_health():
|
||||
"""Check how many stores have working wallets by creating test invoices."""
|
||||
healthy = 0
|
||||
for sid, store_id in BTCPAY_STORES.items():
|
||||
try:
|
||||
# Try creating a 0.01 invoice — if wallet works, it'll succeed
|
||||
r = requests.post(f"{BTCPAY_URL}/api/v1/stores/{store_id}/invoices",
|
||||
headers={"Authorization": f"token {BTCPAY_KEY}", "Content-Type": "application/json"},
|
||||
json={"amount": "5.00", "currency": "USD"}, timeout=8, verify=False)
|
||||
if r.status_code == 200:
|
||||
healthy += 1
|
||||
except:
|
||||
pass
|
||||
total = len(BTCPAY_STORES)
|
||||
return {"check": "₿ Wallet Health", "status": "pass" if healthy == total else "warn" if healthy > 0 else "fail",
|
||||
"detail": f"{healthy}/{total} stores can create invoices"}
|
||||
|
||||
def check_webhook_health():
|
||||
"""Check webhooks are enabled on all stores."""
|
||||
ok = 0
|
||||
total = len(BTCPAY_STORES)
|
||||
for sid, store_id in BTCPAY_STORES.items():
|
||||
try:
|
||||
r = requests.get(f"{BTCPAY_URL}/api/v1/stores/{store_id}/webhooks", headers={"Authorization": f"token {BTCPAY_KEY}"}, timeout=5, verify=False)
|
||||
if r.status_code == 200:
|
||||
hooks = r.json()
|
||||
if hooks and hooks[0].get("enabled"):
|
||||
ok += 1
|
||||
except:
|
||||
pass
|
||||
return {"check": "🔗 Webhooks", "status": "pass" if ok == total else "warn" if ok > 0 else "fail", "detail": f"{ok}/{total} enabled"}
|
||||
|
||||
# ─── Self-Healing ──────────────────────────────────────────
|
||||
|
||||
def heal_restart_site(site_id, info):
|
||||
"""Restart a site's shop service."""
|
||||
try:
|
||||
result = subprocess.run(f"{SSH} 'pct exec {info['vmid']} -- systemctl restart shop'",
|
||||
shell=True, capture_output=True, text=True, timeout=15)
|
||||
success = result.returncode == 0
|
||||
return {"action": f"Restart {info['name']}", "success": success, "detail": "restarted" if success else result.stderr[:80]}
|
||||
except Exception as e:
|
||||
return {"action": f"Restart {info['name']}", "success": False, "detail": str(e)[:80]}
|
||||
|
||||
def heal_generate_wallet(store_id, site_name):
|
||||
"""Try to generate a wallet for a store."""
|
||||
try:
|
||||
r = requests.post(f"{BTCPAY_URL}/api/v1/stores/{store_id}/payment-methods/BTC-CHAIN/wallet/generate",
|
||||
headers={"Authorization": f"token {BTCPAY_KEY}", "Content-Type": "application/json"},
|
||||
json={"savePrivateKeys": True}, timeout=15, verify=False)
|
||||
if r.status_code == 200:
|
||||
return {"action": f"Generate wallet for {site_name}", "success": True, "detail": "generated"}
|
||||
return {"action": f"Generate wallet for {site_name}", "success": False, "detail": f"HTTP {r.status_code}: {r.text[:80]}"}
|
||||
except Exception as e:
|
||||
return {"action": f"Generate wallet for {site_name}", "success": False, "detail": str(e)[:80]}
|
||||
|
||||
# ─── Main Check Loop ──────────────────────────────────────
|
||||
|
||||
def run_all_checks():
|
||||
"""Run all watchdog checks and trigger healing if needed."""
|
||||
checks = []
|
||||
issues = []
|
||||
healing = []
|
||||
|
||||
# Site HTTP checks (one per site)
|
||||
for sid, info in SITES.items():
|
||||
http_check = check_site_http(sid, info)
|
||||
checks.append(http_check)
|
||||
if http_check["status"] == "fail":
|
||||
issues.append(f"{info['emoji']} {info['name']} is DOWN")
|
||||
result = heal_restart_site(sid, info)
|
||||
healing.append(result)
|
||||
|
||||
# BTCPay health
|
||||
sync_check = check_btcpay_sync()
|
||||
checks.append(sync_check)
|
||||
if sync_check["status"] == "fail":
|
||||
issues.append("BTCPay is DOWN")
|
||||
|
||||
wallet_check = check_wallet_health()
|
||||
checks.append(wallet_check)
|
||||
if wallet_check["status"] == "warn":
|
||||
# Try healing missing wallets
|
||||
for sid, store_id in BTCPAY_STORES.items():
|
||||
try:
|
||||
r = requests.get(f"{BTCPAY_URL}/api/v1/stores/{store_id}/payment-methods/BTC-CHAIN", headers={"Authorization": f"token {BTCPAY_KEY}"}, timeout=5, verify=False)
|
||||
if r.status_code == 200:
|
||||
pm = r.json()
|
||||
if not pm.get("walletId") and pm.get("derivationScheme") in (None, "", "NONE"):
|
||||
result = heal_generate_wallet(store_id, SITES[sid]["name"])
|
||||
healing.append(result)
|
||||
except:
|
||||
pass
|
||||
|
||||
webhook_check = check_webhook_health()
|
||||
checks.append(webhook_check)
|
||||
|
||||
# Summary
|
||||
pass_count = sum(1 for c in checks if c["status"] == "pass")
|
||||
warn_count = sum(1 for c in checks if c["status"] == "warn")
|
||||
fail_count = sum(1 for c in checks if c["status"] == "fail")
|
||||
|
||||
with state_lock:
|
||||
watchdog_state["checks"] = checks
|
||||
watchdog_state["issues"] = issues
|
||||
watchdog_state["healing_actions"] = healing
|
||||
watchdog_state["last_check"] = datetime.now().strftime("%H:%M:%S")
|
||||
watchdog_state["summary"] = {
|
||||
"total": len(checks), "pass": pass_count, "warn": warn_count, "fail": fail_count,
|
||||
"status": "healthy" if fail_count == 0 and warn_count == 0 else "degraded" if fail_count == 0 else "critical"
|
||||
}
|
||||
|
||||
return watchdog_state
|
||||
|
||||
def watchdog_loop():
|
||||
"""Background thread — runs checks every 60 seconds."""
|
||||
while True:
|
||||
try:
|
||||
run_all_checks()
|
||||
except Exception as e:
|
||||
print(f"Watchdog error: {e}", file=sys.stderr)
|
||||
time.sleep(60)
|
||||
|
||||
# ─── Flask Integration ────────────────────────────────────
|
||||
|
||||
def get_state():
|
||||
with state_lock:
|
||||
return dict(watchdog_state)
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Watchdog starting...")
|
||||
run_all_checks()
|
||||
print(f" Checks: {watchdog_state['summary']}")
|
||||
t = threading.Thread(target=watchdog_loop, daemon=True)
|
||||
t.start()
|
||||
# Keep alive
|
||||
while True:
|
||||
time.sleep(60)
|
||||
Reference in New Issue
Block a user