Commit a2d006a2 authored by Vitaly Lipatov's avatar Vitaly Lipatov

web-api: stream check results live via SSE instead of waiting for all

Split check_site into phases for SSE mode: 1. IPs + routes sent instantly (check event) 2. Each gateway result streamed as it completes (gateway event) 3. Whois streamed when ready (whois event) 4. Throttle results streamed per gateway (throttle event) This eliminates the 10-18s spinner delay before any results appear. JSON mode (/api/check without Accept: text/event-stream) unchanged. Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 1d7f931d
......@@ -1112,65 +1112,13 @@ function renderCheck(data) {
const container = $('check-gateways');
container.textContent = '';
// Two-column row: left = gateways, right = whois
// Two-column row: left = gateways (filled by SSE), right = info
const cols = mkDiv('check-columns');
const colGw = mkDiv('check-col');
colGw.id = 'check-col-gw';
const colInfo = mkDiv('check-col');
// Left column: Gateways (domain mode only, file_url mode fills via SSE)
if (data.checks && Object.keys(data.checks).length) {
const thr = data.throttle || {};
const spd = data.speed || {};
let maxSpd = 0;
for (const v of Object.values(spd)) { if (v.speed > maxSpd) maxSpd = v.speed; }
const gsec = mkDiv('check-section');
gsec.appendChild(mkDiv('check-section-title', '\\u0414\\u043e\\u0441\\u0442\\u0443\\u043f\\u043d\\u043e\\u0441\\u0442\\u044c \\u0447\\u0435\\u0440\\u0435\\u0437 \\u0448\\u043b\\u044e\\u0437\\u044b'));
const gwrap = document.createElement('div');
gwrap.className = 'check-gateways';
const order = ['dgw', 'igw', 'warp', 'gre.hetzner', 'ikev2.hetzner', 'gre.vdska', 'gre.beget.ogw', 'ikev2.beget.ogw'];
for (const name of order) {
const info = data.checks[name];
if (!info) continue;
let st4 = info.status;
const gwThr = thr[name];
if (gwThr && gwThr.throttled && st4 === 'OK') st4 = 'SLOW';
const gwSpd = spd[name];
if (gwSpd && !gwSpd.error && maxSpd > 0 && gwSpd.speed < maxSpd / 10 && st4 === 'OK') st4 = 'SLOW';
const cls4 = st4 === 'OK' ? 'ok' : st4 === 'SLOW' ? 'slow' : st4 === 'BLOCK' ? 'block' : st4 === 'PROXY?' ? 'proxy' : 'other';
const el4 = document.createElement('span');
el4.className = 'check-gw ' + cls4;
el4.textContent = name + ': ' + st4 + (gwSpd && !gwSpd.error ? ' (' + gwSpd.speed_str + ')' : '');
if (gwThr && gwThr.throttled) el4.title = gwThr.asset + ': ' + gwThr.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + gwThr.time + '\\u0441';
gwrap.appendChild(el4);
if (info.status_v6 && info.status_v6 !== 'PROXY?' && info.status_v6 !== 'NOIP') {
const cls6 = info.status_v6 === 'OK' ? 'ok' : info.status_v6 === 'BLOCK' ? 'block' : 'other';
const el6 = document.createElement('span');
el6.className = 'check-gw ' + cls6;
el6.textContent = name + '/v6: ' + info.status_v6;
gwrap.appendChild(el6);
}
}
gsec.appendChild(gwrap);
// Throttle detection inline
if (Object.keys(thr).length) {
const slowGws = Object.entries(thr).filter(([,v]) => v.throttled);
if (slowGws.length) {
gsec.appendChild(mkDiv('check-section-title', '\\u0417\\u0430\\u043c\\u0435\\u0434\\u043b\\u0435\\u043d\\u0438\\u0435 (\\u0422\\u0421\\u041f\\u0423)'));
for (const [gw, info] of slowGws) {
const msg = gw + ': ' + info.asset + ' \\u2014 ' + info.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + info.time + '\\u0441';
const el = mkDiv('check-route', msg);
el.style.background = '#f8d7da';
el.style.color = '#721c24';
gsec.appendChild(el);
}
}
}
colGw.appendChild(gsec);
}
// Right column: IPs + routes + whois
// Right column: IPs + routes (whois arrives via SSE)
if (data.ips && data.ips.length) {
const sec = mkDiv('check-section');
sec.appendChild(mkDiv('check-section-title', 'IP-\\u0430\\u0434\\u0440\\u0435\\u0441\\u0430'));
......@@ -1197,13 +1145,6 @@ function renderCheck(data) {
}
colInfo.appendChild(rsec);
if (data.whois && data.whois.length) {
const wsec = mkDiv('check-section');
wsec.appendChild(mkDiv('check-section-title', 'WHOIS'));
wsec.appendChild(mkDiv('check-whois', data.whois.join('\\n')));
colInfo.appendChild(wsec);
}
cols.appendChild(colGw);
cols.appendChild(colInfo);
container.appendChild(cols);
......@@ -1234,15 +1175,13 @@ async function checkDomain() {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let checkData = null;
let speedSection = null;
let speedWrap = null;
let speedResults = {};
let gwWrap = null; // gateway badge container (left column)
let gwResults = {}; // {name: {status, status_v6, http_code, ...}}
let speedResults = {}; // file_url mode speed results
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);
......@@ -1255,24 +1194,49 @@ async function checkDomain() {
if (!evData) continue;
const parsed = JSON.parse(evData);
if (evType === 'check') {
checkData = parsed;
renderCheck(parsed);
// file_url mode: create gateway section for speed results
if (parsed.file_url) {
speedSection = mkDiv('check-section');
speedSection.appendChild(mkDiv('check-section-title',
// Create gateway section in left column
const gsec = mkDiv('check-section');
gsec.appendChild(mkDiv('check-section-title',
'\\u0414\\u043e\\u0441\\u0442\\u0443\\u043f\\u043d\\u043e\\u0441\\u0442\\u044c \\u0447\\u0435\\u0440\\u0435\\u0437 \\u0448\\u043b\\u044e\\u0437\\u044b'));
speedWrap = document.createElement('div');
speedWrap.className = 'check-gateways';
speedSection.appendChild(speedWrap);
$('check-col-gw').appendChild(speedSection);
gwWrap = document.createElement('div');
gwWrap.className = 'check-gateways';
gsec.appendChild(gwWrap);
$('check-col-gw').appendChild(gsec);
} else if (evType === 'gateway' && gwWrap) {
// Domain mode: individual gateway result
const n = parsed.name;
if (!gwResults[n]) gwResults[n] = {};
if (parsed.ipver === '-4') {
gwResults[n].status = parsed.status;
gwResults[n].http_code = parsed.http_code;
} else {
gwResults[n].status_v6 = parsed.status;
gwResults[n].http_code_v6 = parsed.http_code;
}
} else if (evType === 'speed' && speedWrap) {
// Create or update badge
let el = document.getElementById('gw-' + n);
if (!el) {
el = document.createElement('span');
el.id = 'gw-' + n;
gwWrap.appendChild(el);
}
const info = gwResults[n];
const st = info.status || '\\u2026';
const cls = st === 'OK' ? 'ok' : st === 'BLOCK' ? 'block' : st === 'PROXY?' ? 'proxy' : st === '\\u2026' ? 'other' : 'other';
el.className = 'check-gw ' + cls;
let txt = n + ': ' + st;
if (info.status_v6 && info.status_v6 !== 'PROXY?' && info.status_v6 !== 'NOIP') {
txt += ' v6: ' + info.status_v6;
}
el.textContent = txt;
} else if (evType === 'speed' && gwWrap) {
// File URL mode: speed test result per gateway
const gw = parsed.gateway;
const info = parsed.result;
speedResults[gw] = info;
const el = document.createElement('span');
el.id = 'speed-' + gw;
el.id = 'gw-' + gw;
if (info.error) {
el.className = 'check-gw proxy';
el.textContent = gw + ': ' + info.error;
......@@ -1280,13 +1244,33 @@ async function checkDomain() {
el.className = 'check-gw other';
el.textContent = gw + ': ' + info.speed_str;
}
speedWrap.appendChild(el);
gwWrap.appendChild(el);
} else if (evType === 'whois') {
// Add whois to right column
const colInfo = document.querySelector('#check-gateways .check-col:last-child');
if (colInfo && parsed.whois && parsed.whois.length) {
const wsec = mkDiv('check-section');
wsec.appendChild(mkDiv('check-section-title', 'WHOIS'));
wsec.appendChild(mkDiv('check-whois', parsed.whois.join('\\n')));
colInfo.appendChild(wsec);
}
} else if (evType === 'throttle' && gwWrap) {
// Update gateway badge with SLOW
const gw = parsed.gateway;
const el = document.getElementById('gw-' + gw);
if (el && el.classList.contains('ok')) {
const r = parsed.result;
el.className = 'check-gw slow';
el.textContent = el.textContent.split(':')[0] + ': SLOW';
el.title = r.asset + ': ' + r.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + r.time + '\\u0441';
}
} else if (evType === 'done') {
// Recolor speed badges based on max speed
// File URL mode: recolor speed badges
if (Object.keys(speedResults).length) {
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);
const el = document.getElementById('gw-' + gw);
if (!el || r.error) continue;
if (maxSpd > 0 && r.speed < maxSpd / 10) {
el.className = 'check-gw slow';
......@@ -1299,6 +1283,7 @@ async function checkDomain() {
}
}
}
}
} catch(e) { console.error(e); }
btn.disabled = false;
btn.textContent = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u0438\\u0442\\u044c';
......@@ -1745,19 +1730,76 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
accept = self.headers.get("Accept", "")
if "text/event-stream" in accept:
# SSE stream (browser)
# SSE stream (browser) — send results as they arrive
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)
self.send_sse("check", result)
# Phase 0: quick data (IPs, routes) — instant
ips = resolve_domain(domain)
routes = find_in_routes(domain, ips)
base = {"domain": domain, "ips": ips, "routes": routes}
if file_url:
base["file_url"] = file_url
self.send_sse("check", base)
if file_url:
# File URL mode: whois + speed test
whois_info = get_whois(domain)
if whois_info:
self.send_sse("whois", {"whois": whois_info})
def on_speed(gw_name, res):
self.send_sse("speed", {"gateway": gw_name, "result": res})
run_speed_test(file_url, on_speed)
else:
# Domain mode: stream gateway checks
url = "https://%s/" % domain
has_v6 = False
try:
socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
has_v6 = True
except socket.gaierror:
pass
workers = len(CHECK_GATEWAYS) * 2 + 2
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
gw_futures = {}
for name, proxy_v4, proxy_v6 in CHECK_GATEWAYS:
f4 = pool.submit(_check_one, name, proxy_v4, url, "-4")
gw_futures[f4] = True
if has_v6 and proxy_v6:
f6 = pool.submit(_check_one, name, proxy_v6, url, "-6")
gw_futures[f6] = True
whois_future = pool.submit(get_whois, domain)
assets_future = pool.submit(_find_assets, CHECK_GATEWAYS[0][1], url)
for future in concurrent.futures.as_completed(gw_futures):
name, ipver, status, code = future.result()
self.send_sse("gateway", {
"name": name, "ipver": ipver,
"status": status, "http_code": code,
})
whois_info = whois_future.result()
if whois_info:
self.send_sse("whois", {"whois": whois_info})
assets = assets_future.result()
# Phase 2: throttle detection
if assets:
asset_url = assets[0]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(CHECK_GATEWAYS)) as pool:
throttle_futures = [
pool.submit(_check_throttle, name, proxy, asset_url)
for name, proxy, _pv6 in CHECK_GATEWAYS
]
for future in concurrent.futures.as_completed(throttle_futures):
gw_name, result = future.result()
if result and result.get("throttled"):
self.send_sse("throttle", {
"gateway": gw_name, "result": result,
})
self.send_sse("done", {})
else:
......
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