ccsite-manager: fleet orchestrator, BTCPay auto-settlement, watchdog, dashboard

This commit is contained in:
drjones
2026-08-02 22:36:59 -07:00
commit 4477d56b30
15 changed files with 2754 additions and 0 deletions

Binary file not shown.

Binary file not shown.

BIN
btc_chain.db Normal file

Binary file not shown.

178
btc_monitor.py Normal file
View 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
View 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
View 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

Binary file not shown.

646
dashboard.py Normal file
View 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

Submodule payment-recon added at 5ed685a79a

397
payment-recon.html Normal file
View 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&#10;casino-site.com gambling&#10;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
View 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
View 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
View 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
View 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
View 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)