mirror of
https://github.com/R0GGER/iperf3-map.git
synced 2026-11-11 01:20:06 +01:00
Add files via upload
This commit is contained in:
1 parent
23f71e8716
commit
db8af1f016
13 files changed
+1377
-2
No files matched your search
+17
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
iperf3-map:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
restart: unless-stopped
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
flask
|
||||
requests
|
||||
geopy
|
||||
geonamescache
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 793 KiB |
@@ -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">×</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 |
@@ -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: '© <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';
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user