Commit 9467481c authored by Vitaly Lipatov's avatar Vitaly Lipatov

web-api: add file URL speed test across all gateways

When a file URL is entered (e.g. https://xpra.org/src/xpra-6.4.3.tar.xz), download it via curl through each gateway proxy in parallel and display speed comparison. Highlights slow gateways (<10% of max) in red and fast ones (>50% of max) in green. Also add dark mode CSS support. Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f48f3130
...@@ -274,7 +274,51 @@ def _check_throttle(name, proxy, asset_url): ...@@ -274,7 +274,51 @@ def _check_throttle(name, proxy, asset_url):
return (name, None) return (name, None)
def check_site(domain): SPEED_CHECK_TIMEOUT = 15
def _check_speed(name, proxy, file_url):
"""Download file via proxy, measure speed.
Returns (name, result_dict).
"""
try:
cmd = ["curl", "-4"]
if proxy:
cmd.extend(["--proxy", proxy])
cmd.extend(["-o", "/dev/null", "-s",
"-w", "%{size_download} %{speed_download} %{time_total} %{http_code}",
"-m", str(SPEED_CHECK_TIMEOUT), "-L", file_url])
result = subprocess.run(
cmd,
capture_output=True, text=True, timeout=SPEED_CHECK_TIMEOUT + 5,
)
parts = result.stdout.strip().split()
if len(parts) < 4:
return (name, {"error": "no data"})
size = int(float(parts[0]))
speed = float(parts[1])
elapsed = float(parts[2])
http_code = int(parts[3])
if http_code == 0:
return (name, {"error": "connect failed"})
if speed >= 1048576:
speed_str = "%.1f MB/s" % (speed / 1048576)
elif speed >= 1024:
speed_str = "%.0f KB/s" % (speed / 1024)
else:
speed_str = "%.0f B/s" % speed
return (name, {
"size": size, "speed": round(speed), "speed_str": speed_str,
"time": round(elapsed, 1), "http_code": http_code,
})
except subprocess.TimeoutExpired:
return (name, {"error": "timeout"})
except (OSError, ValueError) as e:
return (name, {"error": str(e)})
def check_site(domain, file_url=None):
"""Check domain: resolve IPs, find in route lists, whois, test gateways.""" """Check domain: resolve IPs, find in route lists, whois, test gateways."""
url = "https://%s/" % domain url = "https://%s/" % domain
...@@ -335,14 +379,31 @@ def check_site(domain): ...@@ -335,14 +379,31 @@ def check_site(domain):
if result: if result:
throttle[gw_name] = result throttle[gw_name] = result
# Phase 3: speed check — download file URL via all gateways in parallel
speed = {}
if file_url:
with concurrent.futures.ThreadPoolExecutor(max_workers=len(CHECK_GATEWAYS)) as pool:
speed_futures = [
pool.submit(_check_speed, name, proxy, file_url)
for name, proxy, _pv6 in CHECK_GATEWAYS
]
for future in concurrent.futures.as_completed(speed_futures):
gw_name, res = future.result()
if res:
speed[gw_name] = res
ips = resolve_domain(domain) ips = resolve_domain(domain)
routes = find_in_routes(domain, ips) routes = find_in_routes(domain, ips)
result = { result = {
"domain": domain, "ips": ips, "routes": routes, "domain": domain, "ips": ips, "routes": routes,
"whois": whois_info, "checks": checks, "whois": whois_info, "checks": checks,
} }
if file_url:
result["file_url"] = file_url
if throttle: if throttle:
result["throttle"] = throttle result["throttle"] = throttle
if speed:
result["speed"] = speed
return result return result
...@@ -731,6 +792,46 @@ HTML_PAGE = """\ ...@@ -731,6 +792,46 @@ HTML_PAGE = """\
.status.ok { display: block; background: #d4edda; color: #155724; } .status.ok { display: block; background: #d4edda; color: #155724; }
.status.err { display: block; background: #f8d7da; color: #721c24; } .status.err { display: block; background: #f8d7da; color: #721c24; }
.note { text-align: center; color: #999; font-size: 0.8em; margin-top: 16px; } .note { text-align: center; color: #999; font-size: 0.8em; margin-top: 16px; }
@media (prefers-color-scheme: dark) {
body { background: #1a1a2e; color: #e0e0e0; }
h1 { color: #e0e0e0; }
.subtitle { color: #999; }
.sidebar-section, .column, .check-result { background: #16213e; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
.sidebar-section h3 { color: #999; border-bottom-color: #2a2a4a; }
.sidebar-item { color: #ccc; }
.sidebar-item span.ip { color: #777; }
.health-gw .tag { color: #ccc; }
.health-gw .loss { color: #777; }
.input-row input { background: #16213e; border-color: #2a2a4a; color: #e0e0e0; }
.input-row input:focus { border-color: #4a90d9; }
.input-row input::placeholder { color: #666; }
.clear-btn, .clear-btn:hover { color: #777 !important; }
.dropdown, .ctx-menu { background: #16213e; border-color: #2a2a4a;
box-shadow: 0 2px 8px rgba(0,0,0,0.4); }
.dropdown-item, .ctx-menu-item { color: #ccc; }
.dropdown-item:hover, .ctx-menu-item:hover { background: #1a1a2e; }
.dropdown-item.danger, .ctx-menu-item.danger { color: #e74c3c; }
.dropdown-item.danger:hover, .ctx-menu-item.danger:hover { background: #2a1a1a; }
.column h2 { border-bottom-color: #2a2a4a; color: #e0e0e0; }
.col-bypass h2 { border-bottom-color: #e67e22; }
.col-direct h2 { border-bottom-color: #27ae60; }
.col-geo h2 { border-bottom-color: #8e44ad; }
.entry { border-bottom-color: #2a2a4a; }
.entry-domain { color: #ccc; }
.empty { color: #666; }
.status.ok { background: #1a3a2a; color: #6fcf97; }
.status.err { background: #3a1a1a; color: #f08080; }
.check-gw.ok { background: #1a3a2a; color: #6fcf97; }
.check-gw.slow, .check-gw.block { background: #3a1a1a; color: #f08080; }
.check-gw.proxy { background: #2a2a3a; color: #9ca3af; }
.check-gw.other { background: #3a3520; color: #e0c060; }
.check-section-title { color: #999; }
.check-ips { color: #ccc; }
.check-route { background: #1a2540; color: #6fa8f5; }
.check-no-route { color: #666; }
.check-whois { color: #aaa; }
.note { color: #666; }
}
</style> </style>
</head> </head>
<body> <body>
...@@ -1068,6 +1169,33 @@ function renderCheck(data) { ...@@ -1068,6 +1169,33 @@ function renderCheck(data) {
container.appendChild(tsec); container.appendChild(tsec);
} }
// Speed test (file URL)
const spd = data.speed || {};
if (Object.keys(spd).length) {
const ssec = mkDiv('check-section');
const fname = (data.file_url || '').split('/').pop().split('?')[0];
ssec.appendChild(mkDiv('check-section-title', '\\u0421\\u043a\\u043e\\u0440\\u043e\\u0441\\u0442\\u044c \\u0441\\u043a\\u0430\\u0447\\u0438\\u0432\\u0430\\u043d\\u0438\\u044f' + (fname ? ' (' + fname + ')' : '')));
const order = ['dgw', 'igw', 'warp', 'gre.hetzner', 'ikev2.hetzner', 'gre.vdska', 'gre.beget.ogw', 'ikev2.beget.ogw'];
// Find max speed for highlighting
let maxSpd = 0;
for (const v of Object.values(spd)) { if (v.speed > maxSpd) maxSpd = v.speed; }
for (const name of order) {
const info = spd[name];
if (!info) continue;
const el = mkDiv('check-route', '');
if (info.error) {
el.textContent = name + ': ' + info.error;
el.style.color = '#999';
} else {
el.textContent = name + ': ' + info.speed_str + ' (' + info.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + info.time + '\\u0441)';
if (info.speed < maxSpd / 10) { el.style.background = '#f8d7da'; el.style.color = '#721c24'; }
else if (info.speed >= maxSpd * 0.5) { el.style.background = '#d4edda'; el.style.color = '#155724'; }
}
ssec.appendChild(el);
}
container.appendChild(ssec);
}
// Whois // Whois
if (data.whois && data.whois.length) { if (data.whois && data.whois.length) {
const wsec = mkDiv('check-section'); const wsec = mkDiv('check-section');
...@@ -1513,7 +1641,17 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1513,7 +1641,17 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.send_json({"ok": True, "domain": domain, "from": src, "to": dst}) self.send_json({"ok": True, "domain": domain, "from": src, "to": dst})
def handle_check(self, data): def handle_check(self, data):
domain = extract_domain(data.get("domain", "")) raw = data.get("domain", "").strip()
# Detect file URL: has path with extension (not just domain)
file_url = None
if "://" in raw:
parsed = urllib.parse.urlparse(raw)
path = parsed.path.rstrip("/")
last = path.rsplit("/", 1)[-1] if path else ""
if "." in last:
file_url = raw
domain = extract_domain(raw)
if not domain: if not domain:
self.send_error_json(400, "Invalid domain or IP") self.send_error_json(400, "Invalid domain or IP")
return return
...@@ -1522,8 +1660,9 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1522,8 +1660,9 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.send_error_json(429, "Check already in progress") self.send_error_json(429, "Check already in progress")
return return
try: try:
self.log_message("CHECK %s", domain) self.log_message("CHECK %s%s", domain,
result = check_site(domain) " file=%s" % file_url if file_url else "")
result = check_site(domain, file_url=file_url)
self.send_json(result) self.send_json(result)
finally: finally:
_check_lock.release() _check_lock.release()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment