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
This commit is contained in:
24
app/db.py
24
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
|
@_with_lock
|
||||||
def get_recent_games(n: int = 50) -> list[dict]:
|
def get_recent_games(n: int = 50) -> list[dict]:
|
||||||
"""Get last N completed games."""
|
"""Get last N completed games."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ All blocking DB calls run in a thread executor to avoid blocking the event loop.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -51,6 +52,8 @@ class WebServer:
|
|||||||
or request.remote
|
or request.remote
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
|
log.info('%s %s %s %d "%s"', ip, request.method, path, response.status,
|
||||||
|
request.headers.get("User-Agent", "-"))
|
||||||
visitor = {
|
visitor = {
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
"country": request.headers.get("CF-IPCountry", ""),
|
"country": request.headers.get("CF-IPCountry", ""),
|
||||||
@@ -90,6 +93,28 @@ class WebServer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Visitor flush failed: %s", 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):
|
def _setup_routes(self):
|
||||||
self.app.router.add_get("/", self._handle_index)
|
self.app.router.add_get("/", self._handle_index)
|
||||||
self.app.router.add_get("/api/history", self._handle_history)
|
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("/predictions", self._handle_predictions_page)
|
||||||
self.app.router.add_get("/api/predictions", self._handle_predictions)
|
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("/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_get("/ws", self._handle_ws)
|
||||||
self.app.router.add_static("/static/", STATIC_DIR, name="static")
|
self.app.router.add_static("/static/", STATIC_DIR, name="static")
|
||||||
|
|
||||||
@@ -186,6 +213,25 @@ class WebServer:
|
|||||||
log.error("Prediction history query failed: %s", e)
|
log.error("Prediction history query failed: %s", e)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
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:
|
async def _handle_analytics(self, request: web.Request) -> web.Response:
|
||||||
period = request.query.get("period", "all")
|
period = request.query.get("period", "all")
|
||||||
if period not in ("1h", "6h", "24h", "7d", "all"):
|
if period not in ("1h", "6h", "24h", "7d", "all"):
|
||||||
@@ -272,7 +318,7 @@ class WebServer:
|
|||||||
log.warning("push_refresh failed: %s", e)
|
log.warning("push_refresh failed: %s", e)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
runner = web.AppRunner(self.app)
|
runner = web.AppRunner(self.app, access_log=None)
|
||||||
await runner.setup()
|
await runner.setup()
|
||||||
site = web.TCPSite(runner, "0.0.0.0", config.WEB_PORT)
|
site = web.TCPSite(runner, "0.0.0.0", config.WEB_PORT)
|
||||||
await site.start()
|
await site.start()
|
||||||
|
|||||||
@@ -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); }
|
.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); } }
|
@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) {
|
@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; }
|
.advisor-grid { grid-template-columns: 1fr; gap: 10px; }
|
||||||
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
|
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.pred-card .prob { font-size: 28px; }
|
.pred-card .prob { font-size: 28px; }
|
||||||
@@ -229,6 +247,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
|||||||
<div id="advisor-content" class="advisor-grid"></div>
|
<div id="advisor-content" class="advisor-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bet Impact Simulator -->
|
||||||
|
<div id="bet-impact" class="bet-advisor" style="margin-top:12px">
|
||||||
|
<div class="panel-title">Bet Impact Simulator</div>
|
||||||
|
<div id="impact-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel" style="margin-top:8px">
|
<div class="panel" style="margin-top:8px">
|
||||||
<div class="panel-title">Signal Breakdown</div>
|
<div class="panel-title">Signal Breakdown</div>
|
||||||
<table class="signal-table" id="signal-table">
|
<table class="signal-table" id="signal-table">
|
||||||
@@ -556,6 +580,119 @@ function renderBetAdvisor(data, pot) {
|
|||||||
${whaleHtml}
|
${whaleHtml}
|
||||||
${pubHtml}
|
${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 = '<div style="color:var(--text3);font-size:12px;padding:8px 0">Waiting for bets to calculate impact...</div>';
|
||||||
|
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 = `<span style="color:${diff >= 0 ? 'var(--green)' : 'var(--red)'}">${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows += `<tr>
|
||||||
|
<td style="font-weight:700;color:${CHAIR_COLORS[c]}">Chair ${c}</td>
|
||||||
|
<td><span class="impact-rank ${curRank}">${curRank}</span></td>
|
||||||
|
<td>${curWR !== null ? curWR.toFixed(1) + '%' : '--'}</td>
|
||||||
|
<td class="impact-headroom" style="color:${headroom === Infinity ? 'var(--green)' : headroom === 0 ? 'var(--red)' : 'var(--text)'}">${headroom === Infinity ? 'No limit' : fmt(headroom)}</td>
|
||||||
|
<td>${nextRank !== '--' ? `<span class="impact-rank ${nextRank}">${nextRank}</span>` : '--'}</td>
|
||||||
|
<td>${wrChange || '--'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<table class="impact-table">
|
||||||
|
<thead><tr><th>Chair</th><th>Rank</th><th>Win Rate</th><th>Max Bet to Keep Rank</th><th>Next Rank</th><th>Win Rate Change</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="impact-recs">
|
||||||
|
<div class="impact-rec">
|
||||||
|
<div class="rec-label">Safe Bet on ${best} (Top Pick)</div>
|
||||||
|
<div class="rec-value" style="color:${CHAIR_COLORS[best]}">${safeBest}</div>
|
||||||
|
<div class="rec-note">80% of headroom · keeps <span class="impact-rank ${bestRank}" style="font-size:9px">${bestRank}</span> rank</div>
|
||||||
|
</div>
|
||||||
|
<div class="impact-rec">
|
||||||
|
<div class="rec-label">Safe Bet on ${second} (2nd Pick)</div>
|
||||||
|
<div class="rec-value" style="color:${CHAIR_COLORS[second]}">${safeSecond}</div>
|
||||||
|
<div class="rec-note">80% of headroom · keeps <span class="impact-rank ${secondRank}" style="font-size:9px">${secondRank}</span> rank</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Whale & Public Trends ──
|
// ── Whale & Public Trends ──
|
||||||
|
|||||||
98
static/visitors.html
Normal file
98
static/visitors.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Visitor Log</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117; --surface: #1a1d27; --surface2: #232736;
|
||||||
|
--border: #2d3148; --text: #e4e6f0; --text2: #8b8fa3; --text3: #5a5f75;
|
||||||
|
--accent: #6c5ce7; --green: #10b981; --red: #ef4444;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
||||||
|
.nav-links { display: flex; gap: 14px; }
|
||||||
|
.nav-link { font-size: 12px; color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||||
|
.nav-link:hover { color: #a78bfa; }
|
||||||
|
|
||||||
|
.content { padding: 16px 20px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex; gap: 24px; margin-bottom: 16px; padding: 14px 20px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.stat-item { text-align: center; }
|
||||||
|
.stat-item .s-label { font-size: 10px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 2px; }
|
||||||
|
.stat-item .s-value { font-size: 22px; font-weight: 800; }
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
overflow: auto; max-height: calc(100vh - 180px);
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||||
|
th { background: var(--surface2); color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; position: sticky; top: 0; z-index: 1; }
|
||||||
|
td { color: var(--text); }
|
||||||
|
tr:hover td { background: var(--surface2); }
|
||||||
|
.loading { text-align: center; padding: 60px; color: var(--text2); font-size: 14px; }
|
||||||
|
.ua { max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Visitor Log</h1>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/" class="nav-link">Dashboard</a>
|
||||||
|
<a href="/predictions" class="nav-link">Predictions</a>
|
||||||
|
<a href="/analytics" class="nav-link">Analytics</a>
|
||||||
|
<a href="/patterns" class="nav-link">Patterns</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div id="stats" class="stats-bar" style="display:none"></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>IP</th><th>Country</th><th>Method</th><th>Path</th><th>User Agent</th></tr></thead>
|
||||||
|
<tbody id="tbody"><tr><td colspan="6" class="loading">Loading...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
fetch('/api/visitors?limit=200')
|
||||||
|
.then(r => { if (r.status === 401) throw new Error('Auth required'); return r.json(); })
|
||||||
|
.then(data => {
|
||||||
|
const tbody = document.getElementById('tbody');
|
||||||
|
if (!data.length) { tbody.innerHTML = '<tr><td colspan="6" style="color:#5a5f75;text-align:center;padding:40px">No visitors yet</td></tr>'; return; }
|
||||||
|
|
||||||
|
const ips = new Set(data.map(v => v.ip));
|
||||||
|
const countries = new Set(data.filter(v => v.country).map(v => v.country));
|
||||||
|
const stats = document.getElementById('stats');
|
||||||
|
stats.style.display = 'flex';
|
||||||
|
stats.innerHTML = `
|
||||||
|
<div class="stat-item"><div class="s-label">Total Visits</div><div class="s-value">${data.length}</div></div>
|
||||||
|
<div class="stat-item"><div class="s-label">Unique IPs</div><div class="s-value" style="color:#10b981">${ips.size}</div></div>
|
||||||
|
<div class="stat-item"><div class="s-label">Countries</div><div class="s-value" style="color:#6c5ce7">${countries.size}</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.innerHTML = data.map(v => `<tr>
|
||||||
|
<td>${v.created_at}</td>
|
||||||
|
<td>${v.ip}</td>
|
||||||
|
<td>${v.country || '--'}</td>
|
||||||
|
<td>${v.method}</td>
|
||||||
|
<td>${v.path}</td>
|
||||||
|
<td class="ua" title="${v.user_agent.replace(/"/g, '"')}">${v.user_agent}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
document.getElementById('tbody').innerHTML = `<tr><td colspan="6" style="color:#ef4444;text-align:center;padding:40px">${err.message}</td></tr>`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user