Commit 0f44225e authored by Vitaly Lipatov's avatar Vitaly Lipatov

web-api: stream speed test results via SSE for live display

Replace batch speed check with Server-Sent Events. Gateway check results appear immediately, then speed test results stream in one by one as each gateway is tested. Colors applied after all tests complete. Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c5b4e57f
...@@ -383,26 +383,6 @@ def check_site(domain, file_url=None): ...@@ -383,26 +383,6 @@ def check_site(domain, file_url=None):
if result: if result:
throttle[gw_name] = result throttle[gw_name] = result
# Phase 3: speed check — sequential, stop after two fast results in a row
speed = {}
if file_url:
gw_by_name = {name: proxy for name, proxy, _pv6 in CHECK_GATEWAYS}
consecutive_ok = 0
for gw_name in SPEED_CHECK_ORDER:
proxy = gw_by_name.get(gw_name)
if proxy is None:
continue
_name, res = _check_speed(gw_name, proxy, file_url)
if res:
speed[gw_name] = res
# "success" = downloaded enough (not timeout/error with tiny size)
if res and not res.get("error") and res.get("time", 99) < SPEED_CHECK_TIMEOUT - 0.5:
consecutive_ok += 1
if consecutive_ok >= 2:
break
else:
consecutive_ok = 0
ips = resolve_domain(domain) ips = resolve_domain(domain)
routes = find_in_routes(domain, ips) routes = find_in_routes(domain, ips)
result = { result = {
...@@ -413,11 +393,30 @@ def check_site(domain, file_url=None): ...@@ -413,11 +393,30 @@ def check_site(domain, file_url=None):
result["file_url"] = 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
def run_speed_test(file_url, callback):
"""Run sequential speed test, calling callback(gw_name, result) for each.
Stops after two consecutive fast downloads.
"""
gw_by_name = {name: proxy for name, proxy, _pv6 in CHECK_GATEWAYS}
consecutive_ok = 0
for gw_name in SPEED_CHECK_ORDER:
proxy = gw_by_name.get(gw_name)
if proxy is None:
continue
_name, res = _check_speed(gw_name, proxy, file_url)
callback(gw_name, res)
if res and not res.get("error") and res.get("time", 99) < SPEED_CHECK_TIMEOUT - 0.5:
consecutive_ok += 1
if consecutive_ok >= 2:
break
else:
consecutive_ok = 0
# domain, IPv4, IPv4/CIDR, IPv6, IPv6/CIDR # domain, IPv4, IPv4/CIDR, IPv6, IPv6/CIDR
DOMAIN_RE = re.compile( DOMAIN_RE = re.compile(
r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+" r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+"
...@@ -1186,32 +1185,6 @@ function renderCheck(data) { ...@@ -1186,32 +1185,6 @@ function renderCheck(data) {
container.appendChild(tsec); container.appendChild(tsec);
} }
// Speed test (file URL)
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');
...@@ -1229,8 +1202,7 @@ async function checkDomain() { ...@@ -1229,8 +1202,7 @@ async function checkDomain() {
if (!v) return; if (!v) return;
const btn = $('btn-check'); const btn = $('btn-check');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Проверка\\u2026'; btn.textContent = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u043a\\u0430\\u2026';
// Show block immediately with spinner
const hdr = $('check-domain'); const hdr = $('check-domain');
hdr.textContent = v + ' '; hdr.textContent = v + ' ';
const sp = document.createElement('span'); const sp = document.createElement('span');
...@@ -1239,11 +1211,88 @@ async function checkDomain() { ...@@ -1239,11 +1211,88 @@ async function checkDomain() {
$('check-gateways').textContent = ''; $('check-gateways').textContent = '';
$('check-result').className = 'check-result visible'; $('check-result').className = 'check-result visible';
try { try {
const d = await api('check', {domain: v}); const resp = await fetch('/api/check', {
renderCheck(d); method: 'POST',
} catch(e) {} headers: {'Content-Type': 'application/json'},
body: JSON.stringify({domain: v}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let checkData = null;
let speedSection = null;
let speedResults = {};
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += decoder.decode(value, {stream: true});
// Parse SSE events from buffer
let idx;
while ((idx = buf.indexOf('\\n\\n')) !== -1) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let evType = '', evData = '';
for (const line of block.split('\\n')) {
if (line.startsWith('event: ')) evType = line.slice(7);
else if (line.startsWith('data: ')) evData = line.slice(6);
}
if (!evData) continue;
const parsed = JSON.parse(evData);
if (evType === 'check') {
checkData = parsed;
renderCheck(parsed);
// Prepare speed section if file_url present
if (parsed.file_url) {
speedSection = mkDiv('check-section');
const fname = parsed.file_url.split('/').pop().split('?')[0];
speedSection.appendChild(mkDiv('check-section-title',
'\\u0421\\u043a\\u043e\\u0440\\u043e\\u0441\\u0442\\u044c \\u0441\\u043a\\u0430\\u0447\\u0438\\u0432\\u0430\\u043d\\u0438\\u044f (' + fname + ')'));
$('check-gateways').appendChild(speedSection);
}
} else if (evType === 'speed' && speedSection) {
const gw = parsed.gateway;
const info = parsed.result;
speedResults[gw] = info;
const el = mkDiv('check-route', '');
if (info.error) {
el.textContent = gw + ': ' + info.error;
el.style.color = '#999';
} else {
el.textContent = gw + ': ' + info.speed_str + ' (' + info.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + info.time + '\\u0441)';
}
el.id = 'speed-' + gw;
speedSection.appendChild(el);
} else if (evType === 'done') {
// Recolor speed rows based on max
let maxSpd = 0;
for (const r of Object.values(speedResults)) { if (r.speed > maxSpd) maxSpd = r.speed; }
for (const [gw, r] of Object.entries(speedResults)) {
const el = document.getElementById('speed-' + gw);
if (!el || r.error) continue;
if (r.speed < maxSpd / 10) { el.style.background = '#f8d7da'; el.style.color = '#721c24'; }
else if (r.speed >= maxSpd * 0.5) { el.style.background = '#d4edda'; el.style.color = '#155724'; }
}
// Update gateway statuses with SLOW
if (checkData) {
const container = $('check-gateways');
const gwSpans = container.querySelectorAll('.check-gw');
gwSpans.forEach(span => {
const name = span.textContent.split(':')[0].trim();
const r = speedResults[name];
if (r && !r.error && maxSpd > 0 && r.speed < maxSpd / 10 && span.classList.contains('ok')) {
span.textContent = name + ': SLOW (' + r.speed_str + ')';
span.className = 'check-gw slow';
} else if (r && !r.error && span.classList.contains('ok')) {
span.textContent = name + ': OK (' + r.speed_str + ')';
}
});
}
}
}
}
} catch(e) { console.error(e); }
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Проверить'; btn.textContent = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u0438\\u0442\\u044c';
} }
$('domain').addEventListener('keydown', e => { $('domain').addEventListener('keydown', e => {
...@@ -1656,6 +1705,12 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1656,6 +1705,12 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.log_message("MOVE %s: %s -> %s", domain, src, dst) self.log_message("MOVE %s: %s -> %s", domain, src, dst)
self.send_json({"ok": True, "domain": domain, "from": src, "to": dst}) self.send_json({"ok": True, "domain": domain, "from": src, "to": dst})
def send_sse(self, event, data):
"""Send one SSE event."""
line = "event: %s\ndata: %s\n\n" % (event, json.dumps(data, ensure_ascii=False))
self.wfile.write(line.encode("utf-8"))
self.wfile.flush()
def handle_check(self, data): def handle_check(self, data):
raw = data.get("domain", "").strip() raw = data.get("domain", "").strip()
# Detect file URL: has path with extension (not just domain) # Detect file URL: has path with extension (not just domain)
...@@ -1678,8 +1733,22 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1678,8 +1733,22 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
try: try:
self.log_message("CHECK %s%s", domain, self.log_message("CHECK %s%s", domain,
" file=%s" % file_url if file_url else "") " file=%s" % file_url if file_url else "")
# SSE stream: first send check result, then speed events
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
result = check_site(domain, file_url=file_url) result = check_site(domain, file_url=file_url)
self.send_json(result) self.send_sse("check", result)
if file_url:
def on_speed(gw_name, res):
self.send_sse("speed", {"gateway": gw_name, "result": res})
run_speed_test(file_url, on_speed)
self.send_sse("done", {})
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