diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1172f67 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md index 64031c4..ac62567 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ -# iPERF3-MAP -isualizes iPerf3 servers on a global map using Leaflet.js, allowing you to initiate speed tests directly from the map markers. +# IPERF3 MAP + +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. + +iPerf3 Server Map iPerf3 Server Map + + +- **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 + 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) + diff --git a/app.py b/app.py new file mode 100644 index 0000000..c4be984 --- /dev/null +++ b/app.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6aaa7ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + iperf3-map: + build: . + ports: + - "5000:5000" + restart: unless-stopped \ No newline at end of file diff --git a/generate_locations.py b/generate_locations.py new file mode 100644 index 0000000..1bd8a62 --- /dev/null +++ b/generate_locations.py @@ -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() diff --git a/prefilled_locations.json b/prefilled_locations.json new file mode 100644 index 0000000..84675bc --- /dev/null +++ b/prefilled_locations.json @@ -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 + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..abcc404 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +requests +geopy +geonamescache diff --git a/screenshots/1.png b/screenshots/1.png new file mode 100644 index 0000000..25fc257 Binary files /dev/null and b/screenshots/1.png differ diff --git a/screenshots/2.png b/screenshots/2.png new file mode 100644 index 0000000..4bcf457 Binary files /dev/null and b/screenshots/2.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ffc32a2 --- /dev/null +++ b/static/index.html @@ -0,0 +1,62 @@ + + + + + + IPERF3 MAP - iperf3serverlist.net + + + + + + + + + + +
+ +
+ + +
+ + + + + + + + + + + diff --git a/static/iperf3serverlist_256.png b/static/iperf3serverlist_256.png new file mode 100644 index 0000000..19b4b26 Binary files /dev/null and b/static/iperf3serverlist_256.png differ diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..42e634c --- /dev/null +++ b/static/script.js @@ -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: '© OpenStreetMap 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: "
📍
", + 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: "
★
", + 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 => ``).join(''); + + modalBody.innerHTML = ` +
+
+ Provider: ${server.PROVIDER} +
+
+ Host: ${server['IP/HOST']} +
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Server suggested options: ${server.OPTIONS || 'None'} +
+
+ +
+ +
+ + `; + + 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 = ' 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'; + }); + }; + } +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..039afd9 --- /dev/null +++ b/static/style.css @@ -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; +}