From 9762c0f9bf2a71d4cf2d51a81a15681a228acd87 Mon Sep 17 00:00:00 2001 From: Junaid Saeed Uppal Date: Thu, 26 Feb 2026 10:19:14 +0500 Subject: [PATCH] add bet impact simulator, visitor log page, and fix console logging - Bet impact simulator on /predictions shows rank headroom and safe bet amounts - Password-protected /visitors page with visitor log table and stats - Console now logs real visitor IPs instead of Cloudflare tunnel IPs --- app/db.py | 24 +++++++ app/server.py | 48 +++++++++++++- static/predictions.html | 139 +++++++++++++++++++++++++++++++++++++++- static/visitors.html | 98 ++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 static/visitors.html diff --git a/app/db.py b/app/db.py index 2cb3349bc..47b2fa4fd 100644 --- a/app/db.py +++ b/app/db.py @@ -177,6 +177,30 @@ def insert_visitors(batch: list[dict]): ) +@_with_lock +def get_recent_visitors(limit: int = 200) -> list[dict]: + """Get recent visitor log entries.""" + client = get_client() + result = client.query( + "SELECT ip, country, path, method, user_agent, referer, accept_lang, created_at " + "FROM visitors ORDER BY created_at DESC LIMIT {limit:UInt32}", + parameters={"limit": limit}, + ) + visitors = [] + for row in result.result_rows: + visitors.append({ + "ip": row[0], + "country": row[1], + "path": row[2], + "method": row[3], + "user_agent": row[4], + "referer": row[5], + "accept_lang": row[6], + "created_at": str(row[7]), + }) + return visitors + + @_with_lock def get_recent_games(n: int = 50) -> list[dict]: """Get last N completed games.""" diff --git a/app/server.py b/app/server.py index 98c3df611..bc3f5b04d 100644 --- a/app/server.py +++ b/app/server.py @@ -5,6 +5,7 @@ All blocking DB calls run in a thread executor to avoid blocking the event loop. """ import asyncio +import base64 import json import logging import os @@ -51,6 +52,8 @@ class WebServer: or request.remote or "" ) + log.info('%s %s %s %d "%s"', ip, request.method, path, response.status, + request.headers.get("User-Agent", "-")) visitor = { "ip": ip, "country": request.headers.get("CF-IPCountry", ""), @@ -90,6 +93,28 @@ class WebServer: except Exception as e: log.warning("Visitor flush failed: %s", e) + @staticmethod + def _check_basic_auth(request: web.Request) -> bool: + auth = request.headers.get("Authorization", "") + if not auth.startswith("Basic "): + return False + try: + decoded = base64.b64decode(auth[6:]).decode() + return decoded == "sk:hakunamatata2020" + except Exception: + return False + + @staticmethod + def _require_auth(request: web.Request) -> web.Response | None: + """Return a 401 response if auth fails, else None.""" + if WebServer._check_basic_auth(request): + return None + return web.Response( + status=401, + headers={"WWW-Authenticate": 'Basic realm="3pmonitor"'}, + text="Unauthorized", + ) + def _setup_routes(self): self.app.router.add_get("/", self._handle_index) self.app.router.add_get("/api/history", self._handle_history) @@ -104,6 +129,8 @@ class WebServer: self.app.router.add_get("/predictions", self._handle_predictions_page) self.app.router.add_get("/api/predictions", self._handle_predictions) self.app.router.add_get("/api/prediction-history", self._handle_prediction_history) + self.app.router.add_get("/visitors", self._handle_visitors_page) + self.app.router.add_get("/api/visitors", self._handle_visitors) self.app.router.add_get("/ws", self._handle_ws) self.app.router.add_static("/static/", STATIC_DIR, name="static") @@ -186,6 +213,25 @@ class WebServer: log.error("Prediction history query failed: %s", e) return web.json_response({"error": str(e)}, status=500) + async def _handle_visitors_page(self, request: web.Request) -> web.Response: + denied = self._require_auth(request) + if denied: + return denied + path = os.path.join(STATIC_DIR, "visitors.html") + return web.FileResponse(path) + + async def _handle_visitors(self, request: web.Request) -> web.Response: + denied = self._require_auth(request) + if denied: + return denied + limit = min(int(request.query.get("limit", 200)), 1000) + try: + visitors = await _run_sync(db.get_recent_visitors, limit) + return web.json_response(visitors) + except Exception as e: + log.error("Visitors query failed: %s", e) + return web.json_response([], status=500) + async def _handle_analytics(self, request: web.Request) -> web.Response: period = request.query.get("period", "all") if period not in ("1h", "6h", "24h", "7d", "all"): @@ -272,7 +318,7 @@ class WebServer: log.warning("push_refresh failed: %s", e) async def run(self): - runner = web.AppRunner(self.app) + runner = web.AppRunner(self.app, access_log=None) await runner.setup() site = web.TCPSite(runner, "0.0.0.0", config.WEB_PORT) await site.start() diff --git a/static/predictions.html b/static/predictions.html index e4bca5249..916591aed 100644 --- a/static/predictions.html +++ b/static/predictions.html @@ -178,8 +178,26 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans- .result-flash.loss { background: rgba(239,68,68,0.15); border: 1px solid var(--red); color: var(--red); } @keyframes flashIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } +/* Bet impact simulator */ +.impact-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; } +.impact-table th, .impact-table td { padding: 8px 10px; text-align: center; border-bottom: 1px solid var(--border); } +.impact-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; background: var(--surface2); } +.impact-rank { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase; } +.impact-rank.high { background: rgba(239,68,68,0.2); color: var(--red); } +.impact-rank.mid { background: rgba(108,92,231,0.2); color: var(--accent); } +.impact-rank.low { background: rgba(16,185,129,0.2); color: var(--green); } +.impact-headroom { font-weight: 800; font-variant-numeric: tabular-nums; } +.impact-recs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.impact-rec { + background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; + padding: 12px 16px; text-align: center; +} +.impact-rec .rec-label { font-size: 10px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; } +.impact-rec .rec-value { font-size: 20px; font-weight: 800; } +.impact-rec .rec-note { font-size: 10px; color: var(--text3); margin-top: 4px; } + @media (max-width: 768px) { - .pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats { grid-template-columns: 1fr; } + .pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats, .impact-recs { grid-template-columns: 1fr; } .advisor-grid { grid-template-columns: 1fr; gap: 10px; } .backtest-grid { grid-template-columns: repeat(2, 1fr); } .pred-card .prob { font-size: 28px; } @@ -229,6 +247,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
+ +
+
Bet Impact Simulator
+
+
+
Signal Breakdown
@@ -556,6 +580,119 @@ function renderBetAdvisor(data, pot) { ${whaleHtml} ${pubHtml} `; + + renderBetImpact(); +} + +// ── Bet Impact Simulator ── +function getRank(chair, bets) { + const vals = CHAIRS.map(c => bets[c] || 0); + const v = bets[chair] || 0; + const maxV = Math.max(...vals); + const minV = Math.min(...vals); + if (maxV === minV) return 'mid'; + if (v >= maxV) return 'high'; + if (v <= minV) return 'low'; + return 'mid'; +} + +function rankWinRate(rank) { + const br = predictionData?.bet_rank || {}; + const total = (br.high || 0) + (br.mid || 0) + (br.low || 0); + if (total === 0) return null; + return (br[rank] || 0) / total * 100; +} + +function computeHeadroom(chair, bets) { + const curRank = getRank(chair, bets); + const others = CHAIRS.filter(c => c !== chair).map(c => bets[c] || 0); + const myBet = bets[chair] || 0; + const maxOther = Math.max(...others); + const minOther = Math.min(...others); + + if (curRank === 'low') { + // Can add up to (minOther - myBet) before leaving low + // If two others are equal, second-lowest is minOther + const sorted = others.slice().sort((a, b) => a - b); + return Math.max(0, sorted[0] - myBet); + } else if (curRank === 'mid') { + // Can add up to (maxOther - myBet) before becoming high + return Math.max(0, maxOther - myBet); + } else { + // Already high — headroom is infinite (rank can't go higher) + return Infinity; + } +} + +function renderBetImpact() { + const el = $('impact-content'); + const totalBets = liveBets.A + liveBets.B + liveBets.C; + if (totalBets === 0) { + el.innerHTML = '
Waiting for bets to calculate impact...
'; + return; + } + + // Table + let rows = ''; + for (const c of CHAIRS) { + const curRank = getRank(c, liveBets); + const curWR = rankWinRate(curRank); + const headroom = computeHeadroom(c, liveBets); + + // What rank would the chair move to? + let nextRank = '--'; + let nextWR = null; + let wrChange = ''; + if (headroom !== Infinity && headroom < 1e9) { + // Simulate adding headroom+1 + const simBets = {...liveBets, [c]: (liveBets[c] || 0) + headroom + 1}; + const nr = getRank(c, simBets); + nextRank = nr; + nextWR = rankWinRate(nr); + if (curWR !== null && nextWR !== null) { + const diff = nextWR - curWR; + wrChange = `${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%`; + } + } + + rows += ` + + + + + + + `; + } + + // Recommendations: safe bet = 80% of headroom for top pick & 2nd pick + const ranked = CHAIRS.slice().sort((a, b) => (predictionData?.prediction?.[b] || 0) - (predictionData?.prediction?.[a] || 0)); + const best = ranked[0], second = ranked[1]; + const headBest = computeHeadroom(best, liveBets); + const headSecond = computeHeadroom(second, liveBets); + const safeBest = headBest === Infinity ? 'No limit' : fmt(Math.floor(headBest * 0.8)); + const safeSecond = headSecond === Infinity ? 'No limit' : fmt(Math.floor(headSecond * 0.8)); + const bestRank = getRank(best, liveBets); + const secondRank = getRank(second, liveBets); + + el.innerHTML = ` +
Chair ${c}${curRank}${curWR !== null ? curWR.toFixed(1) + '%' : '--'}${headroom === Infinity ? 'No limit' : fmt(headroom)}${nextRank !== '--' ? `${nextRank}` : '--'}${wrChange || '--'}
+ + ${rows} +
ChairRankWin RateMax Bet to Keep RankNext RankWin Rate Change
+
+
+
Safe Bet on ${best} (Top Pick)
+
${safeBest}
+
80% of headroom · keeps ${bestRank} rank
+
+
+
Safe Bet on ${second} (2nd Pick)
+
${safeSecond}
+
80% of headroom · keeps ${secondRank} rank
+
+
+ `; } // ── Whale & Public Trends ── diff --git a/static/visitors.html b/static/visitors.html new file mode 100644 index 000000000..7034ba8db --- /dev/null +++ b/static/visitors.html @@ -0,0 +1,98 @@ + + + + + +Visitor Log + + + +
+

Visitor Log

+ +
+
+ +
+ + + +
TimeIPCountryMethodPathUser Agent
Loading...
+
+
+ + +