httpd: extract static files to separate directory
This commit is contained in:
431
static/dashboard.html
Normal file
431
static/dashboard.html
Normal file
@@ -0,0 +1,431 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PPF Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/lib/uPlot.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hdr">
|
||||
<h1>PPF Dashboard <a href="/map" style="font-size:12px;font-weight:normal;color:var(--dim);margin-left:12px">Map</a> <a href="/mitm" style="font-size:12px;font-weight:normal;color:var(--dim);margin-left:8px">MITM Search</a></h1>
|
||||
<div class="status">
|
||||
<span class="mode-badge mode-ssl" id="sslBadge" style="display:none">SSL</span>
|
||||
<span class="mode-badge mode-judges" id="checktypeBadge">-</span>
|
||||
<span class="mode-badge mode-profile" id="profileBadge" style="display:none">PROFILING</span>
|
||||
<div class="status-item"><div class="dot" id="dot"></div><span id="statusTxt">Connecting</span></div>
|
||||
<div class="status-item">Updated: <span id="lastUpdate">-</span></div>
|
||||
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme">
|
||||
<span class="theme-toggle-icon">☾</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Monitor Bar -->
|
||||
<div class="sysbar">
|
||||
<div class="sysbar-item"><span class="sysbar-lbl">Load:</span><span class="sysbar-val" id="sysLoad">-</span></div>
|
||||
<div class="sysbar-item"><span class="sysbar-lbl">Memory:</span><span class="sysbar-val" id="sysMemVal">-</span>
|
||||
<div class="sysbar-bar"><div class="sysbar-fill" id="sysMemFill" style="width:0"></div></div>
|
||||
<span class="sysbar-val" id="sysMemPct">-</span></div>
|
||||
<div class="sysbar-item"><span class="sysbar-lbl">Disk:</span><span class="sysbar-val" id="sysDiskVal">-</span>
|
||||
<div class="sysbar-bar"><div class="sysbar-fill" id="sysDiskFill" style="width:0"></div></div>
|
||||
<span class="sysbar-val" id="sysDiskPct">-</span></div>
|
||||
<div class="sysbar-item"><span class="sysbar-lbl">Process:</span><span class="sysbar-val" id="sysProcMem">-</span></div>
|
||||
<div class="sysbar-item sysbar-net"><span class="sysbar-lbl">Scrape:</span><span class="sysbar-val net-tx" id="netScrapeTx">-</span><span class="sysbar-val net-rx" id="netScrapeRx">-</span></div>
|
||||
<div class="sysbar-item sysbar-net"><span class="sysbar-lbl">Proxy:</span><span class="sysbar-val net-tx" id="netProxyTx">-</span><span class="sysbar-val net-rx" id="netProxyRx">-</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Primary Stats Row -->
|
||||
<div class="g g5">
|
||||
<div class="c">
|
||||
<div class="lbl">Working Proxies</div>
|
||||
<div class="val grn" id="working">-</div>
|
||||
<div class="sub">of <span id="total">-</span> in database</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Tests (Cumulative)</div>
|
||||
<div class="val" id="tested">-</div>
|
||||
<div class="sub"><span class="grn" id="passed">-</span> passed / <span class="red" id="failed">-</span> failed</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Success Rate</div>
|
||||
<div class="val-md grn" id="successRate">-</div>
|
||||
<div class="bar-wrap"><div class="bar grn" id="srBar" style="width:0"></div></div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Test Rate</div>
|
||||
<div class="val-md blu" id="rate">-</div>
|
||||
<div class="sub">tests/sec (60s)</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Uptime</div>
|
||||
<div class="val-md" id="uptime">-</div>
|
||||
<div class="sub">session duration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Worker Pool (always visible) -->
|
||||
<div class="g g2" style="margin-bottom:16px">
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Worker Pool</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Active Threads</span><span class="stat-val" id="threads">-</span></div>
|
||||
<div class="bar-wrap"><div class="bar blu" id="threadBar" style="width:0"></div></div>
|
||||
<div class="stat-row" style="margin-top:8px"><span class="stat-lbl">Job Queue</span><span class="stat-val yel" id="queue">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Tor Pool</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Total Requests</span><span class="stat-val" id="torTotal">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Success Rate</span><span class="stat-val grn" id="torSuccess">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Healthy Nodes</span><span class="stat-val" id="torHealthy">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Avg Latency</span><span class="stat-val cyn" id="torLatency">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="tabs">
|
||||
<div class="tab-nav">
|
||||
<button class="tab-btn active" data-tab="perf">Performance</button>
|
||||
<button class="tab-btn" data-tab="proto">Protocols</button>
|
||||
<button class="tab-btn" data-tab="geo">Geography</button>
|
||||
<button class="tab-btn" data-tab="infra">Infrastructure</button>
|
||||
<button class="tab-btn" data-tab="mitm">MITM</button>
|
||||
<button class="tab-btn" data-tab="db">Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Tab -->
|
||||
<div id="tab-perf" class="tab-content active">
|
||||
<!-- Rate & Success Charts -->
|
||||
<div class="g g2">
|
||||
<div class="c c-lg">
|
||||
<div class="lbl">Test Rate History (10 min)</div>
|
||||
<div class="mini">
|
||||
<div class="mini-item"><span class="mini-val blu" id="recentRate">-</span><span class="mini-lbl">current</span></div>
|
||||
<div class="mini-item"><span class="mini-val yel" id="peakRate">-</span><span class="mini-lbl">peak</span></div>
|
||||
<div class="mini-item"><span class="mini-val grn" id="passRate">-</span><span class="mini-lbl">pass/s</span></div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart chart-lg" id="rateChart"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c c-lg">
|
||||
<div class="lbl">Success Rate History</div>
|
||||
<div class="mini">
|
||||
<div class="mini-item"><span class="mini-val" id="recentSuccessRate">-</span><span class="mini-lbl">recent</span></div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart chart-lg" id="srChart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latency Section -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Latency Analysis</div>
|
||||
<div class="g g2">
|
||||
<div class="c">
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Average</span><span class="stat-val cyn" id="avgLatency">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Min</span><span class="stat-val grn" id="minLatency">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Max</span><span class="stat-val red" id="maxLatency">-</span></div>
|
||||
</div>
|
||||
<div class="pct-badges">
|
||||
<div class="pct-badge"><div class="pct-label">P50</div><div class="pct-value cyn" id="p50">-</div></div>
|
||||
<div class="pct-badge"><div class="pct-label">P90</div><div class="pct-value yel" id="p90">-</div></div>
|
||||
<div class="pct-badge"><div class="pct-label">P99</div><div class="pct-value org" id="p99">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Response Time Distribution</div>
|
||||
<div class="histo-wrap">
|
||||
<div class="histo" id="latencyHisto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Protocols Tab -->
|
||||
<div id="tab-proto" class="tab-content">
|
||||
<!-- Protocol Breakdown -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Protocol Performance</div>
|
||||
<div class="g g3">
|
||||
<div class="c proto-card">
|
||||
<div class="proto-icon">🌐</div>
|
||||
<div class="proto-name">HTTP</div>
|
||||
<div class="proto-val grn" id="httpPassed">-</div>
|
||||
<div class="sub">of <span id="httpTested">-</span> tested</div>
|
||||
<div class="proto-rate tag-ok" id="httpRate">-</div>
|
||||
</div>
|
||||
<div class="c proto-card">
|
||||
<div class="proto-icon">🔌</div>
|
||||
<div class="proto-name">SOCKS4</div>
|
||||
<div class="proto-val blu" id="socks4Passed">-</div>
|
||||
<div class="sub">of <span id="socks4Tested">-</span> tested</div>
|
||||
<div class="proto-rate tag-ok" id="socks4Rate">-</div>
|
||||
</div>
|
||||
<div class="c proto-card">
|
||||
<div class="proto-icon">🔒</div>
|
||||
<div class="proto-name">SOCKS5</div>
|
||||
<div class="proto-val pur" id="socks5Passed">-</div>
|
||||
<div class="sub">of <span id="socks5Tested">-</span> tested</div>
|
||||
<div class="proto-rate tag-ok" id="socks5Rate">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results & Failures -->
|
||||
<div class="g g2">
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Test Results</div>
|
||||
<div class="pie-wrap">
|
||||
<div class="pie" id="resultsPie"></div>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div><span class="legend-name">Passed</span><span class="legend-val grn" id="passedLeg">-</span><span class="sub" id="passedPct">-</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#f85149"></div><span class="legend-name">Failed</span><span class="legend-val red" id="failedLeg">-</span><span class="sub" id="failedPct">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Failure Breakdown</div>
|
||||
<div class="pie-wrap">
|
||||
<div class="pie" id="failPie"></div>
|
||||
<div class="legend" id="failLegend"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Geography Tab -->
|
||||
<div id="tab-geo" class="tab-content">
|
||||
<!-- Geographic Distribution -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Geographic Distribution <a href="/map" style="font-size:10px;font-weight:normal;margin-left:8px">View Map →</a></div>
|
||||
<div class="g g2">
|
||||
<div class="c">
|
||||
<div class="lbl">Proxies by Country (Database)</div>
|
||||
<div class="pie-wrap">
|
||||
<div class="pie" id="countryPie"></div>
|
||||
<div class="legend" id="countryLegend"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Top ASNs (Session)</div>
|
||||
<div class="lb-wrap">
|
||||
<div class="lb" id="topAsns"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Infrastructure Tab -->
|
||||
<div id="tab-infra" class="tab-content">
|
||||
<!-- Judge Services & Anonymity -->
|
||||
<div class="g g2">
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Judge Services</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Available</span><span class="stat-val grn" id="judgesAvail">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">In Cooldown</span><span class="stat-val yel" id="judgesCooldown">-</span></div>
|
||||
</div>
|
||||
<div class="lbl" style="margin-top:10px">Top Performers</div>
|
||||
<div class="lb-wrap">
|
||||
<div id="topJudges"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Anonymity Levels</div>
|
||||
<div class="stats-wrap">
|
||||
<div id="anonBreakdown"></div>
|
||||
</div>
|
||||
<div class="sub" style="margin-top:8px;font-size:10px">Elite = no headers, Anonymous = adds headers, Transparent = reveals IP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tor Exit Nodes -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Tor Exit Nodes</div>
|
||||
<div class="g g3" id="torPool"></div>
|
||||
</div>
|
||||
|
||||
<!-- Network Usage -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Network Usage</div>
|
||||
<div class="g g3">
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Total RX</div>
|
||||
<div class="val-sm grn" id="netRx">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Total TX</div>
|
||||
<div class="val-sm blu" id="netTx">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Total</div>
|
||||
<div class="val-sm cyn" id="netTotal">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g g2" style="margin-top:12px">
|
||||
<div class="c">
|
||||
<div class="lbl" style="text-align:center;margin-bottom:8px">By Category</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Proxy Testing</span><span class="stat-val cyn" id="netProxy">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Scraping</span><span class="stat-val yel" id="netScraper">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl" style="text-align:center;margin-bottom:8px">Rates (avg)</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">RX Rate</span><span class="stat-val grn" id="netRxRate">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">TX Rate</span><span class="stat-val blu" id="netTxRate">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lbl" style="margin-top:12px">Per Tor Node</div>
|
||||
<div class="g g3" id="netTorNodes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scraper & SSL Stats -->
|
||||
<div class="g g2">
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Search Engines</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Available</span><span class="stat-val grn" id="engAvail">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">In Backoff</span><span class="stat-val yel" id="engBackoff">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Total</span><span class="stat-val" id="engTotal">-</span></div>
|
||||
</div>
|
||||
<div class="lbl" style="margin-top:10px">Top Engines</div>
|
||||
<div class="lb-wrap">
|
||||
<div class="lb" id="topEngines"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">SSL/TLS Security</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">SSL Tests</span><span class="stat-val" id="sslTested">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Passed</span><span class="stat-val grn" id="sslPassed">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Failed</span><span class="stat-val red" id="sslFailed">-</span></div>
|
||||
<div class="bar-wrap" style="margin:8px 0"><div class="bar grn" id="sslBar" style="width:0"></div></div>
|
||||
<div class="stat-row"><span class="stat-lbl">MITM Detected</span><span class="stat-val red" id="mitmDetected">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Cert Errors</span><span class="stat-val yel" id="certErrors">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- MITM Tab -->
|
||||
<div id="tab-mitm" class="tab-content">
|
||||
<!-- MITM Certificate Statistics -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">MITM Detection Summary</div>
|
||||
<div class="g g4">
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Total Detections</div>
|
||||
<div class="val-sm red" id="mitmTotal">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Unique Certs</div>
|
||||
<div class="val-sm yel" id="mitmUniqueCerts">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Unique Proxies</div>
|
||||
<div class="val-sm org" id="mitmUniqueProxies">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">SSL Tests</div>
|
||||
<div class="val-sm cyn" id="mitmSslTests">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g g2">
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Top Organizations</div>
|
||||
<div class="lb-wrap">
|
||||
<div class="lb" id="mitmOrgs"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="sec-hdr" style="margin-top:0">Top Issuers</div>
|
||||
<div class="lb-wrap">
|
||||
<div class="lb" id="mitmIssuers"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Certificate Details</div>
|
||||
<div class="stats-wrap" id="mitmCerts" style="max-height:300px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Recent Detections</div>
|
||||
<div class="stats-wrap" id="mitmRecent" style="max-height:200px;overflow-y:auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Tab -->
|
||||
<div id="tab-db" class="tab-content">
|
||||
<!-- Database Stats -->
|
||||
<div class="sec">
|
||||
<div class="sec-hdr">Database Overview</div>
|
||||
<div class="g g4">
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Database Size</div>
|
||||
<div class="val-sm cyn" id="dbSize">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Tested (1h)</div>
|
||||
<div class="val-sm blu" id="dbTestedHour">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Added (24h)</div>
|
||||
<div class="val-sm grn" id="dbAddedDay">-</div>
|
||||
</div>
|
||||
<div class="c c-sm">
|
||||
<div class="lbl">Dead Proxies</div>
|
||||
<div class="val-sm red" id="dbDead">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g g3">
|
||||
<div class="c">
|
||||
<div class="lbl">Working by Protocol</div>
|
||||
<div class="stats-wrap">
|
||||
<div id="dbByProto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Latency Stats</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Average</span><span class="stat-val cyn" id="dbAvgLat">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Min</span><span class="stat-val grn" id="dbMinLat">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Max</span><span class="stat-val red" id="dbMaxLat">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c">
|
||||
<div class="lbl">Activity</div>
|
||||
<div class="stats-wrap">
|
||||
<div class="stat-row"><span class="stat-lbl">Failing</span><span class="stat-val yel" id="dbFailing">-</span></div>
|
||||
<div class="stat-row"><span class="stat-lbl">Freelist</span><span class="stat-val" id="dbFreelist">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftr">PPF Python Proxy Finder</div>
|
||||
</div>
|
||||
<script src="/static/lib/uPlot.min.js"></script>
|
||||
<script src="/static/lib/chart.min.js"></script>
|
||||
<script src="/static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
651
static/dashboard.js
Normal file
651
static/dashboard.js
Normal file
@@ -0,0 +1,651 @@
|
||||
/* PPF Dashboard JavaScript */
|
||||
var $ = function(id) { return document.getElementById(id); };
|
||||
var $$ = function(sel) { return document.querySelectorAll(sel); };
|
||||
var fmt = function(n) { return n == null ? '-' : n.toLocaleString(); };
|
||||
|
||||
// uPlot chart instances (persistent)
|
||||
var uplotCharts = {};
|
||||
|
||||
// Chart.js instances (persistent)
|
||||
var chartJsInstances = {};
|
||||
|
||||
// Network rate tracking (for real-time speed calculation)
|
||||
var prevNet = null;
|
||||
var prevNetTime = null;
|
||||
|
||||
// Tab switching
|
||||
function initTabs() {
|
||||
$$('.tab-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var tabId = this.dataset.tab;
|
||||
// Update buttons
|
||||
$$('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
// Update content
|
||||
$$('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
||||
var content = $('tab-' + tabId);
|
||||
if (content) content.classList.add('active');
|
||||
// Save preference
|
||||
try { localStorage.setItem('ppf-tab', tabId); } catch(e) {}
|
||||
});
|
||||
});
|
||||
// Restore saved tab
|
||||
try {
|
||||
var saved = localStorage.getItem('ppf-tab');
|
||||
if (saved) {
|
||||
var btn = document.querySelector('.tab-btn[data-tab="' + saved + '"]');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initTabs);
|
||||
|
||||
// Theme toggle (cycles: dark -> muted-dark -> light -> dark)
|
||||
var themes = ['dark', 'muted-dark', 'light'];
|
||||
function getTheme() {
|
||||
if (document.documentElement.classList.contains('light')) return 'light';
|
||||
if (document.documentElement.classList.contains('muted-dark')) return 'muted-dark';
|
||||
return 'dark';
|
||||
}
|
||||
function setTheme(theme) {
|
||||
document.documentElement.classList.remove('light', 'muted-dark');
|
||||
if (theme === 'light') document.documentElement.classList.add('light');
|
||||
else if (theme === 'muted-dark') document.documentElement.classList.add('muted-dark');
|
||||
try { localStorage.setItem('ppf-theme', theme); } catch(e) {}
|
||||
}
|
||||
function initTheme() {
|
||||
// Check saved preference or system preference
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('ppf-theme'); } catch(e) {}
|
||||
if (saved && themes.indexOf(saved) !== -1) {
|
||||
setTheme(saved);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
setTheme('light');
|
||||
}
|
||||
// Setup toggle button
|
||||
var btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var current = getTheme();
|
||||
var idx = themes.indexOf(current);
|
||||
var next = themes[(idx + 1) % themes.length];
|
||||
setTheme(next);
|
||||
});
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initTheme);
|
||||
|
||||
var fmtDec = function(n, d) { return n == null ? '-' : n.toFixed(d || 1); };
|
||||
var pct = function(n, t) { return t > 0 ? ((n / t) * 100).toFixed(1) : '0.0'; };
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (!b || b <= 0) return '-';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = 0;
|
||||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function fmtRate(bps) {
|
||||
if (bps < 1) return '0';
|
||||
if (bps < 1024) return bps.toFixed(0) + 'B';
|
||||
if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + 'K';
|
||||
return (bps / (1024 * 1024)).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
function fmtTime(s) {
|
||||
if (!s) return '-';
|
||||
var d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm ' + Math.floor(s % 60) + 's';
|
||||
}
|
||||
|
||||
function setBarColor(el, pct) {
|
||||
if (pct > 90) return 'background:var(--red)';
|
||||
if (pct > 70) return 'background:var(--yellow)';
|
||||
return 'background:var(--green)';
|
||||
}
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!ms || ms <= 0) return '-';
|
||||
if (ms < 1000) return Math.round(ms) + 'ms';
|
||||
return (ms / 1000).toFixed(1) + 's';
|
||||
}
|
||||
|
||||
function setBar(id, val, max, cls) {
|
||||
var el = $(id);
|
||||
if (el) { el.style.width = Math.min(val / max * 100, 100) + '%'; el.className = 'bar ' + (cls || 'grn'); }
|
||||
}
|
||||
|
||||
// uPlot-based line chart with electric cyan theme
|
||||
function renderLineChart(id, data, color, maxVal) {
|
||||
var el = $(id); if (!el || !data || data.length < 2) return;
|
||||
var w = el.clientWidth || 300, h = el.clientHeight || 80;
|
||||
var max = maxVal || Math.max.apply(null, data) || 1;
|
||||
|
||||
// Generate time indices (mock timestamps, 3s apart)
|
||||
var now = Date.now() / 1000;
|
||||
var times = data.map(function(_, i) { return now - (data.length - 1 - i) * 3; });
|
||||
|
||||
var opts = {
|
||||
width: w,
|
||||
height: h,
|
||||
padding: [4, 4, 4, 4],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{ show: false }, // x-axis hidden
|
||||
{ show: false } // y-axis hidden
|
||||
],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: { range: [0, max * 1.1] }
|
||||
},
|
||||
series: [
|
||||
{}, // x series (timestamps)
|
||||
{
|
||||
stroke: color,
|
||||
width: 2,
|
||||
fill: function(u, seriesIdx) {
|
||||
var grad = u.ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, color.replace(')', ',0.4)').replace('rgb', 'rgba'));
|
||||
grad.addColorStop(1, color.replace(')', ',0.05)').replace('rgb', 'rgba'));
|
||||
return grad;
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Destroy existing chart if any
|
||||
if (uplotCharts[id]) {
|
||||
uplotCharts[id].destroy();
|
||||
}
|
||||
el.innerHTML = '';
|
||||
uplotCharts[id] = new uPlot(opts, [times, data], el);
|
||||
}
|
||||
|
||||
// Chart.js doughnut chart with electric cyan theme
|
||||
function renderDoughnutChart(id, labels, values, colors, cutout) {
|
||||
var el = $(id); if (!el) return;
|
||||
cutout = cutout || '65%';
|
||||
|
||||
// Convert div to canvas if needed
|
||||
var canvas;
|
||||
if (el.tagName !== 'CANVAS') {
|
||||
canvas = el.querySelector('canvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
el.innerHTML = '';
|
||||
el.appendChild(canvas);
|
||||
}
|
||||
} else {
|
||||
canvas = el;
|
||||
}
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
var total = values.reduce(function(a, b) { return a + b; }, 0);
|
||||
if (total === 0) return;
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartJsInstances[id]) {
|
||||
chartJsInstances[id].destroy();
|
||||
}
|
||||
|
||||
chartJsInstances[id] = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: colors,
|
||||
borderColor: 'rgba(24,31,42,0.8)',
|
||||
borderWidth: 2,
|
||||
hoverBorderColor: '#38bdf8',
|
||||
hoverBorderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: cutout,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(24,31,42,0.95)',
|
||||
titleColor: '#38bdf8',
|
||||
bodyColor: '#e6edf3',
|
||||
borderColor: 'rgba(56,189,248,0.3)',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var pct = ((ctx.raw / total) * 100).toFixed(1);
|
||||
return ctx.label + ': ' + fmt(ctx.raw) + ' (' + pct + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: { duration: 400, easing: 'easeOutQuart' }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderHistogram(id, data) {
|
||||
var el = $(id); if (!el || !data || !data.length) return;
|
||||
var max = Math.max.apply(null, data.map(function(d) { return d.pct; })) || 1;
|
||||
var colors = ['#3fb950', '#3fb950', '#58a6ff', '#58a6ff', '#d29922', '#f85149', '#f85149'];
|
||||
var html = '';
|
||||
data.forEach(function(d, i) {
|
||||
var h = Math.max(4, (d.pct / max) * 100);
|
||||
var c = colors[Math.min(i, colors.length - 1)];
|
||||
html += '<div class="histo-bar" style="height:' + h + '%;background:' + c + '" data-label="' + d.range + '" title="' + d.range + 'ms: ' + d.count + ' (' + d.pct + '%)"></div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderLeaderboard(id, data, nameKey, valKey, limit) {
|
||||
var el = $(id); if (!el) return;
|
||||
limit = limit || 8;
|
||||
if (!data || !data.length) { el.innerHTML = '<div style="color:var(--dim);font-size:11px;padding:8px 0">No data</div>'; return; }
|
||||
var html = '';
|
||||
data.slice(0, limit).forEach(function(item, i) {
|
||||
var name = Array.isArray(item) ? item[0] : item[nameKey];
|
||||
var val = Array.isArray(item) ? item[1] : item[valKey];
|
||||
html += '<div class="lb-item"><div class="lb-rank' + (i === 0 ? ' top' : '') + '">' + (i + 1) + '</div>';
|
||||
html += '<span class="lb-name">' + name + '</span><span class="lb-val grn">' + fmt(val) + '</span></div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function update(d) {
|
||||
$('dot').className = 'dot'; $('statusTxt').textContent = 'Live';
|
||||
|
||||
// SSL badge (main test mode when enabled)
|
||||
var sslBadge = $('sslBadge');
|
||||
if (sslBadge) {
|
||||
sslBadge.style.display = d.use_ssl ? 'inline-block' : 'none';
|
||||
}
|
||||
// Check type badge (fallback/secondary indicator)
|
||||
var ct = d.checktype || 'unknown';
|
||||
var ctBadge = $('checktypeBadge');
|
||||
if (ctBadge) {
|
||||
ctBadge.textContent = ct.toUpperCase();
|
||||
ctBadge.className = 'mode-badge mode-' + ct;
|
||||
}
|
||||
// Profiling badge
|
||||
var profBadge = $('profileBadge');
|
||||
if (profBadge) {
|
||||
profBadge.style.display = d.profiling ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
// System monitor bar
|
||||
var sys = d.system || {};
|
||||
$('sysLoad').textContent = (sys.load_1m || 0) + ' / ' + (sys.cpu_count || 1);
|
||||
$('sysMemVal').textContent = fmtBytes(sys.mem_used) + ' / ' + fmtBytes(sys.mem_total);
|
||||
$('sysMemPct').textContent = (sys.mem_pct || 0) + '%';
|
||||
var memFill = $('sysMemFill');
|
||||
if (memFill) { memFill.style.width = (sys.mem_pct || 0) + '%'; memFill.style.cssText = 'width:' + (sys.mem_pct || 0) + '%;' + setBarColor(memFill, sys.mem_pct || 0); }
|
||||
$('sysDiskVal').textContent = fmtBytes(sys.disk_used) + ' / ' + fmtBytes(sys.disk_total);
|
||||
$('sysDiskPct').textContent = (sys.disk_pct || 0) + '%';
|
||||
var diskFill = $('sysDiskFill');
|
||||
if (diskFill) { diskFill.style.width = (sys.disk_pct || 0) + '%'; diskFill.style.cssText = 'width:' + (sys.disk_pct || 0) + '%;' + setBarColor(diskFill, sys.disk_pct || 0); }
|
||||
var memGrowth = sys.proc_rss_growth || 0;
|
||||
var memGrowthStr = memGrowth > 0 ? ' (+' + fmtBytes(memGrowth) + ')' : '';
|
||||
$('sysProcMem').textContent = fmtBytes(sys.proc_rss) + memGrowthStr;
|
||||
|
||||
// Network speed (current rate, not average)
|
||||
var net = d.network || {};
|
||||
var now = Date.now();
|
||||
if (prevNet && prevNetTime) {
|
||||
var dt = (now - prevNetTime) / 1000;
|
||||
if (dt > 0) {
|
||||
var s = net.scraper || {}, ps = prevNet.scraper || {};
|
||||
var p = net.proxy || {}, pp = prevNet.proxy || {};
|
||||
$('netScrapeTx').textContent = fmtRate((s.bytes_tx - (ps.bytes_tx || 0)) / dt);
|
||||
$('netScrapeRx').textContent = fmtRate((s.bytes_rx - (ps.bytes_rx || 0)) / dt);
|
||||
$('netProxyTx').textContent = fmtRate((p.bytes_tx - (pp.bytes_tx || 0)) / dt);
|
||||
$('netProxyRx').textContent = fmtRate((p.bytes_rx - (pp.bytes_rx || 0)) / dt);
|
||||
}
|
||||
}
|
||||
prevNet = net;
|
||||
prevNetTime = now;
|
||||
|
||||
// Main stats - db stats are nested under d.db
|
||||
var db = d.db || {};
|
||||
$('working').textContent = fmt(db.working);
|
||||
$('total').textContent = fmt(db.total);
|
||||
$('tested').textContent = fmt(d.tested);
|
||||
$('passed').textContent = fmt(d.passed);
|
||||
$('failed').textContent = fmt(d.failed);
|
||||
|
||||
// Success rate
|
||||
var sr = d.success_rate || 0;
|
||||
$('successRate').textContent = fmtDec(sr, 1) + '%';
|
||||
$('successRate').className = 'val-md ' + (sr < 20 ? 'red' : sr < 50 ? 'yel' : 'grn');
|
||||
setBar('srBar', sr, 100, sr < 20 ? 'red' : sr < 50 ? 'yel' : 'grn');
|
||||
|
||||
var rsr = d.recent_success_rate || 0;
|
||||
$('recentSuccessRate').textContent = fmtDec(rsr, 1) + '%';
|
||||
$('recentSuccessRate').className = 'stat-val ' + (rsr < 20 ? 'red' : rsr < 50 ? 'yel' : 'grn');
|
||||
|
||||
// Rates
|
||||
$('rate').textContent = fmtDec(d.recent_rate, 2);
|
||||
$('recentRate').textContent = fmtDec(d.recent_rate, 2) + '/s';
|
||||
$('peakRate').textContent = fmtDec(d.peak_rate, 2) + '/s';
|
||||
$('passRate').textContent = fmtDec(d.pass_rate, 3);
|
||||
|
||||
// Latency
|
||||
var lat = d.avg_latency || 0;
|
||||
$('avgLatency').textContent = fmtMs(lat);
|
||||
$('minLatency').textContent = fmtMs(d.min_latency);
|
||||
$('maxLatency').textContent = fmtMs(d.max_latency);
|
||||
|
||||
var pctl = d.latency_percentiles || {};
|
||||
$('p50').textContent = fmtMs(pctl.p50);
|
||||
$('p90').textContent = fmtMs(pctl.p90);
|
||||
$('p99').textContent = fmtMs(pctl.p99);
|
||||
|
||||
// System
|
||||
$('threads').textContent = d.threads + '/' + d.max_threads;
|
||||
setBar('threadBar', d.threads, d.max_threads, 'blu');
|
||||
$('queue').textContent = fmt(d.queue_size);
|
||||
$('uptime').textContent = fmtTime(d.uptime_seconds);
|
||||
|
||||
// Charts
|
||||
renderLineChart('rateChart', d.rate_history, '#58a6ff', d.peak_rate * 1.1);
|
||||
renderLineChart('srChart', d.success_rate_history, '#3fb950', 100);
|
||||
renderHistogram('latencyHisto', d.latency_histogram);
|
||||
|
||||
// Protocol breakdown
|
||||
var ps = d.proto_stats || {};
|
||||
['http', 'socks4', 'socks5'].forEach(function(p) {
|
||||
var s = ps[p] || {passed: 0, tested: 0, success_rate: 0};
|
||||
$(p + 'Passed').textContent = fmt(s.passed);
|
||||
$(p + 'Tested').textContent = fmt(s.tested);
|
||||
var rateEl = $(p + 'Rate');
|
||||
rateEl.textContent = fmtDec(s.success_rate, 0) + '%';
|
||||
rateEl.className = 'proto-rate ' + (s.success_rate < 20 ? 'tag-err' : s.success_rate < 50 ? 'tag-warn' : 'tag-ok');
|
||||
});
|
||||
|
||||
// Results pie (Chart.js doughnut)
|
||||
var passed = d.passed || 0, failed = d.failed || 0, total = passed + failed;
|
||||
if (total > 0) {
|
||||
renderDoughnutChart('resultsPie', ['Passed', 'Failed'], [passed, failed], ['#3fb950', '#f85149']);
|
||||
}
|
||||
$('passedLeg').textContent = fmt(passed);
|
||||
$('passedPct').textContent = pct(passed, total) + '%';
|
||||
$('failedLeg').textContent = fmt(failed);
|
||||
$('failedPct').textContent = pct(failed, total) + '%';
|
||||
|
||||
// Failures breakdown (Chart.js doughnut)
|
||||
var fhtml = '', failColors = ['#f85149','#db6d28','#d29922','#58a6ff','#a371f7','#39c5cf','#db61a2','#7d8590'];
|
||||
if (d.failures && Object.keys(d.failures).length > 0) {
|
||||
var cats = Object.keys(d.failures).sort(function(a,b) { return d.failures[b] - d.failures[a]; });
|
||||
var failVals = cats.map(function(c) { return d.failures[c]; });
|
||||
var failCols = cats.map(function(_, i) { return failColors[i % failColors.length]; });
|
||||
renderDoughnutChart('failPie', cats, failVals, failCols);
|
||||
cats.forEach(function(cat, i) {
|
||||
var n = d.failures[cat], col = failColors[i % failColors.length];
|
||||
fhtml += '<div class="legend-item"><div class="legend-dot" style="background:' + col + '"></div>';
|
||||
fhtml += '<span class="legend-name">' + cat + '</span><span class="legend-val">' + n + '</span></div>';
|
||||
});
|
||||
} else {
|
||||
fhtml = '<div style="color:var(--dim);font-size:11px">No failures yet</div>';
|
||||
}
|
||||
$('failLegend').innerHTML = fhtml;
|
||||
|
||||
// Leaderboards (session data)
|
||||
renderLeaderboard('topAsns', d.top_asns_session, 'asn', 'count');
|
||||
|
||||
// Country pie chart (Chart.js doughnut)
|
||||
var countryColors = ['#58a6ff','#3fb950','#d29922','#f85149','#a371f7','#39c5cf','#db61a2','#db6d28','#7ee787','#7d8590'];
|
||||
if (d.db && d.db.top_countries && d.db.top_countries.length > 0) {
|
||||
var countries = d.db.top_countries.slice(0, 8);
|
||||
var countryTotal = countries.reduce(function(s, c) { return s + (c.count || c[1] || 0); }, 0);
|
||||
var cLabels = [], cValues = [], cColors = [], chtml = '';
|
||||
countries.forEach(function(c, i) {
|
||||
var code = c.code || c[0], cnt = c.count || c[1] || 0;
|
||||
var col = countryColors[i % countryColors.length];
|
||||
var pctVal = countryTotal > 0 ? ((cnt / countryTotal) * 100).toFixed(1) : '0';
|
||||
cLabels.push(code);
|
||||
cValues.push(cnt);
|
||||
cColors.push(col);
|
||||
chtml += '<div class="legend-item"><div class="legend-dot" style="background:' + col + '"></div>';
|
||||
chtml += '<span class="legend-name">' + code + '</span><span class="legend-val">' + fmt(cnt) + '</span>';
|
||||
chtml += '<span class="sub" style="margin-left:4px">' + pctVal + '%</span></div>';
|
||||
});
|
||||
renderDoughnutChart('countryPie', cLabels, cValues, cColors);
|
||||
$('countryLegend').innerHTML = chtml;
|
||||
} else {
|
||||
$('countryLegend').innerHTML = '<div style="color:var(--dim);font-size:11px">No data</div>';
|
||||
}
|
||||
|
||||
// Tor pool
|
||||
var thtml = '';
|
||||
if (d.tor_pool && d.tor_pool.hosts) {
|
||||
d.tor_pool.hosts.forEach(function(h) {
|
||||
// Status: OK only if available AND has successes, WARN if available but 0%, DOWN if in backoff
|
||||
var statusCls = !h.healthy ? 'tag-err' : (h.success_rate > 0 ? 'tag-ok' : 'tag-warn');
|
||||
var statusTxt = !h.healthy ? 'DOWN' : (h.success_rate > 0 ? 'OK' : 'IDLE');
|
||||
thtml += '<div class="tor-card"><div class="host-card">';
|
||||
thtml += '<span class="host-addr">' + h.address + '</span>';
|
||||
thtml += '<span class="tag ' + statusCls + '">' + statusTxt + '</span>';
|
||||
thtml += '</div><div class="host-stats">' + fmtMs(h.latency_ms) + ' / ' + fmtDec(h.success_rate, 0) + '% success</div></div>';
|
||||
});
|
||||
}
|
||||
$('torPool').innerHTML = thtml || '<div class="tor-card" style="color:var(--dim)">No Tor hosts</div>';
|
||||
|
||||
// Judges
|
||||
if (d.judges) {
|
||||
$('judgesAvail').textContent = d.judges.available + '/' + d.judges.total;
|
||||
$('judgesCooldown').textContent = d.judges.in_cooldown;
|
||||
var jhtml = '';
|
||||
if (d.judges.top_judges) {
|
||||
d.judges.top_judges.slice(0, 6).forEach(function(j) {
|
||||
jhtml += '<div class="judge-item"><span class="judge-name">' + j.judge + '</span>';
|
||||
jhtml += '<div class="judge-stats"><span class="grn">' + j.success + '/' + j.tests + '</span>';
|
||||
jhtml += '<span style="color:var(--dim)">' + fmtDec(j.rate, 0) + '%</span></div></div>';
|
||||
});
|
||||
}
|
||||
$('topJudges').innerHTML = jhtml || '<div style="color:var(--dim)">No data</div>';
|
||||
}
|
||||
|
||||
// Database stats
|
||||
if (d.db) {
|
||||
var dbs = d.db;
|
||||
$('dbByProto').innerHTML = ['http', 'socks4', 'socks5'].map(function(p) {
|
||||
var c = dbs.by_proto && dbs.by_proto[p] || 0;
|
||||
return '<div class="stat-row"><span class="stat-lbl">' + p.toUpperCase() + '</span><span class="stat-val">' + fmt(c) + '</span></div>';
|
||||
}).join('');
|
||||
|
||||
}
|
||||
|
||||
// Scraper/Engine stats
|
||||
$('engAvail').textContent = fmt(d.engines_available);
|
||||
$('engBackoff').textContent = fmt(d.engines_backoff);
|
||||
$('engTotal').textContent = fmt(d.engines_total);
|
||||
if (d.scraper && d.scraper.engines) {
|
||||
var ehtml = '';
|
||||
d.scraper.engines.slice(0, 5).forEach(function(e, i) {
|
||||
var statusCls = e.available ? 'tag-ok' : 'tag-warn';
|
||||
var statusTxt = e.available ? 'OK' : (e.backoff_remaining > 0 ? e.backoff_remaining + 's' : 'OFF');
|
||||
ehtml += '<div class="lb-item"><div class="lb-rank' + (i === 0 ? ' top' : '') + '">' + (i + 1) + '</div>';
|
||||
ehtml += '<span class="lb-name">' + e.name + '</span>';
|
||||
ehtml += '<span class="tag ' + statusCls + '">' + statusTxt + '</span>';
|
||||
ehtml += '<span class="lb-val grn">' + fmt(e.successes) + '</span></div>';
|
||||
});
|
||||
$('topEngines').innerHTML = ehtml || '<div style="color:var(--dim)">No engines</div>';
|
||||
} else {
|
||||
$('topEngines').innerHTML = '<div style="color:var(--dim)">Scraper disabled</div>';
|
||||
}
|
||||
|
||||
// SSL/TLS stats
|
||||
if (d.ssl) {
|
||||
var ssl = d.ssl;
|
||||
$('sslTested').textContent = fmt(ssl.tested);
|
||||
$('sslPassed').textContent = fmt(ssl.passed);
|
||||
$('sslFailed').textContent = fmt(ssl.failed);
|
||||
var sslRate = ssl.success_rate || 0;
|
||||
setBar('sslBar', sslRate, 100, sslRate < 50 ? 'red' : sslRate < 80 ? 'yel' : 'grn');
|
||||
$('mitmDetected').textContent = fmt(ssl.mitm_detected);
|
||||
$('mitmDetected').className = 'stat-val ' + (ssl.mitm_detected > 0 ? 'red' : 'grn');
|
||||
$('certErrors').textContent = fmt(ssl.cert_errors);
|
||||
$('certErrors').className = 'stat-val ' + (ssl.cert_errors > 0 ? 'yel' : 'grn');
|
||||
}
|
||||
|
||||
// Anonymity breakdown
|
||||
var dbh = d.db_health || {};
|
||||
if (dbh.anonymity) {
|
||||
var anonHtml = '';
|
||||
var anonColors = {elite: 'grn', anonymous: 'blu', transparent: 'yel', unknown: 'dim'};
|
||||
var anonOrder = ['elite', 'anonymous', 'transparent', 'unknown'];
|
||||
anonOrder.forEach(function(level) {
|
||||
var count = dbh.anonymity[level] || 0;
|
||||
if (count > 0) {
|
||||
anonHtml += '<div class="stat-row"><span class="stat-lbl">' + level.charAt(0).toUpperCase() + level.slice(1) + '</span>';
|
||||
anonHtml += '<span class="stat-val ' + anonColors[level] + '">' + fmt(count) + '</span></div>';
|
||||
}
|
||||
});
|
||||
$('anonBreakdown').innerHTML = anonHtml || '<div style="color:var(--dim)">No data</div>';
|
||||
}
|
||||
|
||||
// Database health
|
||||
if (dbh.db_size) {
|
||||
$('dbSize').textContent = fmtBytes(dbh.db_size);
|
||||
$('dbTestedHour').textContent = fmt(dbh.tested_last_hour);
|
||||
$('dbAddedDay').textContent = fmt(dbh.added_last_day);
|
||||
$('dbDead').textContent = fmt(dbh.dead_count);
|
||||
$('dbFailing').textContent = fmt(dbh.failing_count);
|
||||
$('dbFreelist').textContent = fmt(dbh.freelist_count);
|
||||
$('dbAvgLat').textContent = fmtMs(dbh.db_avg_latency);
|
||||
$('dbMinLat').textContent = fmtMs(dbh.db_min_latency);
|
||||
$('dbMaxLat').textContent = fmtMs(dbh.db_max_latency);
|
||||
}
|
||||
|
||||
// Tor pool enhanced stats
|
||||
if (d.tor_pool) {
|
||||
var tp = d.tor_pool;
|
||||
$('torTotal').textContent = fmt(tp.total_requests || 0);
|
||||
$('torSuccess').textContent = fmtDec(tp.success_rate || 0, 1) + '%';
|
||||
$('torHealthy').textContent = (tp.healthy_count || 0) + '/' + (tp.total_count || 0);
|
||||
if (tp.avg_latency) {
|
||||
$('torLatency').textContent = fmtMs(tp.avg_latency);
|
||||
}
|
||||
}
|
||||
|
||||
// MITM certificate stats
|
||||
if (d.mitm) {
|
||||
var mitm = d.mitm;
|
||||
$('mitmTotal').textContent = fmt(mitm.total_detections || 0);
|
||||
$('mitmUniqueCerts').textContent = fmt(mitm.unique_certs || 0);
|
||||
$('mitmUniqueProxies').textContent = fmt(mitm.unique_proxies || 0);
|
||||
$('mitmSslTests').textContent = d.ssl ? fmt(d.ssl.tested || 0) : '-';
|
||||
|
||||
// Top organizations
|
||||
var orgsHtml = '';
|
||||
if (mitm.top_organizations && mitm.top_organizations.length > 0) {
|
||||
mitm.top_organizations.slice(0, 8).forEach(function(org, i) {
|
||||
orgsHtml += '<div class="lb-item"><div class="lb-rank' + (i === 0 ? ' top' : '') + '">' + (i + 1) + '</div>';
|
||||
orgsHtml += '<span class="lb-name">' + (org.name || 'Unknown') + '</span>';
|
||||
orgsHtml += '<span class="lb-val red">' + fmt(org.count) + '</span></div>';
|
||||
});
|
||||
} else {
|
||||
orgsHtml = '<div style="color:var(--dim);font-size:11px;padding:8px 0">No MITM certs detected</div>';
|
||||
}
|
||||
$('mitmOrgs').innerHTML = orgsHtml;
|
||||
|
||||
// Top issuers
|
||||
var issHtml = '';
|
||||
if (mitm.top_issuers && mitm.top_issuers.length > 0) {
|
||||
mitm.top_issuers.slice(0, 8).forEach(function(iss, i) {
|
||||
issHtml += '<div class="lb-item"><div class="lb-rank' + (i === 0 ? ' top' : '') + '">' + (i + 1) + '</div>';
|
||||
issHtml += '<span class="lb-name">' + (iss.name || 'Unknown') + '</span>';
|
||||
issHtml += '<span class="lb-val yel">' + fmt(iss.count) + '</span></div>';
|
||||
});
|
||||
} else {
|
||||
issHtml = '<div style="color:var(--dim);font-size:11px;padding:8px 0">No MITM certs detected</div>';
|
||||
}
|
||||
$('mitmIssuers').innerHTML = issHtml;
|
||||
|
||||
// Certificate details
|
||||
var certHtml = '';
|
||||
if (mitm.certificates && mitm.certificates.length > 0) {
|
||||
mitm.certificates.slice(0, 10).forEach(function(cert) {
|
||||
certHtml += '<div class="stat-row" style="flex-wrap:wrap;padding:6px 0;border-bottom:1px solid var(--border)">';
|
||||
certHtml += '<div style="width:100%;margin-bottom:4px">';
|
||||
certHtml += '<span class="stat-lbl" style="font-weight:500">CN:</span> <span class="cyn">' + (cert.subject_cn || '-') + '</span>';
|
||||
certHtml += '</div>';
|
||||
certHtml += '<div style="width:50%"><span class="stat-lbl">Org:</span> ' + (cert.subject_o || '-') + '</div>';
|
||||
certHtml += '<div style="width:50%"><span class="stat-lbl">Issuer:</span> ' + (cert.issuer_cn || '-') + '</div>';
|
||||
certHtml += '<div style="width:50%"><span class="stat-lbl">Count:</span> <span class="red">' + fmt(cert.count || 1) + '</span></div>';
|
||||
certHtml += '<div style="width:50%"><span class="stat-lbl">Proxies:</span> ' + (cert.proxies ? cert.proxies.length : 0) + '</div>';
|
||||
certHtml += '<div style="width:100%;font-size:9px;color:var(--dim)">FP: ' + (cert.fingerprint || '-') + '</div>';
|
||||
certHtml += '</div>';
|
||||
});
|
||||
} else {
|
||||
certHtml = '<div style="color:var(--dim);font-size:11px;padding:12px 0">No MITM certificates captured yet</div>';
|
||||
}
|
||||
$('mitmCerts').innerHTML = certHtml;
|
||||
|
||||
// Recent detections
|
||||
var recentHtml = '';
|
||||
if (mitm.recent && mitm.recent.length > 0) {
|
||||
mitm.recent.slice(-10).reverse().forEach(function(r) {
|
||||
var ts = r.timestamp ? new Date(r.timestamp * 1000).toLocaleTimeString() : '-';
|
||||
recentHtml += '<div class="stat-row" style="padding:4px 0;border-bottom:1px solid var(--border)">';
|
||||
recentHtml += '<span class="stat-lbl" style="color:var(--dim);width:60px">' + ts + '</span>';
|
||||
recentHtml += '<span style="flex:1">' + (r.proxy || '-') + '</span>';
|
||||
recentHtml += '<span class="cyn" style="width:120px;overflow:hidden;text-overflow:ellipsis">' + (r.subject_cn || '-') + '</span>';
|
||||
recentHtml += '</div>';
|
||||
});
|
||||
} else {
|
||||
recentHtml = '<div style="color:var(--dim);font-size:11px;padding:12px 0">No recent MITM detections</div>';
|
||||
}
|
||||
$('mitmRecent').innerHTML = recentHtml;
|
||||
}
|
||||
|
||||
// Network usage stats
|
||||
if (d.network) {
|
||||
var net = d.network;
|
||||
$('netRx').textContent = fmtBytes(net.bytes_rx || 0);
|
||||
$('netTx').textContent = fmtBytes(net.bytes_tx || 0);
|
||||
$('netTotal').textContent = fmtBytes(net.bytes_total || 0);
|
||||
$('netRxRate').textContent = fmtBytes(net.rx_rate || 0) + '/s';
|
||||
$('netTxRate').textContent = fmtBytes(net.tx_rate || 0) + '/s';
|
||||
if (net.proxy) {
|
||||
$('netProxy').textContent = fmtBytes(net.proxy.bytes_total || 0);
|
||||
}
|
||||
if (net.scraper) {
|
||||
$('netScraper').textContent = fmtBytes(net.scraper.bytes_total || 0);
|
||||
}
|
||||
// Per-tor-node stats
|
||||
var torContainer = $('netTorNodes');
|
||||
if (torContainer && net.tor_nodes) {
|
||||
var html = '';
|
||||
var nodes = Object.keys(net.tor_nodes).sort();
|
||||
nodes.forEach(function(node) {
|
||||
var s = net.tor_nodes[node];
|
||||
html += '<div class="c c-sm">';
|
||||
html += '<div class="lbl" style="font-size:10px">' + node + '</div>';
|
||||
html += '<div class="val-sm cyn">' + fmtBytes(s.rx + s.tx) + '</div>';
|
||||
html += '<div class="sub" style="font-size:9px">' + fmt(s.requests || 0) + ' req</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
torContainer.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
$('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function fetchStats() {
|
||||
fetch('/api/stats')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(update)
|
||||
.catch(function(e) { $('dot').className = 'dot err'; $('statusTxt').textContent = 'Error'; });
|
||||
}
|
||||
|
||||
fetchStats();
|
||||
setInterval(fetchStats, 3000);
|
||||
60
static/lib/MarkerCluster.Default.css
Normal file
60
static/lib/MarkerCluster.Default.css
Normal file
@@ -0,0 +1,60 @@
|
||||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
/* IE 6-8 fallback colors */
|
||||
.leaflet-oldie .marker-cluster-small {
|
||||
background-color: rgb(181, 226, 140);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-small div {
|
||||
background-color: rgb(110, 204, 57);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-medium {
|
||||
background-color: rgb(241, 211, 87);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-medium div {
|
||||
background-color: rgb(240, 194, 12);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-large {
|
||||
background-color: rgb(253, 156, 115);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-large div {
|
||||
background-color: rgb(241, 128, 23);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
14
static/lib/MarkerCluster.css
Normal file
14
static/lib/MarkerCluster.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.leaflet-cluster-spider-leg {
|
||||
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
|
||||
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
|
||||
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
|
||||
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
|
||||
}
|
||||
20
static/lib/chart.min.js
vendored
Normal file
20
static/lib/chart.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
static/lib/leaflet-heat.js
Normal file
11
static/lib/leaflet-heat.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
|
||||
https://github.com/mourner/simpleheat
|
||||
*/
|
||||
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
|
||||
https://github.com/Leaflet/Leaflet.heat
|
||||
*/
|
||||
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
|
||||
661
static/lib/leaflet.css
Normal file
661
static/lib/leaflet.css
Normal file
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
6
static/lib/leaflet.js
Normal file
6
static/lib/leaflet.js
Normal file
File diff suppressed because one or more lines are too long
2
static/lib/leaflet.markercluster.js
Normal file
2
static/lib/leaflet.markercluster.js
Normal file
File diff suppressed because one or more lines are too long
1
static/lib/uPlot.min.css
vendored
Normal file
1
static/lib/uPlot.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
|
||||
2
static/lib/uPlot.min.js
vendored
Normal file
2
static/lib/uPlot.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
31
static/map.html
Normal file
31
static/map.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PPF Proxy Map</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/lib/leaflet.css">
|
||||
<link rel="stylesheet" href="/static/lib/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="/static/lib/MarkerCluster.Default.css">
|
||||
</head>
|
||||
<body class="map-page">
|
||||
<div class="map-nav glass" style="display:flex;align-items:center;gap:12px"><a href="/dashboard">← Dashboard</a><button class="theme-toggle" id="themeToggle" title="Toggle theme"><span class="theme-toggle-icon">☾</span></button></div>
|
||||
<div class="map-stats-panel glass">
|
||||
<div class="map-stat"><span class="map-stat-val" id="countryCount">-</span><span class="map-stat-lbl">Countries</span></div>
|
||||
<div class="map-stat"><span class="map-stat-val" id="proxyCount">-</span><span class="map-stat-lbl">Proxies</span></div>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div class="map-legend-panel glass">
|
||||
<div class="map-legend-title">Anonymity Level</div>
|
||||
<div class="map-legend-row"><div class="map-legend-dot elite"></div> Elite</div>
|
||||
<div class="map-legend-row"><div class="map-legend-dot anonymous"></div> Anonymous</div>
|
||||
<div class="map-legend-row"><div class="map-legend-dot transparent"></div> Transparent</div>
|
||||
</div>
|
||||
<div class="map-footer">PPF uses the IP2Location LITE database for <a href="https://lite.ip2location.com">IP geolocation</a>.</div>
|
||||
<script src="/static/lib/leaflet.js"></script>
|
||||
<script src="/static/lib/leaflet-heat.js"></script>
|
||||
<script src="/static/lib/leaflet.markercluster.js"></script>
|
||||
<script src="/static/map.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
406
static/map.js
Normal file
406
static/map.js
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* PPF Proxy Map - Interactive visualization
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Theme toggle (shared with dashboard)
|
||||
var themes = ['dark', 'muted-dark', 'light'];
|
||||
function getTheme() {
|
||||
if (document.documentElement.classList.contains('light')) return 'light';
|
||||
if (document.documentElement.classList.contains('muted-dark')) return 'muted-dark';
|
||||
return 'dark';
|
||||
}
|
||||
function setTheme(theme) {
|
||||
document.documentElement.classList.remove('light', 'muted-dark');
|
||||
if (theme === 'light') document.documentElement.classList.add('light');
|
||||
else if (theme === 'muted-dark') document.documentElement.classList.add('muted-dark');
|
||||
try { localStorage.setItem('ppf-theme', theme); } catch(e) {}
|
||||
}
|
||||
function initTheme() {
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('ppf-theme'); } catch(e) {}
|
||||
if (saved && themes.indexOf(saved) !== -1) {
|
||||
setTheme(saved);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
setTheme('light');
|
||||
}
|
||||
var btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var current = getTheme();
|
||||
var idx = themes.indexOf(current);
|
||||
var next = themes[(idx + 1) % themes.length];
|
||||
setTheme(next);
|
||||
});
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initTheme);
|
||||
|
||||
// Country coordinates (ISO 3166-1 alpha-2 -> [lat, lon])
|
||||
var COORDS = {
|
||||
"AD":[42.5,1.5],"AE":[24,54],"AF":[33,65],"AG":[17.05,-61.8],"AL":[41,20],
|
||||
"AM":[40,45],"AO":[-12.5,18.5],"AR":[-34,-64],"AT":[47.33,13.33],"AU":[-27,133],
|
||||
"AZ":[40.5,47.5],"BA":[44,18],"BB":[13.17,-59.53],"BD":[24,90],"BE":[50.83,4],
|
||||
"BF":[13,-2],"BG":[43,25],"BH":[26,50.55],"BI":[-3.5,30],"BJ":[9.5,2.25],
|
||||
"BN":[4.5,114.67],"BO":[-17,-65],"BR":[-10,-55],"BS":[24.25,-76],"BT":[27.5,90.5],
|
||||
"BW":[-22,24],"BY":[53,28],"BZ":[17.25,-88.75],"CA":[60,-95],"CD":[-2.5,23.5],
|
||||
"CF":[7,21],"CG":[-1,15],"CH":[47,8],"CI":[8,-5],"CL":[-30,-71],"CM":[6,12],
|
||||
"CN":[35,105],"CO":[4,-72],"CR":[10,-84],"CU":[21.5,-80],"CV":[16,-24],
|
||||
"CY":[35,33],"CZ":[49.75,15.5],"DE":[51,9],"DJ":[11.5,43],"DK":[56,10],
|
||||
"DO":[19,-70],"DZ":[28,3],"EC":[-2,-77.5],"EE":[59,26],"EG":[27,30],
|
||||
"ER":[15,39],"ES":[40,-4],"ET":[8,38],"FI":[64,26],"FJ":[-18,175],"FR":[46,2],
|
||||
"GA":[-1,11.75],"GB":[54,-2],"GE":[42,43.5],"GH":[8,-2],"GM":[13.47,-16.57],
|
||||
"GN":[11,-10],"GQ":[2,10],"GR":[39,22],"GT":[15.5,-90.25],"GW":[12,-15],
|
||||
"GY":[5,-59],"HK":[22.25,114.17],"HN":[15,-86.5],"HR":[45.17,15.5],
|
||||
"HT":[19,-72.42],"HU":[47,20],"ID":[-5,120],"IE":[53,-8],"IL":[31.5,34.75],
|
||||
"IN":[20,77],"IQ":[33,44],"IR":[32,53],"IS":[65,-18],"IT":[42.83,12.83],
|
||||
"JM":[18.25,-77.5],"JO":[31,36],"JP":[36,138],"KE":[-1,38],"KG":[41,75],
|
||||
"KH":[13,105],"KM":[-12.17,44.25],"KP":[40,127],"KR":[37,127.5],"KW":[29.5,45.75],
|
||||
"KZ":[48,68],"LA":[18,105],"LB":[33.83,35.83],"LK":[7,81],"LR":[6.5,-9.5],
|
||||
"LS":[-29.5,28.5],"LT":[56,24],"LU":[49.75,6.17],"LV":[57,25],"LY":[25,17],
|
||||
"MA":[32,-5],"MC":[43.73,7.42],"MD":[47,29],"ME":[42.5,19.3],"MG":[-20,47],
|
||||
"MK":[41.83,22],"ML":[17,-4],"MM":[22,98],"MN":[46,105],"MO":[22.17,113.55],
|
||||
"MR":[20,-12],"MT":[35.83,14.58],"MU":[-20.28,57.55],"MV":[3.25,73],
|
||||
"MW":[-13.5,34],"MX":[23,-102],"MY":[2.5,112.5],"MZ":[-18.25,35],"NA":[-22,17],
|
||||
"NE":[16,8],"NG":[10,8],"NI":[13,-85],"NL":[52.5,5.75],"NO":[62,10],
|
||||
"NP":[28,84],"NZ":[-41,174],"OM":[21,57],"PA":[9,-80],"PE":[-10,-76],
|
||||
"PG":[-6,147],"PH":[13,122],"PK":[30,70],"PL":[52,20],"PR":[18.25,-66.5],
|
||||
"PS":[32,35.25],"PT":[39.5,-8],"PY":[-23,-58],"QA":[25.5,51.25],"RO":[46,25],
|
||||
"RS":[44,21],"RU":[60,100],"RW":[-2,30],"SA":[25,45],"SC":[-4.58,55.67],
|
||||
"SD":[15,30],"SE":[62,15],"SG":[1.37,103.8],"SI":[46.12,14.82],"SK":[48.67,19.5],
|
||||
"SL":[8.5,-11.5],"SN":[14,-14],"SO":[10,49],"SR":[4,-56],"SS":[7,30],
|
||||
"SV":[13.83,-88.92],"SY":[35,38],"SZ":[-26.5,31.5],"TD":[15,19],"TG":[8,1.17],
|
||||
"TH":[15,100],"TJ":[39,71],"TM":[40,60],"TN":[34,9],"TO":[-20,-175],
|
||||
"TR":[39,35],"TT":[11,-61],"TW":[23.5,121],"TZ":[-6,35],"UA":[49,32],
|
||||
"UG":[1,32],"US":[38,-97],"UY":[-33,-56],"UZ":[41,64],"VE":[8,-66],
|
||||
"VN":[16,106],"YE":[15,48],"ZA":[-29,24],"ZM":[-15,30],"ZW":[-20,30],"XK":[42.6,20.9]
|
||||
};
|
||||
|
||||
// Country names
|
||||
var NAMES = {
|
||||
"AD":"Andorra","AE":"UAE","AF":"Afghanistan","AG":"Antigua","AL":"Albania",
|
||||
"AM":"Armenia","AO":"Angola","AR":"Argentina","AT":"Austria","AU":"Australia",
|
||||
"AZ":"Azerbaijan","BA":"Bosnia","BB":"Barbados","BD":"Bangladesh","BE":"Belgium",
|
||||
"BF":"Burkina Faso","BG":"Bulgaria","BH":"Bahrain","BI":"Burundi","BJ":"Benin",
|
||||
"BN":"Brunei","BO":"Bolivia","BR":"Brazil","BS":"Bahamas","BT":"Bhutan",
|
||||
"BW":"Botswana","BY":"Belarus","BZ":"Belize","CA":"Canada","CD":"DR Congo",
|
||||
"CF":"C. African Rep.","CG":"Congo","CH":"Switzerland","CI":"Ivory Coast",
|
||||
"CL":"Chile","CM":"Cameroon","CN":"China","CO":"Colombia","CR":"Costa Rica",
|
||||
"CU":"Cuba","CV":"Cape Verde","CY":"Cyprus","CZ":"Czechia","DE":"Germany",
|
||||
"DJ":"Djibouti","DK":"Denmark","DO":"Dominican Rep.","DZ":"Algeria","EC":"Ecuador",
|
||||
"EE":"Estonia","EG":"Egypt","ER":"Eritrea","ES":"Spain","ET":"Ethiopia",
|
||||
"FI":"Finland","FJ":"Fiji","FR":"France","GA":"Gabon","GB":"United Kingdom",
|
||||
"GE":"Georgia","GH":"Ghana","GM":"Gambia","GN":"Guinea","GQ":"Eq. Guinea",
|
||||
"GR":"Greece","GT":"Guatemala","GW":"Guinea-Bissau","GY":"Guyana","HK":"Hong Kong",
|
||||
"HN":"Honduras","HR":"Croatia","HT":"Haiti","HU":"Hungary","ID":"Indonesia",
|
||||
"IE":"Ireland","IL":"Israel","IN":"India","IQ":"Iraq","IR":"Iran","IS":"Iceland",
|
||||
"IT":"Italy","JM":"Jamaica","JO":"Jordan","JP":"Japan","KE":"Kenya",
|
||||
"KG":"Kyrgyzstan","KH":"Cambodia","KM":"Comoros","KP":"North Korea",
|
||||
"KR":"South Korea","KW":"Kuwait","KZ":"Kazakhstan","LA":"Laos","LB":"Lebanon",
|
||||
"LK":"Sri Lanka","LR":"Liberia","LS":"Lesotho","LT":"Lithuania","LU":"Luxembourg",
|
||||
"LV":"Latvia","LY":"Libya","MA":"Morocco","MC":"Monaco","MD":"Moldova",
|
||||
"ME":"Montenegro","MG":"Madagascar","MK":"N. Macedonia","ML":"Mali","MM":"Myanmar",
|
||||
"MN":"Mongolia","MO":"Macau","MR":"Mauritania","MT":"Malta","MU":"Mauritius",
|
||||
"MV":"Maldives","MW":"Malawi","MX":"Mexico","MY":"Malaysia","MZ":"Mozambique",
|
||||
"NA":"Namibia","NE":"Niger","NG":"Nigeria","NI":"Nicaragua","NL":"Netherlands",
|
||||
"NO":"Norway","NP":"Nepal","NZ":"New Zealand","OM":"Oman","PA":"Panama",
|
||||
"PE":"Peru","PG":"Papua New Guinea","PH":"Philippines","PK":"Pakistan",
|
||||
"PL":"Poland","PR":"Puerto Rico","PS":"Palestine","PT":"Portugal","PY":"Paraguay",
|
||||
"QA":"Qatar","RO":"Romania","RS":"Serbia","RU":"Russia","RW":"Rwanda",
|
||||
"SA":"Saudi Arabia","SC":"Seychelles","SD":"Sudan","SE":"Sweden","SG":"Singapore",
|
||||
"SI":"Slovenia","SK":"Slovakia","SL":"Sierra Leone","SN":"Senegal","SO":"Somalia",
|
||||
"SR":"Suriname","SS":"South Sudan","SV":"El Salvador","SY":"Syria","SZ":"Eswatini",
|
||||
"TD":"Chad","TG":"Togo","TH":"Thailand","TJ":"Tajikistan","TM":"Turkmenistan",
|
||||
"TN":"Tunisia","TO":"Tonga","TR":"Turkey","TT":"Trinidad","TW":"Taiwan",
|
||||
"TZ":"Tanzania","UA":"Ukraine","UG":"Uganda","US":"United States","UY":"Uruguay",
|
||||
"UZ":"Uzbekistan","VE":"Venezuela","VN":"Vietnam","YE":"Yemen","ZA":"South Africa",
|
||||
"ZM":"Zambia","ZW":"Zimbabwe","XK":"Kosovo"
|
||||
};
|
||||
|
||||
// Anonymity color mapping (matches CSS variables)
|
||||
var ANON_COLORS = {
|
||||
elite: {fill: '#50c878', stroke: '#2d8a4e'},
|
||||
anonymous: {fill: '#38bdf8', stroke: '#1d8acf'},
|
||||
transparent: {fill: '#f97316', stroke: '#c2410c'},
|
||||
unknown: {fill: '#6b7280', stroke: '#4b5563'}
|
||||
};
|
||||
|
||||
// Heatmap gradient
|
||||
var HEAT_GRADIENT = {
|
||||
0.0: '#181f2a',
|
||||
0.3: '#1d4e89',
|
||||
0.5: '#38bdf8',
|
||||
0.7: '#50c878',
|
||||
1.0: '#7ee787'
|
||||
};
|
||||
|
||||
// Map configuration
|
||||
var MAP_CONFIG = {
|
||||
center: [30, 10],
|
||||
zoom: 2,
|
||||
minZoom: 2,
|
||||
maxZoom: 8,
|
||||
tileUrl: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
tileAttribution: '© OSM © CARTO'
|
||||
};
|
||||
|
||||
// Cluster configuration
|
||||
var CLUSTER_CONFIG = {
|
||||
showCoverageOnHover: false,
|
||||
spiderfyOnMaxZoom: true,
|
||||
disableClusteringAtZoom: 7,
|
||||
maxClusterRadius: 50
|
||||
};
|
||||
|
||||
// DOM element references
|
||||
var $countryCount, $proxyCount, map, clusterGroup;
|
||||
|
||||
/**
|
||||
* Initialize the map
|
||||
*/
|
||||
function init() {
|
||||
$countryCount = document.getElementById('countryCount');
|
||||
$proxyCount = document.getElementById('proxyCount');
|
||||
|
||||
// Set loading state
|
||||
$countryCount.classList.add('loading');
|
||||
$proxyCount.classList.add('loading');
|
||||
|
||||
// Create map
|
||||
map = L.map('map', {
|
||||
center: MAP_CONFIG.center,
|
||||
zoom: MAP_CONFIG.zoom,
|
||||
minZoom: MAP_CONFIG.minZoom,
|
||||
maxZoom: MAP_CONFIG.maxZoom,
|
||||
zoomControl: true,
|
||||
worldCopyJump: true
|
||||
});
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer(MAP_CONFIG.tileUrl, {
|
||||
attribution: MAP_CONFIG.tileAttribution,
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
// Create cluster group
|
||||
clusterGroup = L.markerClusterGroup({
|
||||
showCoverageOnHover: CLUSTER_CONFIG.showCoverageOnHover,
|
||||
spiderfyOnMaxZoom: CLUSTER_CONFIG.spiderfyOnMaxZoom,
|
||||
disableClusteringAtZoom: CLUSTER_CONFIG.disableClusteringAtZoom,
|
||||
maxClusterRadius: CLUSTER_CONFIG.maxClusterRadius,
|
||||
iconCreateFunction: createClusterIcon
|
||||
});
|
||||
|
||||
// Load data
|
||||
loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cluster icon
|
||||
*/
|
||||
function createClusterIcon(cluster) {
|
||||
var count = cluster.getChildCount();
|
||||
var size = count >= 100 ? 'lg' : count >= 10 ? 'md' : 'sm';
|
||||
var sizeMap = {sm: 32, md: 40, lg: 50};
|
||||
return L.divIcon({
|
||||
html: '<div class="cluster-icon cluster-' + size + '">' + count + '</div>',
|
||||
className: 'cluster-wrapper',
|
||||
iconSize: L.point(sizeMap[size], sizeMap[size])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate brightness based on count (logarithmic scale)
|
||||
*/
|
||||
function calcBrightness(count, maxCount, minBrightness, maxBrightness) {
|
||||
minBrightness = minBrightness || 0.3;
|
||||
maxBrightness = maxBrightness || 1.0;
|
||||
var range = maxBrightness - minBrightness;
|
||||
return minBrightness + range * (Math.log(count + 1) / Math.log(maxCount + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate radius based on count
|
||||
*/
|
||||
function calcRadius(count, baseRadius, maxExtra, divisor) {
|
||||
baseRadius = baseRadius || 3;
|
||||
maxExtra = maxExtra || 7;
|
||||
divisor = divisor || 5;
|
||||
return baseRadius + Math.min(maxExtra, Math.sqrt(count / divisor));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup content
|
||||
*/
|
||||
function createPopup(code, count, anon, lat, lon, isApprox) {
|
||||
var name = NAMES[code] || code;
|
||||
var anonLabel = anon ? anon.charAt(0).toUpperCase() + anon.slice(1) : '';
|
||||
var coords = isApprox ? 'approx. location' :
|
||||
(anonLabel ? anonLabel + ' • ' : '') + lat.toFixed(1) + ', ' + lon.toFixed(1);
|
||||
|
||||
return '<div class="popup-header">' +
|
||||
'<span class="popup-code">' + code + '</span>' +
|
||||
'<span class="popup-name">' + name + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="popup-count">' + count.toLocaleString() + ' proxies</div>' +
|
||||
'<div class="popup-coords">' + coords + '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and render map data
|
||||
*/
|
||||
function loadData() {
|
||||
Promise.all([
|
||||
fetch('/api/locations').then(function(r) { return r.json(); }).catch(function() { return {locations: []}; }),
|
||||
fetch('/api/countries').then(function(r) { return r.json(); })
|
||||
]).then(function(results) {
|
||||
var locations = results[0].locations || [];
|
||||
var countries = results[1].countries || {};
|
||||
|
||||
renderData(locations, countries);
|
||||
}).catch(function() {
|
||||
$proxyCount.textContent = 'Error';
|
||||
$proxyCount.style.color = '#ef4444';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render map data
|
||||
*/
|
||||
function renderData(locations, countries) {
|
||||
var entries = Object.entries(countries).sort(function(a, b) { return b[1] - a[1]; });
|
||||
var total = entries.reduce(function(s, e) { return s + e[1]; }, 0);
|
||||
|
||||
// Update stats
|
||||
$countryCount.textContent = entries.length;
|
||||
$proxyCount.textContent = total.toLocaleString();
|
||||
$countryCount.classList.remove('loading');
|
||||
$proxyCount.classList.remove('loading');
|
||||
|
||||
// Track countries with precise locations
|
||||
var countriesWithPrecise = {};
|
||||
locations.forEach(function(l) {
|
||||
countriesWithPrecise[l.country] = (countriesWithPrecise[l.country] || 0) + l.count;
|
||||
});
|
||||
|
||||
// Add heatmap layer
|
||||
renderHeatmap(locations);
|
||||
|
||||
// Add precise location markers
|
||||
renderPreciseLocations(locations);
|
||||
|
||||
// Add country centroid markers
|
||||
renderCountryCentroids(entries, countriesWithPrecise);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render heatmap layer
|
||||
*/
|
||||
function renderHeatmap(locations) {
|
||||
if (locations.length === 0) return;
|
||||
|
||||
var heatData = locations.map(function(l) {
|
||||
var intensity = Math.min(l.count / 50, 1.0);
|
||||
return [l.lat, l.lon, intensity];
|
||||
});
|
||||
|
||||
L.heatLayer(heatData, {
|
||||
radius: 25,
|
||||
blur: 20,
|
||||
maxZoom: 6,
|
||||
max: 1.0,
|
||||
minOpacity: 0.3,
|
||||
gradient: HEAT_GRADIENT
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render precise location markers
|
||||
*/
|
||||
function renderPreciseLocations(locations) {
|
||||
if (locations.length === 0) return;
|
||||
|
||||
var maxCount = Math.max.apply(null, locations.map(function(l) { return l.count; })) || 1;
|
||||
|
||||
locations.forEach(function(l) {
|
||||
var brightness = calcBrightness(l.count, maxCount);
|
||||
var radius = calcRadius(l.count);
|
||||
var colors = ANON_COLORS[l.anon] || ANON_COLORS.unknown;
|
||||
|
||||
var marker = L.circleMarker([l.lat, l.lon], {
|
||||
radius: radius,
|
||||
fillColor: colors.fill,
|
||||
color: colors.stroke,
|
||||
weight: 1,
|
||||
opacity: brightness,
|
||||
fillOpacity: brightness * 0.85
|
||||
});
|
||||
|
||||
marker.bindPopup(createPopup(l.country, l.count, l.anon, l.lat, l.lon, false));
|
||||
|
||||
// Hover effects
|
||||
marker.on('mouseover', function() {
|
||||
this.setStyle({fillOpacity: 0.95, opacity: 1, weight: 2});
|
||||
});
|
||||
marker.on('mouseout', function() {
|
||||
this.setStyle({fillOpacity: brightness * 0.85, opacity: brightness, weight: 1});
|
||||
});
|
||||
|
||||
clusterGroup.addLayer(marker);
|
||||
});
|
||||
|
||||
map.addLayer(clusterGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render country centroid markers (for proxies without precise coords)
|
||||
*/
|
||||
function renderCountryCentroids(entries, countriesWithPrecise) {
|
||||
// Find max remaining for normalization
|
||||
var maxRemaining = 1;
|
||||
entries.forEach(function(e) {
|
||||
var remaining = e[1] - (countriesWithPrecise[e[0]] || 0);
|
||||
if (remaining > maxRemaining) maxRemaining = remaining;
|
||||
});
|
||||
|
||||
entries.forEach(function(e) {
|
||||
var code = e[0], count = e[1], coords = COORDS[code];
|
||||
if (!coords) return;
|
||||
|
||||
var preciseInCountry = countriesWithPrecise[code] || 0;
|
||||
var remaining = count - preciseInCountry;
|
||||
if (remaining <= 0) return;
|
||||
|
||||
var brightness = calcBrightness(remaining, maxRemaining, 0.25, 0.9);
|
||||
var radius = calcRadius(remaining, 4, 10, 10);
|
||||
|
||||
var circle = L.circleMarker([coords[0], coords[1]], {
|
||||
radius: radius,
|
||||
fillColor: '#38bdf8',
|
||||
color: '#1d8acf',
|
||||
weight: 1,
|
||||
opacity: brightness,
|
||||
fillOpacity: brightness * 0.7
|
||||
}).addTo(map);
|
||||
|
||||
circle.bindPopup(createPopup(code, remaining, null, coords[0], coords[1], true));
|
||||
|
||||
// Hover effects
|
||||
circle.on('mouseover', function() {
|
||||
this.setStyle({fillOpacity: 0.9, opacity: 1});
|
||||
});
|
||||
circle.on('mouseout', function() {
|
||||
this.setStyle({fillOpacity: brightness * 0.7, opacity: brightness});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
117
static/mitm.html
Normal file
117
static/mitm.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MITM Certificate Search</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container search-container">
|
||||
<!-- Header -->
|
||||
<div class="search-header">
|
||||
<h1>MITM Certificate Search</h1>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<a href="/dashboard" class="back-link">← Dashboard</a>
|
||||
<button class="theme-toggle" id="themeToggle" title="Toggle theme">
|
||||
<span class="theme-toggle-icon">☾</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="stats-summary" id="statsSummary">
|
||||
<div class="stat-card">
|
||||
<div class="lbl">Total Detections</div>
|
||||
<div class="val red" id="totalDetections">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">Unique Certificates</div>
|
||||
<div class="val yel" id="uniqueCerts">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">Unique Organizations</div>
|
||||
<div class="val cyn" id="uniqueOrgs">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">Affected Proxies</div>
|
||||
<div class="val blu" id="uniqueProxies">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" class="search-input" id="searchInput"
|
||||
placeholder="Search certificates, organizations, proxies..."
|
||||
autocomplete="off" spellcheck="false">
|
||||
<button class="search-clear" id="searchClear" title="Clear search">×</button>
|
||||
<div class="suggestions" id="suggestions"></div>
|
||||
</div>
|
||||
|
||||
<!-- Help Box -->
|
||||
<div class="help-box" id="helpBox">
|
||||
<div class="help-header" onclick="toggleHelp()">
|
||||
<div class="help-title">Search Syntax Help</div>
|
||||
<span class="help-toggle">▼</span>
|
||||
</div>
|
||||
<div class="help-content">
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Field Filters</div>
|
||||
<div class="help-examples">
|
||||
<div class="help-example"><code>org:Cloudflare</code> <span>organization name</span></div>
|
||||
<div class="help-example"><code>issuer:DigiCert</code> <span>certificate issuer</span></div>
|
||||
<div class="help-example"><code>cn:*.example.com</code> <span>common name</span></div>
|
||||
<div class="help-example"><code>proxy:192.168.1</code> <span>proxy IP address</span></div>
|
||||
<div class="help-example"><code>fp:a1b2c3</code> <span>fingerprint prefix</span></div>
|
||||
<div class="help-example"><code>serial:12345</code> <span>serial number</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Date Filters</div>
|
||||
<div class="help-examples">
|
||||
<div class="help-example"><code>expires:2024</code> <span>expiration year</span></div>
|
||||
<div class="help-example"><code>expired:yes</code> <span>show expired certs</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">General Search</div>
|
||||
<div class="help-examples">
|
||||
<div class="help-example"><code>cloudflare</code> <span>search all fields</span></div>
|
||||
<div class="help-example"><code>"security proxy"</code> <span>exact phrase</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Area -->
|
||||
<div class="results-container" id="resultsContainer">
|
||||
<div class="results-empty" id="resultsEmpty">
|
||||
<div class="results-empty-icon">🔒</div>
|
||||
<div class="results-empty-text">Start typing to search MITM certificates</div>
|
||||
<div class="results-empty-hint">Use field filters like <code>org:</code> or <code>proxy:</code> for precise results</div>
|
||||
</div>
|
||||
<div class="results-loading" id="resultsLoading" style="display:none">Searching</div>
|
||||
<div id="resultsList" style="display:none">
|
||||
<div class="results-header">
|
||||
<div class="results-count"><strong id="resultsCount">0</strong> results found</div>
|
||||
</div>
|
||||
<div id="resultsContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Data State (shown when no MITM data exists) -->
|
||||
<div class="no-data" id="noDataState" style="display:none">
|
||||
<div class="no-data-icon">🛡</div>
|
||||
<div class="no-data-title">No MITM Certificates Detected</div>
|
||||
<div class="no-data-text">
|
||||
MITM certificates are captured when proxies attempt SSL interception.
|
||||
As the system validates proxies with SSL enabled, any detected MITM
|
||||
certificates will appear here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/mitm.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
703
static/mitm.js
Normal file
703
static/mitm.js
Normal file
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* MITM Certificate Search Interface
|
||||
* Provides search functionality for SSL MITM certificate data
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Theme toggle (shared with dashboard)
|
||||
var themes = ['dark', 'muted-dark', 'light'];
|
||||
function getTheme() {
|
||||
if (document.documentElement.classList.contains('light')) return 'light';
|
||||
if (document.documentElement.classList.contains('muted-dark')) return 'muted-dark';
|
||||
return 'dark';
|
||||
}
|
||||
function setTheme(theme) {
|
||||
document.documentElement.classList.remove('light', 'muted-dark');
|
||||
if (theme === 'light') document.documentElement.classList.add('light');
|
||||
else if (theme === 'muted-dark') document.documentElement.classList.add('muted-dark');
|
||||
try { localStorage.setItem('ppf-theme', theme); } catch(e) {}
|
||||
}
|
||||
function initTheme() {
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('ppf-theme'); } catch(e) {}
|
||||
if (saved && themes.indexOf(saved) !== -1) {
|
||||
setTheme(saved);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
setTheme('light');
|
||||
}
|
||||
var btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var current = getTheme();
|
||||
var idx = themes.indexOf(current);
|
||||
var next = themes[(idx + 1) % themes.length];
|
||||
setTheme(next);
|
||||
});
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initTheme);
|
||||
|
||||
// DOM elements
|
||||
var searchInput = document.getElementById('searchInput');
|
||||
var searchClear = document.getElementById('searchClear');
|
||||
var suggestions = document.getElementById('suggestions');
|
||||
var resultsContainer = document.getElementById('resultsContainer');
|
||||
var resultsEmpty = document.getElementById('resultsEmpty');
|
||||
var resultsLoading = document.getElementById('resultsLoading');
|
||||
var resultsList = document.getElementById('resultsList');
|
||||
var resultsContent = document.getElementById('resultsContent');
|
||||
var resultsCount = document.getElementById('resultsCount');
|
||||
var noDataState = document.getElementById('noDataState');
|
||||
|
||||
// Stats elements
|
||||
var totalDetections = document.getElementById('totalDetections');
|
||||
var uniqueCerts = document.getElementById('uniqueCerts');
|
||||
var uniqueOrgs = document.getElementById('uniqueOrgs');
|
||||
var uniqueProxies = document.getElementById('uniqueProxies');
|
||||
|
||||
// State
|
||||
var searchTimeout = null;
|
||||
var currentData = null;
|
||||
var suggestionIndex = -1;
|
||||
|
||||
// Search field definitions for autocomplete
|
||||
var searchFields = [
|
||||
{ prefix: 'org:', desc: 'organization name', icon: '🏢' },
|
||||
{ prefix: 'issuer:', desc: 'certificate issuer', icon: '📄' },
|
||||
{ prefix: 'cn:', desc: 'common name', icon: '🔗' },
|
||||
{ prefix: 'proxy:', desc: 'proxy IP address', icon: '🖥' },
|
||||
{ prefix: 'fp:', desc: 'fingerprint', icon: '🔑' },
|
||||
{ prefix: 'serial:', desc: 'serial number', icon: '🔢' },
|
||||
{ prefix: 'expires:', desc: 'expiration year', icon: '📅' },
|
||||
{ prefix: 'expired:', desc: 'yes/no', icon: '⚠' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize search query - remove potentially dangerous characters
|
||||
*/
|
||||
function sanitizeQuery(query) {
|
||||
if (!query) return '';
|
||||
// Allow alphanumeric, spaces, common punctuation for searches
|
||||
// Remove control characters and dangerous patterns
|
||||
return String(query)
|
||||
.replace(/[\x00-\x1f\x7f]/g, '') // Control characters
|
||||
.replace(/<[^>]*>/g, '') // HTML tags
|
||||
.trim()
|
||||
.substring(0, 200); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with comma separators
|
||||
*/
|
||||
function formatNumber(n) {
|
||||
if (n === null || n === undefined || n === '-') return '-';
|
||||
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable date
|
||||
*/
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '-';
|
||||
// Handle ASN.1 time format (YYYYMMDDhhmmssZ)
|
||||
if (typeof ts === 'string' && ts.length >= 14) {
|
||||
var y = ts.substring(0, 4);
|
||||
var m = ts.substring(4, 6);
|
||||
var d = ts.substring(6, 8);
|
||||
return y + '-' + m + '-' + d;
|
||||
}
|
||||
// Handle Unix timestamp
|
||||
if (typeof ts === 'number') {
|
||||
var date = new Date(ts * 1000);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
return String(ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is expired
|
||||
*/
|
||||
function isExpired(notAfter) {
|
||||
if (!notAfter || notAfter.length < 14) return false;
|
||||
var expDate = new Date(
|
||||
parseInt(notAfter.substring(0, 4)),
|
||||
parseInt(notAfter.substring(4, 6)) - 1,
|
||||
parseInt(notAfter.substring(6, 8))
|
||||
);
|
||||
return expDate < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial stats
|
||||
*/
|
||||
function loadStats() {
|
||||
fetch('/api/mitm')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
currentData = data;
|
||||
|
||||
// Update summary stats
|
||||
totalDetections.textContent = formatNumber(data.total_detections || 0);
|
||||
uniqueCerts.textContent = formatNumber(data.unique_certs || 0);
|
||||
uniqueProxies.textContent = formatNumber(data.unique_proxies || 0);
|
||||
|
||||
// Count unique orgs from top_organizations
|
||||
var orgCount = (data.top_organizations || []).length;
|
||||
uniqueOrgs.textContent = formatNumber(orgCount);
|
||||
|
||||
// Show no data state if empty
|
||||
if (!data.certificates || data.certificates.length === 0) {
|
||||
document.getElementById('statsSummary').style.display = 'none';
|
||||
document.querySelector('.search-box').style.display = 'none';
|
||||
document.getElementById('helpBox').style.display = 'none';
|
||||
resultsContainer.style.display = 'none';
|
||||
noDataState.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Failed to load MITM stats:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search query into structured filters
|
||||
*/
|
||||
function parseQuery(query) {
|
||||
var filters = {
|
||||
org: null,
|
||||
issuer: null,
|
||||
cn: null,
|
||||
proxy: null,
|
||||
fp: null,
|
||||
serial: null,
|
||||
expires: null,
|
||||
expired: null,
|
||||
text: []
|
||||
};
|
||||
|
||||
if (!query) return filters;
|
||||
|
||||
// Match field:value patterns and quoted strings
|
||||
var parts = query.match(/(\w+:"[^"]+"|"[^"]+"|[\w:.]+)/g) || [];
|
||||
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var part = parts[i];
|
||||
var colonIdx = part.indexOf(':');
|
||||
|
||||
if (colonIdx > 0 && colonIdx < part.length - 1) {
|
||||
var field = part.substring(0, colonIdx).toLowerCase();
|
||||
var value = part.substring(colonIdx + 1).replace(/^"|"$/g, '');
|
||||
|
||||
if (filters.hasOwnProperty(field)) {
|
||||
filters[field] = value;
|
||||
} else {
|
||||
filters.text.push(part);
|
||||
}
|
||||
} else {
|
||||
// Plain text search
|
||||
filters.text.push(part.replace(/^"|"$/g, ''));
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate matches filters
|
||||
*/
|
||||
function matchesCert(cert, filters) {
|
||||
// Organization filter
|
||||
if (filters.org) {
|
||||
var org = (cert.subject_o || '').toLowerCase();
|
||||
if (org.indexOf(filters.org.toLowerCase()) === -1) return false;
|
||||
}
|
||||
|
||||
// Issuer filter
|
||||
if (filters.issuer) {
|
||||
var issuer = (cert.issuer_cn || cert.issuer_o || '').toLowerCase();
|
||||
if (issuer.indexOf(filters.issuer.toLowerCase()) === -1) return false;
|
||||
}
|
||||
|
||||
// Common name filter
|
||||
if (filters.cn) {
|
||||
var cn = (cert.subject_cn || '').toLowerCase();
|
||||
if (cn.indexOf(filters.cn.toLowerCase()) === -1) return false;
|
||||
}
|
||||
|
||||
// Proxy filter
|
||||
if (filters.proxy) {
|
||||
var proxies = cert.proxies || [];
|
||||
var found = false;
|
||||
for (var i = 0; i < proxies.length; i++) {
|
||||
if (proxies[i].indexOf(filters.proxy) !== -1) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
|
||||
// Fingerprint filter
|
||||
if (filters.fp) {
|
||||
var fp = (cert.fingerprint || cert.fingerprint_full || '').toLowerCase();
|
||||
if (fp.indexOf(filters.fp.toLowerCase()) === -1) return false;
|
||||
}
|
||||
|
||||
// Serial filter
|
||||
if (filters.serial) {
|
||||
var serial = (cert.serial || '').toLowerCase();
|
||||
if (serial.indexOf(filters.serial.toLowerCase()) === -1) return false;
|
||||
}
|
||||
|
||||
// Expiration year filter
|
||||
if (filters.expires) {
|
||||
var notAfter = cert.not_after || '';
|
||||
if (notAfter.indexOf(filters.expires) === -1) return false;
|
||||
}
|
||||
|
||||
// Expired filter
|
||||
if (filters.expired) {
|
||||
var wantExpired = filters.expired.toLowerCase() === 'yes';
|
||||
var certExpired = isExpired(cert.not_after);
|
||||
if (wantExpired !== certExpired) return false;
|
||||
}
|
||||
|
||||
// Text search (searches all fields)
|
||||
if (filters.text.length > 0) {
|
||||
var searchable = [
|
||||
cert.subject_cn || '',
|
||||
cert.subject_o || '',
|
||||
cert.issuer_cn || '',
|
||||
cert.issuer_o || '',
|
||||
cert.fingerprint || '',
|
||||
cert.serial || ''
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
for (var j = 0; j < filters.text.length; j++) {
|
||||
if (searchable.indexOf(filters.text[j].toLowerCase()) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform search on current data
|
||||
*/
|
||||
function performSearch(query) {
|
||||
query = sanitizeQuery(query);
|
||||
|
||||
if (!query) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentData || !currentData.certificates) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading();
|
||||
|
||||
// Use setTimeout to not block UI
|
||||
setTimeout(function() {
|
||||
var filters = parseQuery(query);
|
||||
var results = [];
|
||||
|
||||
var certs = currentData.certificates || [];
|
||||
for (var i = 0; i < certs.length; i++) {
|
||||
if (matchesCert(certs[i], filters)) {
|
||||
results.push(certs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
showResults(results, query);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show empty state
|
||||
*/
|
||||
function showEmpty() {
|
||||
resultsEmpty.style.display = 'block';
|
||||
resultsLoading.style.display = 'none';
|
||||
resultsList.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading state
|
||||
*/
|
||||
function showLoading() {
|
||||
resultsEmpty.style.display = 'none';
|
||||
resultsLoading.style.display = 'block';
|
||||
resultsList.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render search results
|
||||
*/
|
||||
function showResults(results, query) {
|
||||
resultsEmpty.style.display = 'none';
|
||||
resultsLoading.style.display = 'none';
|
||||
resultsList.style.display = 'block';
|
||||
|
||||
resultsCount.textContent = formatNumber(results.length);
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsContent.innerHTML =
|
||||
'<div class="results-empty">' +
|
||||
'<div class="results-empty-icon">🔍</div>' +
|
||||
'<div class="results-empty-text">No certificates match your search</div>' +
|
||||
'<div class="results-empty-hint">Try adjusting your filters or search terms</div>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
html += renderCertCard(results[i]);
|
||||
}
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single certificate card
|
||||
*/
|
||||
function renderCertCard(cert) {
|
||||
var expired = isExpired(cert.not_after);
|
||||
var proxies = cert.proxies || [];
|
||||
|
||||
var html = '<div class="result-card">';
|
||||
html += '<div class="result-header">';
|
||||
html += '<div class="result-title">';
|
||||
html += '<span class="result-badge cert">Certificate</span>';
|
||||
html += escapeHtml(cert.subject_cn || cert.subject_o || 'Unknown');
|
||||
html += '</div>';
|
||||
html += '<div class="result-meta">';
|
||||
if (expired) {
|
||||
html += '<span class="red">Expired</span> · ';
|
||||
}
|
||||
html += 'Seen ' + escapeHtml(String(cert.count || 1)) + ' time(s)';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="result-body">';
|
||||
|
||||
// Subject
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Subject (CN)</div>';
|
||||
html += '<div class="result-field-value">' + escapeHtml(cert.subject_cn || '-') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Organization
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Organization</div>';
|
||||
html += '<div class="result-field-value">' + escapeHtml(cert.subject_o || '-') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Issuer
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Issuer (CN)</div>';
|
||||
html += '<div class="result-field-value">' + escapeHtml(cert.issuer_cn || '-') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Issuer Org
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Issuer Org</div>';
|
||||
html += '<div class="result-field-value">' + escapeHtml(cert.issuer_o || '-') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Fingerprint
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Fingerprint</div>';
|
||||
html += '<div class="result-field-value highlight">' + escapeHtml(cert.fingerprint || cert.fingerprint_full || '-') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Serial
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Serial</div>';
|
||||
html += '<div class="result-field-value">' + escapeHtml(cert.serial || '-') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Valid From
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Valid From</div>';
|
||||
html += '<div class="result-field-value">' + escapeHtml(formatDate(cert.not_before)) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Valid Until
|
||||
html += '<div class="result-field">';
|
||||
html += '<div class="result-field-label">Valid Until</div>';
|
||||
html += '<div class="result-field-value' + (expired ? ' red' : '') + '">' + escapeHtml(formatDate(cert.not_after)) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Proxies list
|
||||
if (proxies.length > 0) {
|
||||
html += '<div class="result-proxies">';
|
||||
html += '<div class="result-proxies-title">Proxies using this certificate (' + proxies.length + ')</div>';
|
||||
html += '<div class="proxy-tags">';
|
||||
for (var i = 0; i < Math.min(proxies.length, 10); i++) {
|
||||
html += '<span class="proxy-tag">' + escapeHtml(proxies[i]) + '</span>';
|
||||
}
|
||||
if (proxies.length > 10) {
|
||||
html += '<span class="proxy-tag">+' + (proxies.length - 10) + ' more</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show autocomplete suggestions
|
||||
*/
|
||||
function showSuggestions(query) {
|
||||
query = sanitizeQuery(query);
|
||||
|
||||
if (!query) {
|
||||
hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// Check if user is typing a field prefix
|
||||
var lastWord = query.split(/\s+/).pop() || '';
|
||||
var colonIdx = lastWord.indexOf(':');
|
||||
|
||||
if (colonIdx === -1) {
|
||||
// Show matching field suggestions
|
||||
var matchingFields = [];
|
||||
for (var i = 0; i < searchFields.length; i++) {
|
||||
if (searchFields[i].prefix.toLowerCase().indexOf(lastWord.toLowerCase()) === 0) {
|
||||
matchingFields.push(searchFields[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingFields.length > 0) {
|
||||
html += '<div class="suggestion-group">';
|
||||
html += '<div class="suggestion-header">Field Filters</div>';
|
||||
for (var j = 0; j < matchingFields.length; j++) {
|
||||
var field = matchingFields[j];
|
||||
html += '<div class="suggestion-item" data-value="' + escapeHtml(field.prefix) + '">';
|
||||
html += '<span class="suggestion-icon">' + field.icon + '</span>';
|
||||
html += '<span class="suggestion-text"><code>' + escapeHtml(field.prefix) + '</code></span>';
|
||||
html += '<span class="suggestion-desc">' + escapeHtml(field.desc) + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Add quick value suggestions from data
|
||||
if (currentData && colonIdx > 0) {
|
||||
var fieldName = lastWord.substring(0, colonIdx);
|
||||
var valuePart = lastWord.substring(colonIdx + 1).toLowerCase();
|
||||
|
||||
var valueSuggestions = getValueSuggestions(fieldName, valuePart);
|
||||
if (valueSuggestions.length > 0) {
|
||||
html += '<div class="suggestion-group">';
|
||||
html += '<div class="suggestion-header">Matching Values</div>';
|
||||
for (var k = 0; k < Math.min(valueSuggestions.length, 5); k++) {
|
||||
var val = valueSuggestions[k];
|
||||
html += '<div class="suggestion-item" data-value="' + escapeHtml(fieldName + ':' + val) + '">';
|
||||
html += '<span class="suggestion-icon">→</span>';
|
||||
html += '<span class="suggestion-text">' + escapeHtml(val) + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (html) {
|
||||
suggestions.innerHTML = html;
|
||||
suggestions.classList.add('visible');
|
||||
suggestionIndex = -1;
|
||||
} else {
|
||||
hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value suggestions for a field
|
||||
*/
|
||||
function getValueSuggestions(field, partial) {
|
||||
if (!currentData) return [];
|
||||
|
||||
var values = [];
|
||||
field = field.toLowerCase();
|
||||
|
||||
if (field === 'org') {
|
||||
var orgs = currentData.top_organizations || [];
|
||||
for (var i = 0; i < orgs.length; i++) {
|
||||
var orgName = orgs[i].name || '';
|
||||
if (partial === '' || orgName.toLowerCase().indexOf(partial) !== -1) {
|
||||
values.push(orgName);
|
||||
}
|
||||
}
|
||||
} else if (field === 'issuer') {
|
||||
var issuers = currentData.top_issuers || [];
|
||||
for (var j = 0; j < issuers.length; j++) {
|
||||
var issuerName = issuers[j].name || '';
|
||||
if (partial === '' || issuerName.toLowerCase().indexOf(partial) !== -1) {
|
||||
values.push(issuerName);
|
||||
}
|
||||
}
|
||||
} else if (field === 'expired') {
|
||||
values = ['yes', 'no'];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide suggestions dropdown
|
||||
*/
|
||||
function hideSuggestions() {
|
||||
suggestions.classList.remove('visible');
|
||||
suggestionIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply suggestion to search input
|
||||
*/
|
||||
function applySuggestion(value) {
|
||||
var query = searchInput.value;
|
||||
var parts = query.split(/\s+/);
|
||||
parts[parts.length - 1] = value;
|
||||
searchInput.value = parts.join(' ');
|
||||
searchInput.focus();
|
||||
hideSuggestions();
|
||||
|
||||
// Trigger search if value is complete
|
||||
if (value.indexOf(':') !== -1) {
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input
|
||||
*/
|
||||
function handleSearch() {
|
||||
var query = searchInput.value;
|
||||
|
||||
// Show/hide clear button
|
||||
if (query) {
|
||||
searchClear.classList.add('visible');
|
||||
} else {
|
||||
searchClear.classList.remove('visible');
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(function() {
|
||||
performSearch(query);
|
||||
}, 200);
|
||||
|
||||
// Show suggestions immediately
|
||||
showSuggestions(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search
|
||||
*/
|
||||
function clearSearch() {
|
||||
searchInput.value = '';
|
||||
searchClear.classList.remove('visible');
|
||||
hideSuggestions();
|
||||
showEmpty();
|
||||
searchInput.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle help box
|
||||
*/
|
||||
window.toggleHelp = function() {
|
||||
var helpBox = document.getElementById('helpBox');
|
||||
helpBox.classList.toggle('collapsed');
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyboard navigation for suggestions
|
||||
*/
|
||||
function handleKeydown(e) {
|
||||
var items = suggestions.querySelectorAll('.suggestion-item');
|
||||
if (!items.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
suggestionIndex = Math.min(suggestionIndex + 1, items.length - 1);
|
||||
updateSuggestionHighlight(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
suggestionIndex = Math.max(suggestionIndex - 1, 0);
|
||||
updateSuggestionHighlight(items);
|
||||
} else if (e.key === 'Enter' && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
var value = items[suggestionIndex].getAttribute('data-value');
|
||||
if (value) applySuggestion(value);
|
||||
} else if (e.key === 'Escape') {
|
||||
hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update suggestion highlight
|
||||
*/
|
||||
function updateSuggestionHighlight(items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (i === suggestionIndex) {
|
||||
items[i].classList.add('active');
|
||||
} else {
|
||||
items[i].classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
searchInput.addEventListener('input', handleSearch);
|
||||
searchInput.addEventListener('keydown', handleKeydown);
|
||||
searchInput.addEventListener('focus', function() {
|
||||
if (searchInput.value) showSuggestions(searchInput.value);
|
||||
});
|
||||
searchClear.addEventListener('click', clearSearch);
|
||||
|
||||
// Click on suggestion
|
||||
suggestions.addEventListener('click', function(e) {
|
||||
var item = e.target.closest('.suggestion-item');
|
||||
if (item) {
|
||||
var value = item.getAttribute('data-value');
|
||||
if (value) applySuggestion(value);
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to hide suggestions
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.search-box')) {
|
||||
hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
loadStats();
|
||||
})();
|
||||
815
static/style.css
Normal file
815
static/style.css
Normal file
@@ -0,0 +1,815 @@
|
||||
/* PPF Dashboard Styles - Electric Cyan Theme */
|
||||
/* Theme variables are substituted at runtime by httpd.py */
|
||||
|
||||
:root {
|
||||
--bg: {bg}; --card: {card}; --card-alt: {card_alt}; --border: {border};
|
||||
--text: {text}; --dim: {dim}; --green: {green}; --red: {red};
|
||||
--yellow: {yellow}; --blue: {blue}; --purple: {purple};
|
||||
--cyan: {cyan}; --orange: {orange}; --pink: {pink}; --map-bg: {map_bg};
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.25);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.3);
|
||||
--glow-green: 0 0 12px rgba(63,185,80,0.4);
|
||||
--glow-blue: 0 0 12px rgba(88,166,255,0.4);
|
||||
--glow-red: 0 0 12px rgba(248,81,73,0.4);
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Muted dark theme - desaturated/softer dark colors */
|
||||
html.muted-dark {
|
||||
--bg: #1a1c20;
|
||||
--card: #22252a;
|
||||
--card-alt: #282c32;
|
||||
--border: #3a3f47;
|
||||
--text: #c8ccd4;
|
||||
--dim: #7a7f88;
|
||||
--green: #5a9a66;
|
||||
--red: #c06060;
|
||||
--yellow: #b39540;
|
||||
--blue: #6090b8;
|
||||
--purple: #8a6aa8;
|
||||
--cyan: #5a9098;
|
||||
--orange: #b87850;
|
||||
--pink: #a86080;
|
||||
--map-bg: #1a1c20;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.35);
|
||||
--glow-green: 0 0 10px rgba(90,154,102,0.3);
|
||||
--glow-blue: 0 0 10px rgba(96,144,184,0.3);
|
||||
--glow-red: 0 0 10px rgba(192,96,96,0.3);
|
||||
}
|
||||
|
||||
/* Light theme - muted/broken colors (not pure white) */
|
||||
html.light {
|
||||
--bg: #e8e4df;
|
||||
--card: #f5f2ed;
|
||||
--card-alt: #ebe7e2;
|
||||
--border: #c5c0b8;
|
||||
--text: #2c2a26;
|
||||
--dim: #6b6860;
|
||||
--green: #2a7d3f;
|
||||
--red: #c53030;
|
||||
--yellow: #a67c00;
|
||||
--blue: #2563a8;
|
||||
--purple: #7c3aad;
|
||||
--cyan: #1a7a7f;
|
||||
--orange: #c05621;
|
||||
--pink: #b5366b;
|
||||
--map-bg: #e8e4df;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
|
||||
--glow-green: 0 0 8px rgba(42,125,63,0.25);
|
||||
--glow-blue: 0 0 8px rgba(37,99,168,0.25);
|
||||
--glow-red: 0 0 8px rgba(197,48,48,0.25);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px; background: var(--bg); color: var(--text);
|
||||
padding: 16px; min-height: 100vh; line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
a { color: var(--blue); text-decoration: none; transition: color var(--transition); }
|
||||
a:hover { color: var(--cyan); }
|
||||
h1 { font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 16px; }
|
||||
h2 { font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 12px; }
|
||||
h3 { font-size: 13px; font-weight: 600; color: var(--dim); margin-bottom: 8px; }
|
||||
.container { max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
/* Header */
|
||||
.hdr { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||
.hdr h1 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
||||
.hdr h1::before { content: ""; width: 10px; height: 10px; background: var(--green); border-radius: 50%; box-shadow: 0 0 8px var(--green); }
|
||||
.status { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--dim); }
|
||||
.status-item { display: flex; align-items: center; gap: 4px; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
|
||||
.dot.err { background: var(--red); animation: none; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
.mode-badge { padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mode-ssl { background: rgba(63,185,80,0.2); color: var(--green); border: 1px solid var(--green); }
|
||||
.mode-profile { background: rgba(255,165,0,0.2); color: #ffa500; border: 1px solid #ffa500; margin-left: 6px; }
|
||||
.mode-judges { background: rgba(88,166,255,0.2); color: var(--blue); border: 1px solid var(--blue); }
|
||||
.mode-head { background: rgba(210,153,34,0.2); color: var(--yellow); border: 1px solid var(--yellow); }
|
||||
.mode-irc { background: rgba(163,113,247,0.2); color: var(--purple); border: 1px solid var(--purple); }
|
||||
|
||||
/* System monitor bar */
|
||||
.sysbar { display: flex; gap: 16px; padding: 8px 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 16px; font-size: 11px; }
|
||||
.sysbar-item { display: flex; align-items: center; gap: 6px; }
|
||||
.sysbar-lbl { color: var(--dim); }
|
||||
.sysbar-val { font-weight: 600; font-feature-settings: "tnum"; }
|
||||
.sysbar-bar { width: 50px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.sysbar-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
|
||||
.sysbar-net .net-tx::before { content: '↑'; opacity: 0.5; margin-right: 1px; }
|
||||
.sysbar-net .net-rx::before { content: '↓'; opacity: 0.5; margin-right: 1px; }
|
||||
.sysbar-net .sysbar-val { min-width: 42px; }
|
||||
|
||||
/* Grid */
|
||||
.g { display: grid; gap: 12px; margin-bottom: 16px; }
|
||||
.g2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.g3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.g4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.g5 { grid-template-columns: repeat(5, 1fr); }
|
||||
.g6 { grid-template-columns: repeat(6, 1fr); }
|
||||
@media (max-width: 1200px) { .g5, .g6 { grid-template-columns: repeat(4, 1fr); } }
|
||||
@media (max-width: 900px) { .g3, .g4, .g5, .g6 { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) { .g2, .g3, .g4, .g5, .g6 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Cards */
|
||||
.c {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px;
|
||||
box-shadow: var(--shadow-sm); transition: transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.c:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
.c-lg { padding: 16px 18px; }
|
||||
.c-sm { padding: 10px 12px; }
|
||||
.c-glow { box-shadow: var(--shadow-md), var(--glow-blue); }
|
||||
.lbl { font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; font-weight: 500; }
|
||||
.val { font-size: 26px; font-weight: 700; font-feature-settings: "tnum"; letter-spacing: -0.5px; line-height: 1.2; }
|
||||
.val-md { font-size: 20px; font-weight: 600; }
|
||||
.val-sm { font-size: 16px; font-weight: 600; }
|
||||
.sub { font-size: 11px; color: var(--dim); margin-top: 4px; }
|
||||
.grn { color: var(--green); } .red { color: var(--red); } .yel { color: var(--yellow); }
|
||||
.blu { color: var(--blue); } .pur { color: var(--purple); } .cyn { color: var(--cyan); }
|
||||
.org { color: var(--orange); } .pnk { color: var(--pink); }
|
||||
|
||||
/* Section headers */
|
||||
.sec { margin-bottom: 16px; }
|
||||
.sec-hdr { font-size: 11px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.sec-hdr::before { content: ""; width: 3px; height: 12px; background: var(--blue); border-radius: 2px; }
|
||||
|
||||
/* Progress bars */
|
||||
.bar-wrap { height: 6px; background: var(--border); border-radius: 3px; margin-top: 8px; overflow: hidden; position: relative; }
|
||||
.bar { height: 100%; border-radius: 3px; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); position: relative; }
|
||||
.bar::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); }
|
||||
.bar.grn { background: linear-gradient(90deg, #238636, #3fb950); box-shadow: 0 0 8px rgba(63,185,80,0.3); }
|
||||
.bar.red { background: linear-gradient(90deg, #da3633, #f85149); box-shadow: 0 0 8px rgba(248,81,73,0.3); }
|
||||
.bar.yel { background: linear-gradient(90deg, #9e6a03, #d29922); box-shadow: 0 0 8px rgba(210,153,34,0.3); }
|
||||
.bar.blu { background: linear-gradient(90deg, #1f6feb, #58a6ff); box-shadow: 0 0 8px rgba(88,166,255,0.3); }
|
||||
|
||||
/* Charts */
|
||||
.chart-wrap {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.65), rgba(18,24,34,0.75));
|
||||
border: 1px solid rgba(56,189,248,0.35); border-radius: 10px; padding: 12px 14px; margin-top: 8px;
|
||||
box-shadow: 0 0 28px rgba(56,189,248,0.12), 0 0 1px rgba(56,189,248,0.4), var(--shadow-md), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
position: relative; overflow: hidden; backdrop-filter: blur(8px);
|
||||
}
|
||||
.chart-wrap::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.06) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart { width: 100%; height: 80px; position: relative; }
|
||||
.chart-lg { height: 120px; }
|
||||
.chart svg { width: 100%; height: 100%; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); }
|
||||
.chart-line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 4px currentColor); }
|
||||
.chart-area { opacity: 0.25; }
|
||||
.chart-grid { stroke: var(--border); stroke-width: 0.5; stroke-dasharray: 2 4; }
|
||||
.chart-label { font-size: 9px; fill: var(--dim); }
|
||||
|
||||
/* Histogram bars */
|
||||
.histo-wrap {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.65), rgba(18,24,34,0.75));
|
||||
border: 1px solid rgba(56,189,248,0.35); border-radius: 10px; padding: 14px 16px 26px;
|
||||
box-shadow: 0 0 28px rgba(56,189,248,0.12), 0 0 1px rgba(56,189,248,0.4), var(--shadow-md), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
position: relative; overflow: hidden; backdrop-filter: blur(8px);
|
||||
}
|
||||
.histo-wrap::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.06) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.histo { display: flex; align-items: flex-end; gap: 3px; height: 70px; padding: 0 2px; position: relative; z-index: 1; }
|
||||
.histo-bar {
|
||||
flex: 1; border-radius: 4px 4px 0 0; min-height: 3px; position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: linear-gradient(180deg, var(--cyan), rgba(88,166,255,0.6));
|
||||
box-shadow: 0 0 10px rgba(56,189,248,0.4), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||
}
|
||||
.histo-bar:hover { transform: scaleY(1.1) translateY(-1px); filter: brightness(1.35); box-shadow: 0 0 16px rgba(56,189,248,0.6); }
|
||||
.histo-bar::after { content: attr(data-label); position: absolute; bottom: -16px; left: 50%; transform: translateX(-50%); font-size: 8px; color: var(--dim); white-space: nowrap; opacity: 0.8; }
|
||||
.histo-labels { display: flex; justify-content: space-between; margin-top: 20px; font-size: 9px; color: var(--dim); position: relative; z-index: 1; }
|
||||
|
||||
/* Stat rows */
|
||||
.stats-wrap {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.7), rgba(18,24,34,0.8));
|
||||
border: 1px solid rgba(56,189,248,0.28); border-radius: 10px; padding: 14px 16px;
|
||||
box-shadow: 0 0 24px rgba(56,189,248,0.1), 0 0 1px rgba(56,189,248,0.35), var(--shadow-md), inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
position: relative; overflow: hidden; backdrop-filter: blur(6px);
|
||||
}
|
||||
.stats-wrap::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.05) 0%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 12px; }
|
||||
.stat-row + .stat-row { border-top: 1px solid rgba(58,68,80,0.4); }
|
||||
.stat-lbl { color: var(--dim); display: flex; align-items: center; gap: 6px; }
|
||||
.stat-val { font-weight: 600; font-feature-settings: "tnum"; }
|
||||
.stat-bar { width: 60px; height: 4px; background: var(--border); border-radius: 2px; margin-left: 8px; overflow: hidden; }
|
||||
.stat-bar-fill { height: 100%; border-radius: 2px; }
|
||||
|
||||
/* Leaderboard */
|
||||
.lb-wrap {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.7), rgba(18,24,34,0.8));
|
||||
border: 1px solid rgba(56,189,248,0.28); border-radius: 10px; padding: 14px 16px;
|
||||
box-shadow: 0 0 24px rgba(56,189,248,0.1), 0 0 1px rgba(56,189,248,0.35), var(--shadow-md), inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
position: relative; overflow: hidden; backdrop-filter: blur(6px);
|
||||
}
|
||||
.lb-wrap::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.05) 0%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lb { font-size: 12px; }
|
||||
.lb-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; }
|
||||
.lb-item + .lb-item { border-top: 1px solid rgba(58,68,80,0.3); }
|
||||
.lb-rank { width: 18px; height: 18px; border-radius: 4px; background: var(--card-alt); display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: var(--dim); }
|
||||
.lb-rank.top { background: var(--yellow); color: var(--bg); }
|
||||
.lb-name { flex: 1; font-family: ui-monospace, monospace; color: var(--text); }
|
||||
.lb-val { font-weight: 600; font-feature-settings: "tnum"; }
|
||||
|
||||
/* Tags */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px;
|
||||
border-radius: 4px; font-size: 10px; font-weight: 600;
|
||||
transition: all var(--transition); backdrop-filter: blur(4px);
|
||||
}
|
||||
.tag-ok { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid rgba(63,185,80,0.3); }
|
||||
.tag-ok:hover { background: rgba(63,185,80,0.25); box-shadow: 0 0 8px rgba(63,185,80,0.2); }
|
||||
.tag-err { background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid rgba(248,81,73,0.3); }
|
||||
.tag-err:hover { background: rgba(248,81,73,0.25); box-shadow: 0 0 8px rgba(248,81,73,0.2); }
|
||||
.tag-warn { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid rgba(210,153,34,0.3); }
|
||||
.tag-warn:hover { background: rgba(210,153,34,0.25); box-shadow: 0 0 8px rgba(210,153,34,0.2); }
|
||||
.tag-info { background: rgba(88,166,255,0.15); color: var(--blue); border: 1px solid rgba(88,166,255,0.3); }
|
||||
.tag-info:hover { background: rgba(88,166,255,0.25); box-shadow: 0 0 8px rgba(88,166,255,0.2); }
|
||||
|
||||
/* Mini stats */
|
||||
.mini { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.mini-item { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.mini-val { font-size: 14px; font-weight: 600; font-feature-settings: "tnum"; }
|
||||
.mini-lbl { font-size: 10px; color: var(--dim); }
|
||||
|
||||
/* Proto cards */
|
||||
.proto-card { text-align: center; transition: transform var(--transition); }
|
||||
.proto-card:hover { transform: translateY(-2px); }
|
||||
.proto-icon { font-size: 24px; margin-bottom: 6px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); }
|
||||
.proto-name { font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.proto-val { font-size: 20px; font-weight: 700; margin: 6px 0; }
|
||||
.proto-rate { font-size: 11px; padding: 3px 8px; border-radius: 4px; display: inline-block; }
|
||||
|
||||
/* Pie charts */
|
||||
.pie-wrap {
|
||||
display: flex; gap: 20px; align-items: center;
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.65), rgba(18,24,34,0.75));
|
||||
border: 1px solid rgba(56,189,248,0.35); border-radius: 10px; padding: 16px;
|
||||
box-shadow: 0 0 28px rgba(56,189,248,0.12), 0 0 1px rgba(56,189,248,0.4), var(--shadow-md), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
position: relative; overflow: hidden; backdrop-filter: blur(8px);
|
||||
}
|
||||
.pie-wrap::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.06) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pie {
|
||||
width: 100px; height: 100px; border-radius: 50%; flex-shrink: 0;
|
||||
box-shadow: var(--shadow-md), inset 0 0 20px rgba(0,0,0,0.3);
|
||||
position: relative; transition: transform var(--transition);
|
||||
}
|
||||
.pie:hover { transform: scale(1.02); }
|
||||
.legend { flex: 1; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; transition: opacity var(--transition); }
|
||||
.legend-item:hover { opacity: 0.8; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; box-shadow: 0 0 4px currentColor; }
|
||||
.legend-name { flex: 1; color: var(--dim); }
|
||||
.legend-val { font-weight: 600; font-feature-settings: "tnum"; }
|
||||
|
||||
/* Tor/Host cards */
|
||||
.tor-card {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.7), rgba(18,24,34,0.8));
|
||||
border: 1px solid rgba(56,189,248,0.28); border-radius: 10px; padding: 12px 14px;
|
||||
box-shadow: 0 0 24px rgba(56,189,248,0.1), 0 0 1px rgba(56,189,248,0.35), var(--shadow-md), inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
position: relative; overflow: hidden; transition: all 0.2s ease; backdrop-filter: blur(6px);
|
||||
}
|
||||
.tor-card::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.05) 0%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tor-card:hover { transform: translateY(-2px); box-shadow: 0 0 36px rgba(56,189,248,0.15), 0 0 2px rgba(56,189,248,0.5), var(--shadow-lg); }
|
||||
.host-card { display: flex; justify-content: space-between; align-items: center; position: relative; z-index: 1; }
|
||||
.host-addr { font-family: ui-monospace, monospace; font-size: 12px; font-weight: 500; }
|
||||
.host-stats { font-size: 11px; color: var(--dim); margin-top: 6px; position: relative; z-index: 1; }
|
||||
|
||||
.judge-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 11px; }
|
||||
.judge-item + .judge-item { border-top: 1px solid rgba(58,68,80,0.3); }
|
||||
.judge-name { font-family: ui-monospace, monospace; color: var(--dim); flex: 1; }
|
||||
.judge-stats { display: flex; gap: 12px; }
|
||||
|
||||
/* Percentile badges */
|
||||
.pct-badges {
|
||||
display: flex; gap: 8px; margin-top: 10px;
|
||||
background: linear-gradient(135deg, rgba(28,35,45,0.85), rgba(18,25,33,0.95));
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
.pct-badge {
|
||||
flex: 1; text-align: center; padding: 8px 6px;
|
||||
background: linear-gradient(180deg, rgba(36,44,56,0.6), rgba(28,35,45,0.8));
|
||||
border: 1px solid rgba(58,68,80,0.5); border-radius: 6px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.pct-badge:hover { background: linear-gradient(180deg, rgba(42,52,64,0.7), rgba(32,40,50,0.9)); transform: translateY(-1px); }
|
||||
.pct-label { font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.pct-value { font-size: 16px; font-weight: 700; margin-top: 3px; }
|
||||
|
||||
/* Map page */
|
||||
.nav { margin-bottom: 16px; font-size: 12px; }
|
||||
.map-stats { margin-bottom: 16px; color: var(--dim); font-size: 12px; padding: 8px 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.country-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; }
|
||||
.country { padding: 12px 8px; border-radius: 6px; text-align: center; background: var(--card); border: 1px solid var(--border); transition: transform 0.15s, box-shadow 0.15s; }
|
||||
.country:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
|
||||
.country .code { font-weight: bold; font-size: 1.1em; letter-spacing: 0.5px; }
|
||||
.country .count { font-size: 0.85em; color: var(--dim); margin-top: 4px; font-feature-settings: "tnum"; }
|
||||
.country.t1 { background: #0d4429; border-color: #238636; }
|
||||
.country.t1 .code { color: #7ee787; }
|
||||
.country.t2 { background: #1a3d2e; border-color: #2ea043; }
|
||||
.country.t2 .code { color: #7ee787; }
|
||||
.country.t3 { background: #1f3d2a; border-color: #3fb950; }
|
||||
.country.t3 .code { color: #56d364; }
|
||||
.country.t4 { background: #2a4a35; border-color: #56d364; }
|
||||
.country.t4 .code { color: #3fb950; }
|
||||
.country.t5 { background: #35573f; border-color: #7ee787; }
|
||||
.country.t5 .code { color: #3fb950; }
|
||||
.map-legend { display: flex; gap: 16px; margin-top: 20px; flex-wrap: wrap; padding: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.map-legend .legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dim); }
|
||||
.map-legend .legend-dot { width: 12px; height: 12px; border-radius: 3px; }
|
||||
|
||||
/* Footer */
|
||||
.ftr { text-align: center; font-size: 11px; color: var(--dim); padding: 16px 0; margin-top: 8px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { margin-bottom: 16px; }
|
||||
.tab-nav {
|
||||
display: flex; gap: 4px; padding: 4px; background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none;
|
||||
}
|
||||
.tab-nav::-webkit-scrollbar { display: none; }
|
||||
.tab-btn {
|
||||
padding: 8px 16px; border: none; background: transparent; color: var(--dim);
|
||||
font-size: 12px; font-weight: 500; border-radius: 6px; cursor: pointer;
|
||||
transition: all var(--transition); white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
||||
.tab-btn.active {
|
||||
background: var(--blue); color: #fff; box-shadow: var(--shadow-sm), var(--glow-blue);
|
||||
}
|
||||
.tab-content { display: none; animation: fadeIn 0.3s ease; }
|
||||
.tab-content.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* Responsive tabs */
|
||||
@media (max-width: 768px) {
|
||||
.tab-nav { padding: 3px; gap: 2px; }
|
||||
.tab-btn { padding: 6px 12px; font-size: 11px; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Map Page Styles
|
||||
========================================================================== */
|
||||
|
||||
/* Glass panel effect - reusable */
|
||||
.glass {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.65), rgba(18,24,34,0.75));
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(56,189,248,0.35); border-radius: 10px;
|
||||
box-shadow: 0 0 28px rgba(56,189,248,0.12), 0 0 1px rgba(56,189,248,0.4),
|
||||
var(--shadow-lg), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.glass::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(56,189,248,0.06) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Map container */
|
||||
.map-page { padding: 0; overflow: hidden; background: var(--map-bg); }
|
||||
.map-page #map { width: 100%; height: 100vh; }
|
||||
|
||||
/* Map navigation */
|
||||
.map-nav { position: fixed; top: 20px; left: 20px; z-index: 1000; padding: 12px 20px; }
|
||||
.map-nav a {
|
||||
color: var(--text); text-decoration: none; font-size: 13px;
|
||||
font-weight: 500; letter-spacing: 0.3px; transition: color var(--transition);
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.map-nav a:hover { color: var(--cyan); }
|
||||
|
||||
/* Map stats panel */
|
||||
.map-stats-panel {
|
||||
position: fixed; top: 20px; right: 20px; z-index: 1000;
|
||||
padding: 16px 24px; display: flex; gap: 28px;
|
||||
}
|
||||
.map-stat { display: flex; flex-direction: column; align-items: flex-end; position: relative; z-index: 1; }
|
||||
.map-stat-val {
|
||||
font-size: 26px; font-weight: 600; color: var(--cyan); line-height: 1.1;
|
||||
font-feature-settings: "tnum"; letter-spacing: -0.5px;
|
||||
text-shadow: 0 0 20px rgba(56,189,248,0.4);
|
||||
}
|
||||
.map-stat-lbl {
|
||||
font-size: 10px; color: rgba(56,189,248,0.6); text-transform: uppercase;
|
||||
letter-spacing: 1px; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Map legend panel */
|
||||
.map-legend-panel { position: fixed; bottom: 24px; left: 20px; z-index: 1000; padding: 16px 20px; }
|
||||
.map-legend-title {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
|
||||
color: rgba(56,189,248,0.5); margin-bottom: 12px; position: relative; z-index: 1;
|
||||
}
|
||||
.map-legend-row {
|
||||
display: flex; align-items: center; gap: 12px; margin: 8px 0;
|
||||
font-size: 12px; color: rgba(230,237,243,0.7); position: relative; z-index: 1;
|
||||
}
|
||||
.map-legend-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.map-legend-dot.elite { background: var(--green); box-shadow: 0 0 12px rgba(80,200,120,0.5); }
|
||||
.map-legend-dot.anonymous { background: var(--cyan); box-shadow: 0 0 12px rgba(56,189,248,0.5); }
|
||||
.map-legend-dot.transparent { background: var(--orange); box-shadow: 0 0 12px rgba(249,115,22,0.5); }
|
||||
.map-legend-dot.unknown { background: var(--dim); box-shadow: 0 0 12px rgba(107,114,128,0.5); }
|
||||
|
||||
/* Map footer */
|
||||
.map-footer {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 1000;
|
||||
font-size: 11px; color: rgba(56,189,248,0.4);
|
||||
}
|
||||
.map-footer a { color: rgba(56,189,248,0.5); text-decoration: none; transition: color var(--transition); }
|
||||
.map-footer a:hover { color: var(--cyan); }
|
||||
|
||||
/* Leaflet overrides for map page */
|
||||
.map-page .leaflet-container { background: var(--map-bg); font-family: inherit; }
|
||||
.map-page .leaflet-control-zoom { border: none !important; margin: 20px !important; }
|
||||
.map-page .leaflet-control-zoom a {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.85), rgba(18,24,34,0.9)) !important;
|
||||
backdrop-filter: blur(12px); color: rgba(56,189,248,0.8) !important;
|
||||
border: 1px solid rgba(56,189,248,0.25) !important;
|
||||
width: 36px; height: 36px; line-height: 36px !important;
|
||||
font-size: 18px; font-weight: 300; transition: all var(--transition);
|
||||
}
|
||||
.map-page .leaflet-control-zoom a:first-child { border-radius: 8px 8px 0 0 !important; }
|
||||
.map-page .leaflet-control-zoom a:last-child { border-radius: 0 0 8px 8px !important; border-top: none !important; }
|
||||
.map-page .leaflet-control-zoom a:hover {
|
||||
background: rgba(56,189,248,0.15) !important; color: var(--cyan) !important;
|
||||
box-shadow: 0 0 16px rgba(56,189,248,0.3);
|
||||
}
|
||||
.map-page .leaflet-control-attribution {
|
||||
background: transparent !important; color: rgba(56,189,248,0.3) !important;
|
||||
font-size: 10px; padding: 4px 8px !important;
|
||||
}
|
||||
.map-page .leaflet-control-attribution a { color: rgba(56,189,248,0.4) !important; }
|
||||
|
||||
/* Map popups */
|
||||
.map-page .leaflet-popup-content-wrapper {
|
||||
background: linear-gradient(145deg, rgba(24,31,42,0.95), rgba(18,24,34,0.97));
|
||||
backdrop-filter: blur(20px); color: var(--text); border-radius: 10px;
|
||||
border: 1px solid rgba(56,189,248,0.3);
|
||||
box-shadow: 0 0 32px rgba(56,189,248,0.15), 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.map-page .leaflet-popup-tip { background: rgba(24,31,42,0.95); border: 1px solid rgba(56,189,248,0.2); }
|
||||
.map-page .leaflet-popup-content { margin: 16px 20px; }
|
||||
.popup-header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
|
||||
.popup-code { font-size: 24px; font-weight: 600; letter-spacing: -0.5px; color: var(--cyan); text-shadow: 0 0 16px rgba(56,189,248,0.4); }
|
||||
.popup-name { font-size: 12px; color: rgba(230,237,243,0.5); }
|
||||
.popup-count { font-size: 16px; font-weight: 500; color: var(--green); }
|
||||
.popup-coords { font-size: 11px; color: rgba(56,189,248,0.5); margin-top: 6px; font-family: ui-monospace, monospace; }
|
||||
.map-page .leaflet-popup-close-button {
|
||||
color: rgba(56,189,248,0.5) !important; font-size: 22px !important;
|
||||
padding: 8px 12px !important; font-weight: 300;
|
||||
}
|
||||
.map-page .leaflet-popup-close-button:hover { color: var(--cyan) !important; }
|
||||
|
||||
/* Map cluster styles */
|
||||
.cluster-wrapper { background: transparent !important; border: none !important; }
|
||||
.cluster-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; font-weight: 600; font-size: 12px;
|
||||
background: linear-gradient(145deg, rgba(80,200,120,0.9), rgba(56,189,248,0.9));
|
||||
border: 2px solid rgba(56,189,248,0.6); color: #fff;
|
||||
box-shadow: 0 0 16px rgba(56,189,248,0.5), var(--shadow-md);
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
.cluster-sm { width: 32px; height: 32px; font-size: 11px; }
|
||||
.cluster-md { width: 40px; height: 40px; font-size: 13px; }
|
||||
.cluster-lg { width: 50px; height: 50px; font-size: 15px; }
|
||||
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large { background: transparent !important; }
|
||||
.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div { background: transparent !important; }
|
||||
|
||||
/* Loading animation */
|
||||
@keyframes loading-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.loading { animation: loading-pulse 1.8s ease-in-out infinite; }
|
||||
|
||||
/* ==========================================================================
|
||||
MITM Search Page Styles
|
||||
========================================================================== */
|
||||
|
||||
.search-container { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* Search Header */
|
||||
.search-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search-header h1 {
|
||||
font-size: 18px; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.search-header h1::before {
|
||||
content: ""; width: 10px; height: 10px;
|
||||
background: var(--red); border-radius: 50%; box-shadow: 0 0 8px var(--red);
|
||||
}
|
||||
.back-link { font-size: 12px; color: var(--dim); }
|
||||
.back-link:hover { color: var(--cyan); }
|
||||
|
||||
/* Search Box */
|
||||
.search-box { position: relative; margin-bottom: 20px; }
|
||||
.search-input {
|
||||
width: 100%; padding: 14px 16px 14px 44px; font-size: 15px; font-family: inherit;
|
||||
background: var(--card); border: 2px solid var(--border); border-radius: 10px;
|
||||
color: var(--text); outline: none; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15); }
|
||||
.search-input::placeholder { color: var(--dim); }
|
||||
.search-icon {
|
||||
position: absolute; left: 16px; top: 50%; transform: translateY(-50%);
|
||||
color: var(--dim); font-size: 16px; pointer-events: none;
|
||||
}
|
||||
.search-clear {
|
||||
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
||||
background: var(--border); border: none; border-radius: 50%;
|
||||
width: 22px; height: 22px; cursor: pointer; color: var(--dim); font-size: 14px;
|
||||
display: none; align-items: center; justify-content: center; transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.search-clear:hover { background: var(--red); color: var(--text); }
|
||||
.search-clear.visible { display: flex; }
|
||||
|
||||
/* Suggestions Dropdown */
|
||||
.suggestions {
|
||||
position: absolute; top: 100%; left: 0; right: 0;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
margin-top: 4px; box-shadow: var(--shadow-lg); z-index: 100;
|
||||
display: none; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
.suggestions.visible { display: block; }
|
||||
.suggestion-group { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.suggestion-group:last-child { border-bottom: none; }
|
||||
.suggestion-header {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--dim); padding: 4px 14px;
|
||||
}
|
||||
.suggestion-item {
|
||||
padding: 8px 14px; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 10px; transition: background 0.15s;
|
||||
}
|
||||
.suggestion-item:hover, .suggestion-item.active { background: rgba(88, 166, 255, 0.1); }
|
||||
.suggestion-icon { font-size: 12px; color: var(--dim); width: 20px; text-align: center; }
|
||||
.suggestion-text { flex: 1; font-size: 13px; }
|
||||
.suggestion-text code {
|
||||
background: var(--card-alt); padding: 2px 6px; border-radius: 4px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; color: var(--cyan);
|
||||
}
|
||||
.suggestion-desc { font-size: 11px; color: var(--dim); }
|
||||
|
||||
/* Help Box */
|
||||
.help-box {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 16px; margin-bottom: 20px;
|
||||
}
|
||||
.help-box.collapsed .help-content { display: none; }
|
||||
.help-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
||||
.help-title {
|
||||
font-size: 13px; font-weight: 600; color: var(--text);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.help-title::before {
|
||||
content: "?"; display: flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; background: var(--blue); color: var(--bg);
|
||||
border-radius: 50%; font-size: 11px; font-weight: 700;
|
||||
}
|
||||
.help-toggle { font-size: 12px; color: var(--dim); transition: transform 0.2s; }
|
||||
.help-box.collapsed .help-toggle { transform: rotate(-90deg); }
|
||||
.help-content { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.help-section { margin-bottom: 14px; }
|
||||
.help-section:last-child { margin-bottom: 0; }
|
||||
.help-section-title {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--dim); margin-bottom: 8px;
|
||||
}
|
||||
.help-examples { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
@media (max-width: 600px) { .help-examples { grid-template-columns: 1fr; } }
|
||||
.help-example {
|
||||
background: var(--card-alt); padding: 8px 12px; border-radius: 6px; font-size: 12px;
|
||||
}
|
||||
.help-example code { color: var(--cyan); font-family: "SFMono-Regular", Consolas, monospace; }
|
||||
.help-example span { color: var(--dim); margin-left: 6px; }
|
||||
|
||||
/* Stats Summary */
|
||||
.stats-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
@media (max-width: 700px) { .stats-summary { grid-template-columns: repeat(2, 1fr); } }
|
||||
.stat-card {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px; text-align: center;
|
||||
}
|
||||
.stat-card .lbl { font-size: 10px; margin-bottom: 4px; }
|
||||
.stat-card .val { font-size: 22px; font-weight: 700; }
|
||||
|
||||
/* Results Area */
|
||||
.results-container { min-height: 200px; }
|
||||
.results-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.results-count { font-size: 12px; color: var(--dim); }
|
||||
.results-count strong { color: var(--text); }
|
||||
.results-empty { text-align: center; padding: 60px 20px; color: var(--dim); }
|
||||
.results-empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.results-empty-text { font-size: 14px; margin-bottom: 8px; }
|
||||
.results-empty-hint { font-size: 12px; }
|
||||
.results-empty-hint code {
|
||||
background: var(--card-alt); padding: 2px 6px; border-radius: 4px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace; color: var(--cyan);
|
||||
}
|
||||
|
||||
/* Result Cards */
|
||||
.result-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 16px; margin-bottom: 12px; transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.result-card:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
.result-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.result-title {
|
||||
font-size: 14px; font-weight: 600; color: var(--text);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.result-badge {
|
||||
font-size: 10px; padding: 3px 8px; border-radius: 4px;
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
}
|
||||
.result-badge.cert { background: rgba(248, 81, 73, 0.2); color: var(--red); }
|
||||
.result-badge.proxy { background: rgba(88, 166, 255, 0.2); color: var(--blue); }
|
||||
.result-badge.org { background: rgba(163, 113, 247, 0.2); color: var(--purple); }
|
||||
.result-meta { font-size: 11px; color: var(--dim); }
|
||||
.result-body { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
@media (max-width: 600px) { .result-body { grid-template-columns: 1fr; } }
|
||||
.result-field { display: flex; flex-direction: column; gap: 2px; }
|
||||
.result-field-label {
|
||||
font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.result-field-value {
|
||||
font-size: 13px; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
color: var(--text); word-break: break-all;
|
||||
}
|
||||
.result-field-value.highlight { color: var(--cyan); }
|
||||
.result-proxies { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.result-proxies-title {
|
||||
font-size: 10px; color: var(--dim); text-transform: uppercase;
|
||||
letter-spacing: 0.5px; margin-bottom: 8px;
|
||||
}
|
||||
.proxy-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.proxy-tag {
|
||||
font-size: 11px; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
background: var(--card-alt); padding: 4px 8px; border-radius: 4px; color: var(--blue);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.results-loading { text-align: center; padding: 40px; color: var(--dim); }
|
||||
.results-loading::after {
|
||||
content: ""; display: inline-block; width: 20px; height: 20px;
|
||||
border: 2px solid var(--border); border-top-color: var(--cyan); border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite; margin-left: 10px; vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* No Data State */
|
||||
.no-data { text-align: center; padding: 80px 20px; }
|
||||
.no-data-icon { font-size: 64px; margin-bottom: 20px; opacity: 0.3; }
|
||||
.no-data-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
||||
.no-data-text { font-size: 13px; color: var(--dim); max-width: 400px; margin: 0 auto; }
|
||||
|
||||
/* ==========================================================================
|
||||
Theme Toggle
|
||||
========================================================================== */
|
||||
|
||||
.theme-toggle {
|
||||
background: var(--card-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
transition: all var(--transition);
|
||||
margin-left: 12px;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.theme-toggle-icon {
|
||||
font-size: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
html.muted-dark .theme-toggle-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
html.light .theme-toggle-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
/* Muted dark theme specific overrides */
|
||||
html.muted-dark .chart-wrap,
|
||||
html.muted-dark .histo-wrap,
|
||||
html.muted-dark .stats-wrap,
|
||||
html.muted-dark .lb-wrap,
|
||||
html.muted-dark .tor-card,
|
||||
html.muted-dark .pie-wrap {
|
||||
background: linear-gradient(145deg, rgba(34,37,42,0.85), rgba(26,28,32,0.9));
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
html.muted-dark .chart-wrap::before,
|
||||
html.muted-dark .histo-wrap::before,
|
||||
html.muted-dark .stats-wrap::before,
|
||||
html.muted-dark .lb-wrap::before,
|
||||
html.muted-dark .tor-card::before,
|
||||
html.muted-dark .pie-wrap::before {
|
||||
background: linear-gradient(180deg, rgba(90,144,152,0.04) 0%, transparent 40%);
|
||||
}
|
||||
html.muted-dark .histo-bar {
|
||||
background: linear-gradient(180deg, var(--cyan), rgba(90,144,152,0.5));
|
||||
box-shadow: 0 0 8px rgba(90,144,152,0.35);
|
||||
}
|
||||
html.muted-dark .glass {
|
||||
background: linear-gradient(145deg, rgba(34,37,42,0.85), rgba(26,28,32,0.9));
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* Light theme specific overrides */
|
||||
html.light .chart-wrap,
|
||||
html.light .histo-wrap,
|
||||
html.light .stats-wrap,
|
||||
html.light .lb-wrap,
|
||||
html.light .tor-card,
|
||||
html.light .pie-wrap {
|
||||
background: linear-gradient(145deg, rgba(245,242,237,0.9), rgba(235,231,226,0.95));
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
html.light .chart-wrap::before,
|
||||
html.light .histo-wrap::before,
|
||||
html.light .stats-wrap::before,
|
||||
html.light .lb-wrap::before,
|
||||
html.light .tor-card::before,
|
||||
html.light .pie-wrap::before {
|
||||
background: linear-gradient(180deg, rgba(200,195,188,0.1) 0%, transparent 40%);
|
||||
}
|
||||
html.light .histo-bar {
|
||||
background: linear-gradient(180deg, var(--cyan), rgba(26,122,127,0.6));
|
||||
box-shadow: 0 0 8px rgba(26,122,127,0.3);
|
||||
}
|
||||
html.light .glass {
|
||||
background: linear-gradient(145deg, rgba(245,242,237,0.9), rgba(235,231,226,0.95));
|
||||
border-color: var(--border);
|
||||
}
|
||||
html.light .pct-badges {
|
||||
background: linear-gradient(135deg, rgba(245,242,237,0.95), rgba(235,231,226,0.98));
|
||||
}
|
||||
html.light .pct-badge {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.6), rgba(245,242,237,0.8));
|
||||
border-color: var(--border);
|
||||
}
|
||||
html.light .map-stat-val {
|
||||
color: var(--cyan);
|
||||
text-shadow: none;
|
||||
}
|
||||
html.light .map-stat-lbl {
|
||||
color: var(--dim);
|
||||
}
|
||||
html.light .map-legend-title {
|
||||
color: var(--dim);
|
||||
}
|
||||
html.light .map-legend-row {
|
||||
color: var(--text);
|
||||
}
|
||||
html.light .leaflet-popup-content-wrapper {
|
||||
background: rgba(245,242,237,0.98);
|
||||
border-color: var(--border);
|
||||
}
|
||||
html.light .leaflet-popup-tip {
|
||||
background: rgba(245,242,237,0.98);
|
||||
border-color: var(--border);
|
||||
}
|
||||
html.light .popup-code {
|
||||
color: var(--cyan);
|
||||
text-shadow: none;
|
||||
}
|
||||
Reference in New Issue
Block a user