diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index 3c2c6c3..9013fcf 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -126,7 +126,7 @@ services: |-----|--------| | **REST** (GET `/version`, `/proxies`, `/connections`, POST к API контроллера и т.д.) | Тот же путь, что и прокси к Telemt: `http.NewRequest` + `RoundTrip` (`internal/proxy/forward.go`, общая логика `newAliasForward`). Так совпадает с рабочим `curl` к upstream. | | **Не** единый `httputil.ReverseProxy` на весь Mihomo | Для обычного HTTP `ReverseProxy` нередко даёт **400** у строгих upstream при том, что прямой запрос работает. | -| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** для графиков скорости и памяти использует **потоковый HTTP GET** к тем же путям (как в Mihomo без `Upgrade`): так же проходит через шлюз, что и `/connections`, без WebSocket за nginx. | +| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** опрашивает `/traffic` и `/memory` короткими **HTTP GET** (первая JSON-строка за запрос), чтобы запросы были видны в Network и стабильно проходили за прокси; те же пути, что и для REST. | | Заголовок **Host** | Не пересылается с клиента; на upstream уходит authority из `mihomo_base_url` (как для Telemt и `base_url`). | Проверки конфигурации: diff --git a/web/src/lib/api/mihomo-types.ts b/web/src/lib/api/mihomo-types.ts index 7675b11..a02933b 100644 --- a/web/src/lib/api/mihomo-types.ts +++ b/web/src/lib/api/mihomo-types.ts @@ -8,6 +8,8 @@ export type MihomoProxyEntry = { name?: string; now?: string; all?: string[]; + /** URL для healthcheck / GET …/delay (если задан в конфиге группы). */ + testUrl?: string; history?: { time: string; delay: number }[]; udp?: boolean; [key: string]: unknown; diff --git a/web/src/routes/servers/[alias]/mihomo/+page.svelte b/web/src/routes/servers/[alias]/mihomo/+page.svelte index b1c1152..69b6baa 100644 --- a/web/src/routes/servers/[alias]/mihomo/+page.svelte +++ b/web/src/routes/servers/[alias]/mihomo/+page.svelte @@ -46,6 +46,9 @@ let topList = $state<{ name: string; n: number }[]>([]); const MAX_POINTS = 72; + /** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */ + const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204'; + let lastTick = $state(null); let pollTimer: ReturnType | null = null; @@ -97,28 +100,54 @@ } } + function delayQuery(testUrl?: string) { + const u = + testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL; + return `timeout=5000&url=${encodeURIComponent(u)}`; + } + + function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('aborted', 'AbortError')); + return; + } + const t = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + function onAbort() { + clearTimeout(t); + reject(new DOMException('aborted', 'AbortError')); + } + signal.addEventListener('abort', onAbort); + }); + } + /** - * Потоковый GET /traffic и /memory (как в hub/route/server.go Mihomo: без Upgrade — chunked JSON/s). - * Работает через тот же HTTP-прокси, что и /connections (Authorization на шлюзе). - * WebSocket с браузера часто рвётся за nginx или без заголовков — метрики остаются нулями. + * Короткий GET /traffic: читаем первую JSON-строку и закрываем соединение. + * Долгий chunked-стрим часто не виден в Network за прокси; такие запросы повторяются и стабильнее. */ - async function pumpMihomoTrafficStream(a: string, signal: AbortSignal) { + async function readOneTrafficSample(a: string, signal: AbortSignal) { + let res: Response; + try { + res = await fetch(mihomoUrl(a, 'traffic'), { signal, cache: 'no-store' }); + } catch { + return; + } + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; try { - const res = await fetch(mihomoUrl(a, 'traffic'), { signal }); - if (!res.ok || !res.body) return; - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; while (!signal.aborted) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); - for (;;) { - const nl = buf.indexOf('\n'); - if (nl < 0) break; - const line = buf.slice(0, nl).trim(); - buf = buf.slice(nl + 1); - if (!line) continue; + const nl = buf.indexOf('\n'); + if (nl < 0) continue; + const line = buf.slice(0, nl).trim(); + if (line) { try { const o = JSON.parse(line) as { up?: number; @@ -137,29 +166,37 @@ /* ignore line */ } } + break; + } + } finally { + try { + await reader.cancel(); + } catch { + /* ignore */ } - } catch { - /* aborted or network */ } } - async function pumpMihomoMemoryStream(a: string, signal: AbortSignal) { + async function readOneMemorySample(a: string, signal: AbortSignal) { + let res: Response; + try { + res = await fetch(mihomoUrl(a, 'memory'), { signal, cache: 'no-store' }); + } catch { + return; + } + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; try { - const res = await fetch(mihomoUrl(a, 'memory'), { signal }); - if (!res.ok || !res.body) return; - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; while (!signal.aborted) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); - for (;;) { - const nl = buf.indexOf('\n'); - if (nl < 0) break; - const line = buf.slice(0, nl).trim(); - buf = buf.slice(nl + 1); - if (!line) continue; + const nl = buf.indexOf('\n'); + if (nl < 0) continue; + const line = buf.slice(0, nl).trim(); + if (line) { try { const o = JSON.parse(line) as { inuse?: number }; memKB = Number(o.inuse) || 0; @@ -169,20 +206,43 @@ /* ignore */ } } + break; + } + } finally { + try { + await reader.cancel(); + } catch { + /* ignore */ } - } catch { - /* aborted or network */ } } - async function runTrafficMemoryStreams(a: string, signal: AbortSignal) { + async function pollTrafficLoop(a: string, signal: AbortSignal) { while (!signal.aborted) { - await Promise.all([pumpMihomoTrafficStream(a, signal), pumpMihomoMemoryStream(a, signal)]); - if (signal.aborted) break; - await new Promise((r) => setTimeout(r, 3000)); + await readOneTrafficSample(a, signal); + try { + await sleep(1250, signal); + } catch { + break; + } } } + async function pollMemoryLoop(a: string, signal: AbortSignal) { + while (!signal.aborted) { + await readOneMemorySample(a, signal); + try { + await sleep(1250, signal); + } catch { + break; + } + } + } + + function runTrafficMemoryPollers(a: string, signal: AbortSignal) { + void Promise.all([pollTrafficLoop(a, signal), pollMemoryLoop(a, signal)]); + } + function topProxyCounts(conns: MihomoConnectionsResponse['connections']): { name: string; n: number }[] { const m = new Map(); for (const c of conns ?? []) { @@ -233,7 +293,7 @@ histMem = []; void loadMeta(a); const streamAc = new AbortController(); - void runTrafficMemoryStreams(a, streamAc.signal); + runTrafficMemoryPollers(a, streamAc.signal); if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => void pollConnections(a), 2000); void pollConnections(a); @@ -300,7 +360,11 @@ if (!a) return; testing = name; try { - await fetchMihomoJson(a, `proxies/${encodeURIComponent(name)}/delay?timeout=5000`); + const testUrl = proxiesData?.proxies?.[name]?.testUrl; + await fetchMihomoJson( + a, + `proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}` + ); await loadProxies(); } catch (e) { proxiesErr = e instanceof ApiError ? e.message : String(e); @@ -313,15 +377,20 @@ const a = alias; if (!a) return; testingGroup = groupName; + const groupTestUrl = proxiesData?.proxies?.[groupName]?.testUrl; try { - await fetchMihomoJson(a, `group/${encodeURIComponent(groupName)}/delay?timeout=5000`); + await fetchMihomoJson( + a, + `group/${encodeURIComponent(groupName)}/delay?${delayQuery(groupTestUrl)}` + ); await loadProxies(); } catch { try { const g = proxiesData?.proxies?.[groupName]; const names = g?.all ?? []; for (const n of names) { - await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?timeout=5000`); + const u = proxiesData?.proxies?.[n]?.testUrl; + await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?${delayQuery(u)}`); } await loadProxies(); } catch (e) {