Add files via upload

This commit is contained in:
rogier authored and GitHub committed 2025-11-28 17:44:43 +01:00
1 parent 23f71e8716
commit db8af1f016
13 files changed
+1377 -2

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
FROM python:3.9-slim
RUN apt-get update && apt-get install -y iperf3 ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
ENV PYTHONUNBUFFERED=1
CMD ["python", "app.py"]
+37 -2
View File
@@ -1,2 +1,37 @@
# iPERF3-MAP # IPERF3 MAP
isualizes iPerf3 servers on a global map using Leaflet.js, allowing you to initiate speed tests directly from the map markers.
This project provides a web-based interactive map interface for visualizing and testing public iPerf3 servers. It automatically fetches a list of [public iperf3 servers](https://github.com/R0GGER/public-iperf3-servers), determines their geographic locations, and allows you to run iPerf3 speed tests directly from your browser.
<img src="screenshots/1.png" alt="iPerf3 Server Map" width="400" /> <img src="screenshots/2.png" alt="iPerf3 Server Map" width="400" />
- **Interactive Map & Testing**: Visualizes iPerf3 servers on a global map using Leaflet.js, allowing you to initiate speed tests directly from the map markers.
- **Automatic Geocoding**: resolving server locations (City/Country) to coordinates using `geonamescache` (offline) and OpenStreetMap's Nominatim (online fallback).
- **Smart Caching**: Caches geocoded locations locally to improve performance and respect API rate limits.
- **Integrated Speed Tests**: Run `iperf3` commands directly from the web interface against any server on the map.
## Prerequisites
- Docker
- Docker Compose
## Installation & Usage
1. **Clone the repository:**
```bash
git clone <repository-url>
cd iperf3-map
```
2. **Build and run the container:**
```bash
docker-compose up -d --build
```
3. **Access the application:**
Open your browser and navigate to `http://localhost:5000`.
## License
[MIT License](LICENSE)
+258
View File
@@ -0,0 +1,258 @@
import os
import json
import subprocess
import requests
import threading
import time
from flask import Flask, jsonify, request, send_from_directory
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
import geonamescache
from generate_locations import generate_locations
app = Flask(__name__, static_url_path='')
LOCATION_CACHE_FILE = 'location_cache.json'
location_cache = {}
failed_lookups = set()
processed_servers = []
processing_lock = threading.Lock()
gc = geonamescache.GeonamesCache()
cities_by_name = {}
CITY_ALIASES = {
"new york": "new york city",
"frankfurt": "frankfurt am main",
"st. louis": "saint louis",
"montreal": "montréal",
"reykjavik": "reykjavík",
"sao paulo": "são paulo",
"zurich": "zürich",
"quebec": "québec"
}
COUNTRY_FIXES = {
"UK": "GB",
"USA": "US"
}
def setup_offline_geocoding():
print("Loading offline city database...", flush=True)
cities = gc.get_cities()
for city_id, city_data in cities.items():
name = city_data['name'].lower()
country = city_data['countrycode']
key = f"{name}, {country}"
cities_by_name[key] = {
"lat": float(city_data['latitude']),
"lon": float(city_data['longitude'])
}
print(f"Offline database loaded ({len(cities_by_name)} cities).", flush=True)
geolocator = Nominatim(user_agent="iperf3-map-app", timeout=10)
def load_cache():
global location_cache
if os.path.exists(LOCATION_CACHE_FILE):
try:
with open(LOCATION_CACHE_FILE, 'r') as f:
location_cache = json.load(f)
except:
location_cache = {}
if os.path.exists('prefilled_locations.json'):
try:
with open('prefilled_locations.json', 'r') as f:
static_cache = json.load(f)
for k, v in static_cache.items():
if k not in location_cache:
location_cache[k] = v
except Exception as e:
print(f"Error loading prefilled locations: {e}")
def save_cache():
with open(LOCATION_CACHE_FILE, 'w') as f:
json.dump(location_cache, f)
def get_coordinates(city, country):
if country in COUNTRY_FIXES:
country = COUNTRY_FIXES[country]
key = f"{city}, {country}"
if key in location_cache:
return location_cache[key]
if key in failed_lookups:
return None
city_lower = city.lower()
if city_lower in CITY_ALIASES:
search_city = CITY_ALIASES[city_lower]
else:
search_city = city_lower
offline_key = f"{search_city}, {country}"
if offline_key in cities_by_name:
print(f"Found offline: {key} (via {offline_key})", flush=True)
coords = cities_by_name[offline_key]
location_cache[key] = coords
save_cache()
return coords
try:
print(f"Geocoding online: {key}...", flush=True)
search_key = f"{search_city}, {country}"
location = geolocator.geocode(search_key)
if location:
coords = {"lat": location.latitude, "lon": location.longitude}
location_cache[key] = coords
save_cache()
time.sleep(1.1)
return coords
else:
print(f"Could not find location for: {key}", flush=True)
failed_lookups.add(key)
except Exception as e:
print(f"Error geocoding {key}: {e}", flush=True)
return None
def update_servers_background():
global processed_servers
if not cities_by_name:
setup_offline_geocoding()
print("Starting background server update...", flush=True)
url = "https://export.iperf3serverlist.net/listed_iperf3_servers.json"
try:
print(f"Fetching {url}...", flush=True)
response = requests.get(url, timeout=30)
response.raise_for_status()
servers = response.json()
print(f"Fetched {len(servers)} servers.", flush=True)
load_cache()
temp_processed = []
for server in servers:
city = server.get('SITE')
country = server.get('COUNTRY')
if not city or not country:
continue
key = f"{city}, {country}"
if key in location_cache:
server['lat'] = location_cache[key]['lat']
server['lon'] = location_cache[key]['lon']
temp_processed.append(server)
with processing_lock:
processed_servers = list(temp_processed)
print(f"Initial load: {len(processed_servers)} servers from cache.", flush=True)
changes_made = False
for server in servers:
city = server.get('SITE')
country = server.get('COUNTRY')
if not city or not country:
continue
if 'lat' in server:
continue
key = f"{city}, {country}"
if key in location_cache:
server['lat'] = location_cache[key]['lat']
server['lon'] = location_cache[key]['lon']
with processing_lock:
processed_servers.append(server)
continue
coords = get_coordinates(city, country)
if coords:
server['lat'] = coords['lat']
server['lon'] = coords['lon']
with processing_lock:
processed_servers.append(server)
changes_made = True
print("Background update finished.", flush=True)
except Exception as e:
print(f"Error in background update: {e}")
update_thread = threading.Thread(target=update_servers_background, daemon=True)
update_thread.start()
@app.route('/')
def index():
return send_from_directory('static', 'index.html')
@app.route('/api/servers')
def get_servers():
with processing_lock:
return jsonify(processed_servers)
@app.route('/api/update-locations', methods=['POST'])
def update_locations_endpoint():
try:
generate_locations()
load_cache()
threading.Thread(target=update_servers_background, daemon=True).start()
return jsonify({"message": "Locations updated successfully! Server list is refreshing."})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/test', methods=['POST'])
def run_test():
data = request.json
host = data.get('host')
port = data.get('port')
flags = data.get('flags', '')
if not host:
return jsonify({"error": "Host is required"}), 400
if any(c in host for c in [';', '&', '|', ' ']):
return jsonify({"error": "Invalid host format"}), 400
cmd = ["iperf3", "-c", host, "-p", str(port) if port else "5201"]
if flags:
if any(c in flags for c in [';', '&', '|']):
return jsonify({"error": "Invalid flags"}), 400
for flag in flags.split():
cmd.append(flag)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
output = result.stdout
if result.stderr:
output += "\n" + result.stderr
return jsonify({"output": output})
except subprocess.TimeoutExpired:
return jsonify({"error": "Test timed out"}), 504
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
load_cache()
app.run(host='0.0.0.0', port=5000, debug=True)
+6
View File
@@ -0,0 +1,6 @@
services:
iperf3-map:
build: .
ports:
- "5000:5000"
restart: unless-stopped
+106
View File
@@ -0,0 +1,106 @@
import json
import requests
import time
import geonamescache
import sys
from geopy.geocoders import Nominatim
CITY_ALIASES = {
"new york": "new york city",
"frankfurt": "frankfurt am main",
"st. louis": "saint louis",
"montreal": "montréal",
"reykjavik": "reykjavík",
"sao paulo": "são paulo",
"zurich": "zürich",
"quebec": "québec",
"washington": "washington, d.c."
}
COUNTRY_FIXES = {
"UK": "GB",
"USA": "US"
}
def generate_locations():
print("Fetching server list...")
url = "https://export.iperf3serverlist.net/listed_iperf3_servers.json"
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
servers = response.json()
except Exception as e:
print(f"Failed to fetch server list: {e}")
return
print(f"Found {len(servers)} servers.")
gc = geonamescache.GeonamesCache()
cities = gc.get_cities()
cities_by_name = {}
print("Loading offline database...")
for city_id, city_data in cities.items():
name = city_data['name'].lower()
country = city_data['countrycode']
key = f"{name}, {country}"
cities_by_name[key] = {
"lat": float(city_data['latitude']),
"lon": float(city_data['longitude'])
}
geolocator = Nominatim(user_agent="iperf3-map-generator-v2", timeout=10)
unique_keys = set()
for server in servers:
city = server.get('SITE')
country = server.get('COUNTRY')
if city and country:
if country in COUNTRY_FIXES:
country = COUNTRY_FIXES[country]
unique_keys.add((city, country))
print(f"Found {len(unique_keys)} unique locations to process.")
results = {}
for city, country in unique_keys:
key = f"{city}, {country}"
city_lower = city.lower()
search_city = CITY_ALIASES.get(city_lower, city_lower)
offline_key = f"{search_city}, {country}"
if offline_key in cities_by_name:
results[key] = cities_by_name[offline_key]
continue
try:
search_query = f"{search_city}, {country}"
print(f"Geocoding online: {search_query}...")
location = geolocator.geocode(search_query)
if location:
results[key] = {
"lat": location.latitude,
"lon": location.longitude
}
print(f"Found: {location.latitude}, {location.longitude}")
time.sleep(1.1)
else:
print(f"Not found online: {search_query}")
except Exception as e:
print(f"Error geocoding {key}: {e}")
print(f"Resolved {len(results)} locations.")
sorted_results = dict(sorted(results.items()))
with open('prefilled_locations.json', 'w', encoding='utf-8') as f:
json.dump(sorted_results, f, indent=2)
print("Saved to prefilled_locations.json")
if __name__ == "__main__":
sys.stdout = open('gen_log.txt', 'w', encoding='utf-8')
sys.stderr = sys.stdout
generate_locations()
+374
View File
@@ -0,0 +1,374 @@
{
"Accra, GH": {
"lat": 5.55602,
"lon": -0.1969
},
"Algiers, DZ": {
"lat": 36.73225,
"lon": 3.08746
},
"Amsterdam, NL": {
"lat": 52.37403,
"lon": 4.88969
},
"Arezzo, IT": {
"lat": 43.46276,
"lon": 11.88068
},
"Ashburn, US": {
"lat": 41.74753,
"lon": -87.71116
},
"Athens, GR": {
"lat": 37.98376,
"lon": 23.72784
},
"Atlanta, US": {
"lat": 33.749,
"lon": -84.38798
},
"Auckland, NZ": {
"lat": -36.84853,
"lon": 174.76349
},
"Banjul, GM": {
"lat": 13.45274,
"lon": -16.57803
},
"Bata, GQ": {
"lat": 1.86391,
"lon": 9.76582
},
"Belgrade, RS": {
"lat": 44.80401,
"lon": 20.46513
},
"Berlin, DE": {
"lat": 52.52437,
"lon": 13.41053
},
"Bissen, LU": {
"lat": 49.7891516,
"lon": 6.0662981
},
"Bogot\u00e1, CO": {
"lat": 4.60971,
"lon": -74.08175
},
"Boston, US": {
"lat": 42.35843,
"lon": -71.05977
},
"Bratislava, SK": {
"lat": 48.14816,
"lon": 17.10674
},
"Brussels, BE": {
"lat": 50.85045,
"lon": 4.34878
},
"Bucharest, RO": {
"lat": 44.43225,
"lon": 26.10626
},
"Cairo, EG": {
"lat": 30.06263,
"lon": 31.24967
},
"Cartago, CO": {
"lat": 4.74639,
"lon": -75.91167
},
"Charleston, US": {
"lat": 38.34982,
"lon": -81.63262
},
"Chicago, US": {
"lat": 41.85003,
"lon": -87.65005
},
"Christchurch, NZ": {
"lat": -43.53333,
"lon": 172.63333
},
"Col\u00f3n, PA": {
"lat": 9.36025,
"lon": -79.90019
},
"Copenhagen, DK": {
"lat": 55.67594,
"lon": 12.56553
},
"Curug, ID": {
"lat": -6.26583,
"lon": 106.55639
},
"Dallas, US": {
"lat": 44.91928,
"lon": -123.31705
},
"Dubai, AE": {
"lat": 25.07725,
"lon": 55.30927
},
"Dublin, IE": {
"lat": 53.33306,
"lon": -6.24889
},
"D\u00fcsseldorf, DE": {
"lat": 51.22172,
"lon": 6.77616
},
"Floreal, MU": {
"lat": -20.3110617,
"lon": 57.5134651
},
"Frankfurt, DE": {
"lat": 50.11552,
"lon": 8.68417
},
"Helsinki, FI": {
"lat": 60.16952,
"lon": 24.93545
},
"Hong Kong, HK": {
"lat": 22.27832,
"lon": 114.17469
},
"Houston, US": {
"lat": 29.76328,
"lon": -95.36327
},
"Hrdlo\u0159ezy, CZ": {
"lat": 50.4513933,
"lon": 14.8753972
},
"Istanbul, TR": {
"lat": 41.01384,
"lon": 28.94966
},
"Jakarta, ID": {
"lat": -6.21462,
"lon": 106.84513
},
"Jerusalem, IL": {
"lat": 31.76904,
"lon": 35.21633
},
"Johannesburg, ZA": {
"lat": -26.20227,
"lon": 28.04363
},
"Kampala, UG": {
"lat": 0.31628,
"lon": 32.58219
},
"Kista, SE": {
"lat": 59.4031236,
"lon": 17.9424221
},
"Kyiv, UA": {
"lat": 50.45466,
"lon": 30.5238
},
"Lisbon, PT": {
"lat": 38.72509,
"lon": -9.1498
},
"London, GB": {
"lat": 51.50853,
"lon": -0.12574
},
"Los Angeles, US": {
"lat": 34.05223,
"lon": -118.24368
},
"Luanda, AO": {
"lat": -8.83682,
"lon": 13.23432
},
"Madrid, ES": {
"lat": 40.4165,
"lon": -3.70256
},
"Marseille, FR": {
"lat": 43.29695,
"lon": 5.38107
},
"Melbourne, AU": {
"lat": -37.814,
"lon": 144.96332
},
"Miami, US": {
"lat": 25.77427,
"lon": -80.19366
},
"Milan, IT": {
"lat": 45.46427,
"lon": 9.18951
},
"Montreal, CA": {
"lat": 45.50884,
"lon": -73.58781
},
"Montr\u00e9al, CA": {
"lat": 45.50884,
"lon": -73.58781
},
"New York City, US": {
"lat": 40.71427,
"lon": -74.00597
},
"New York, US": {
"lat": 40.71427,
"lon": -74.00597
},
"Norderstedt, DE": {
"lat": 53.70177,
"lon": 9.99328
},
"Panam\u00e1, PA": {
"lat": 8.9936,
"lon": -79.51973
},
"Paris, FR": {
"lat": 48.85341,
"lon": 2.3488
},
"Phoenix, US": {
"lat": 33.44838,
"lon": -112.07404
},
"Ponte San Pietro, IT": {
"lat": 45.6977541,
"lon": 9.5872254
},
"Port Louis, MU": {
"lat": -20.16194,
"lon": 57.49889
},
"Port Moresby, PG": {
"lat": -9.47723,
"lon": 147.15089
},
"Portmore, JM": {
"lat": 17.97102,
"lon": -76.88691
},
"Porto, PT": {
"lat": 41.14961,
"lon": -8.61099
},
"Prague, CZ": {
"lat": 50.08804,
"lon": 14.42076
},
"Quer\u00e9taro, MX": {
"lat": 20.8052225,
"lon": -99.8837376
},
"Reykjavik, IS": {
"lat": 64.13548,
"lon": -21.89541
},
"San Francisco, US": {
"lat": 37.77493,
"lon": -122.41942
},
"San Jose, CR": {
"lat": 9.9327707,
"lon": -84.0796144
},
"Sandefjord, NO": {
"lat": 59.13118,
"lon": 10.21665
},
"Santa Ana, EC": {
"lat": -1.1944701,
"lon": -80.2631268
},
"Santiago, CL": {
"lat": -33.45694,
"lon": -70.64827
},
"Schaffhausen, CH": {
"lat": 47.69732,
"lon": 8.63493
},
"Seattle, US": {
"lat": 47.60621,
"lon": -122.33207
},
"Singapore, SG": {
"lat": 1.28967,
"lon": 103.85007
},
"Sofia, BG": {
"lat": 42.69751,
"lon": 23.32415
},
"Stockholm, SE": {
"lat": 59.32938,
"lon": 18.06871
},
"Sydney, AU": {
"lat": -33.86785,
"lon": 151.20732
},
"S\u00e3o Paulo, BR": {
"lat": -23.5475,
"lon": -46.63611
},
"Tashkent, UZ": {
"lat": 41.26465,
"lon": 69.21627
},
"Tel Aviv, IL": {
"lat": 32.08088,
"lon": 34.78057
},
"Tokyo, JP": {
"lat": 35.6895,
"lon": 139.69171
},
"Toronto, CA": {
"lat": 43.70643,
"lon": -79.39864
},
"Tunis, TN": {
"lat": 36.81897,
"lon": 10.16579
},
"Vienna, AT": {
"lat": 48.20849,
"lon": 16.37208
},
"Vitry-sur-Seine, FR": {
"lat": 48.78716,
"lon": 2.40332
},
"Warsaw, PL": {
"lat": 52.22977,
"lon": 21.01178
},
"Washington, US": {
"lat": 38.8950368,
"lon": -77.0365427
},
"Winterthur, CH": {
"lat": 47.50564,
"lon": 8.72413
},
"Wolfsburg, DE": {
"lat": 52.42452,
"lon": 10.7815
},
"Zagreb, HR": {
"lat": 45.81444,
"lon": 15.97798
},
"Z\u00fcrich, CH": {
"lat": 47.36667,
"lon": 8.55
}
}
+4
View File
@@ -0,0 +1,4 @@
flask
requests
geopy
geonamescache
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IPERF3 MAP - iperf3serverlist.net</title>
<link rel="icon" type="image/png" href="iperf3serverlist_256.png">
<link rel="apple-touch-icon" href="iperf3serverlist_256.png">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=2">
</head>
<body>
<div id="map-logo">
<img src="iperf3serverlist_256.png" alt="iPerf3 Server List Logo">
<div class="logo-text">
<div class="logo-title">iPERF3 MAP</div>
<div class="logo-subtitle">by iPERF3 SERVERLIST</div>
</div>
</div>
<div id="map"></div>
<div class="bottom-controls">
<button id="update-locations-btn" class="btn-floating" title="Update Locations">
<i class="fas fa-sync"></i>
</button>
<div class="footer-links">
<a href="https://github.com/R0GGER/public-iperf3-servers" target="_blank" class="footer-link">
<i class="fab fa-github"></i> GitHub
</a>
<a href="https://iperf3serverlist.net/" target="_blank" class="footer-link">
<i class="fas fa-globe"></i> iperf3serverlist.net
</a>
</div>
</div>
<div id="loading-status" style="position: absolute; top: 10px; right: 10px; z-index: 1000; background: white; padding: 10px; border-radius: 5px; box-shadow: 0 0 5px rgba(0,0,0,0.3); display: none;">
Loading servers... <span id="server-count">0</span> found
</div>
<!-- Modal -->
<div id="testModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2 id="modal-title">Run Speedtest</h2>
<div id="modal-body">
<!-- Content injected by JS -->
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script src="script.js?v=2"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+289
View File
@@ -0,0 +1,289 @@
document.addEventListener('DOMContentLoaded', () => {
const map = L.map('map', { zoomControl: false }).setView([52.13, 5.29], 5);
L.control.zoom({ position: 'topright' }).addTo(map);
const openStreetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
openStreetMap.addTo(map);
const modal = document.getElementById('testModal');
const span = document.getElementsByClassName("close")[0];
const modalTitle = document.getElementById('modal-title');
const modalBody = document.getElementById('modal-body');
span.onclick = function() {
modal.style.display = "none";
}
window.onclick = function(event) {
if (event.target == modal) {
modal.style.display = "none";
}
}
const serversByLocation = {};
let markersLayer = L.layerGroup().addTo(map);
const loadingStatus = document.getElementById('loading-status');
const serverCountSpan = document.getElementById('server-count');
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(position => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
const userIcon = L.divIcon({
className: 'custom-div-icon',
html: "<div class='user-marker'>📍</div>",
iconSize: [30, 30],
iconAnchor: [15, 30]
});
L.marker([lat, lon], { icon: userIcon })
.addTo(map)
.openPopup();
}, error => {
console.log("Geolocation access denied or failed.", error);
});
}
function loadServers() {
fetch('/api/servers')
.then(response => response.json())
.then(servers => {
if (servers.length > 0) {
loadingStatus.style.display = 'block';
serverCountSpan.textContent = servers.length;
}
if (servers.length === 0) {
console.log("No servers yet, retrying...");
setTimeout(loadServers, 3000);
return;
}
markersLayer.clearLayers();
for (const key in serversByLocation) delete serversByLocation[key];
servers.forEach(server => {
if (server.lat && server.lon) {
const key = `${server.lat},${server.lon}`;
if (!serversByLocation[key]) {
serversByLocation[key] = {
lat: server.lat,
lon: server.lon,
city: server.SITE,
country: server.COUNTRY,
servers: []
};
}
serversByLocation[key].servers.push(server);
}
});
Object.values(serversByLocation).forEach(loc => {
const starIcon = L.divIcon({
className: 'custom-div-icon',
html: "<div class='star-marker'>★</div>",
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([loc.lat, loc.lon], { icon: starIcon });
const container = document.createElement('div');
const title = document.createElement('h3');
title.textContent = `${loc.city}, ${loc.country}`;
container.appendChild(title);
const list = document.createElement('div');
loc.servers.forEach(server => {
const item = document.createElement('div');
item.className = 'server-list-item';
const info = document.createElement('span');
info.textContent = `${server.PROVIDER} (${server['IP/HOST']})`;
const btn = document.createElement('button');
btn.className = 'btn';
btn.textContent = 'Select';
btn.onclick = () => openTestModal(server);
item.appendChild(info);
item.appendChild(btn);
list.appendChild(item);
});
container.appendChild(list);
marker.bindPopup(container);
markersLayer.addLayer(marker);
});
if (servers.length < 100) {
setTimeout(loadServers, 5000);
} else {
loadingStatus.textContent = `Loaded ${servers.length} servers.`;
setTimeout(() => { loadingStatus.style.display = 'none'; }, 3000);
}
})
.catch(err => console.error('Error loading servers:', err));
}
loadServers();
const updateBtn = document.getElementById('update-locations-btn');
if (updateBtn) {
updateBtn.onclick = () => {
if (!confirm("Update locations? This will fetch the latest server list and regenerate the location database. It may take a while.")) return;
const icon = updateBtn.querySelector('i');
icon.classList.add('fa-spin');
updateBtn.disabled = true;
fetch('/api/update-locations', { method: 'POST' })
.then(res => res.json())
.then(data => {
if (data.error) {
alert("Error: " + data.error);
} else {
alert(data.message);
location.reload();
}
})
.catch(err => alert("Error: " + err))
.finally(() => {
icon.classList.remove('fa-spin');
updateBtn.disabled = false;
});
};
}
function openTestModal(server) {
modal.style.display = "block";
modalTitle.textContent = `Test to ${server.SITE} (${server['IP/HOST']})`;
let ports = [];
if (server.PORT) {
if (server.PORT.includes('-')) {
const parts = server.PORT.split('-');
const start = parseInt(parts[0]);
const end = parseInt(parts[1]);
for (let p = start; p <= end; p++) {
ports.push(p);
if (ports.length > 20) break;
}
} else if (server.PORT.includes(',')) {
ports = server.PORT.split(',').map(p => parseInt(p.trim()));
} else {
ports = [parseInt(server.PORT)];
}
} else {
ports = [5201];
}
let portOptions = ports.map(p => `<option value="${p}">${p}</option>`).join('');
modalBody.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 5px;">
<strong>Provider:</strong> ${server.PROVIDER}
</div>
<div style="margin-bottom: 5px;">
<strong>Host:</strong> ${server['IP/HOST']}
</div>
<div style="margin-bottom: 10px; margin-top: 10px;">
<label for="port-select"><strong>Port:</strong></label>
<select id="port-select" class="form-control" style="padding: 5px;">
${portOptions}
</select>
</div>
<div style="margin-bottom: 10px;">
<label><strong>Options:</strong></label><br>
<div style="margin-top: 5px;">
<input type="checkbox" id="opt-reverse" checked>
<label for="opt-reverse">Reverse Mode (-R) <span style="color:#666; font-size:0.9em;">(Download test)</span></label>
</div>
<div style="margin-top: 5px;">
<input type="checkbox" id="opt-ipv6">
<label for="opt-ipv6">IPv6 (-6)</label>
</div>
<div style="margin-top: 5px;">
<label for="opt-custom">Custom flags:</label>
<input type="text" id="opt-custom" placeholder="e.g. -u -b 10M" style="padding: 4px; width: 60%;">
</div>
<div style="margin-top: 5px; font-size: 0.85em; color: #666;">
Server suggested options: ${server.OPTIONS || 'None'}
</div>
</div>
</div>
<button id="start-test-btn" class="btn" style="font-size: 1.1em; padding: 8px 16px;">Run iperf3 Test</button>
<div id="test-status" style="margin-top: 15px;"></div>
<div id="test-output" style="display:none;"></div>
`;
const startBtn = document.getElementById('start-test-btn');
const statusDiv = document.getElementById('test-status');
const outputDiv = document.getElementById('test-output');
const portSelect = document.getElementById('port-select');
const optReverse = document.getElementById('opt-reverse');
const optIPv6 = document.getElementById('opt-ipv6');
const optCustom = document.getElementById('opt-custom');
startBtn.onclick = () => {
startBtn.disabled = true;
statusDiv.innerHTML = '<span class="loader"></span> Running test... (this may take 10-20 seconds)';
outputDiv.style.display = 'none';
outputDiv.textContent = '';
const selectedPort = parseInt(portSelect.value);
let flags = "";
if (optReverse.checked) flags += " -R";
if (optIPv6.checked) flags += " -6";
if (optCustom.value) flags += " " + optCustom.value;
fetch('/api/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
host: server['IP/HOST'],
port: selectedPort,
flags: flags.trim()
})
})
.then(response => response.json())
.then(data => {
startBtn.disabled = false;
statusDiv.innerHTML = '';
outputDiv.style.display = 'block';
if (data.error) {
outputDiv.textContent = "Error: " + data.error;
outputDiv.style.color = 'red';
} else {
outputDiv.textContent = data.output;
outputDiv.style.color = '#00ff00';
}
})
.catch(err => {
startBtn.disabled = false;
statusDiv.innerHTML = '';
outputDiv.style.display = 'block';
outputDiv.textContent = "Network/Server Error: " + err;
outputDiv.style.color = 'red';
});
};
}
});
+224
View File
@@ -0,0 +1,224 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
#map {
height: 100vh;
width: 100%;
}
#map-logo {
position: absolute;
top: 20px;
left: 20px;
z-index: 1000;
display: flex;
align-items: center;
background-color: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
#map-logo img {
width: 40px;
height: auto;
margin-right: 10px;
}
.logo-text {
display: flex;
flex-direction: column;
}
.logo-title {
font-weight: 900;
font-size: 16px;
color: #333;
line-height: 1.2;
}
.logo-subtitle {
font-weight: bold;
font-size: 12px;
color: #666;
letter-spacing: 0.5px;
}
.star-marker {
font-size: 30px;
color: #FFD700; /* Gold */
text-shadow: 0 0 3px #000;
text-align: center;
line-height: 30px;
cursor: pointer;
}
.user-marker {
font-size: 30px;
color: #ff0000; /* Red for user */
text-shadow: 0 0 3px #fff;
text-align: center;
line-height: 30px;
cursor: pointer;
}
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 10% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 800px;
border-radius: 8px;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.server-list-item {
padding: 10px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.server-list-item:last-child {
border-bottom: none;
}
.btn {
padding: 5px 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn:hover {
background-color: #0056b3;
}
.btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
#test-output {
background-color: #1e1e1e;
color: #00ff00;
padding: 15px;
font-family: monospace;
white-space: pre-wrap;
margin-top: 20px;
height: 300px;
overflow-y: auto;
border-radius: 4px;
}
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 2s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Bottom Controls */
.bottom-controls {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 1000;
display: flex;
align-items: center;
gap: 15px;
}
/* Floating Action Button */
.btn-floating {
/* Removed fixed positioning as it's now handled by .bottom-controls */
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #007bff;
color: white;
border: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
}
.btn-floating:hover {
background-color: #0056b3;
}
.btn-floating:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.footer-links {
display: flex;
gap: 15px;
background-color: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
border-radius: 25px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.footer-link {
text-decoration: none;
color: #333;
font-weight: bold;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.footer-link:hover {
color: #007bff;
}