diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index 44386ef..3c2c6c3 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -112,7 +112,7 @@ services: и в `config.yaml` для нужного сервера: `mihomo_base_url_env: MIHOMO_CONTROLLER_URL`, `mihomo_authorization_env: TELEMT_MIHOMO_AUTH`. -За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`. +За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`. Панель Mihomo тянет трафик и память **потоковым HTTP** (не WS); при задержке или «залипании» графиков за nginx включите для upstream шлюза `proxy_buffering off` (или эквивалент), чтобы не буферизовать длинный ответ. @@ -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`, …) | Отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). | +| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** для графиков скорости и памяти использует **потоковый HTTP GET** к тем же путям (как в Mihomo без `Upgrade`): так же проходит через шлюз, что и `/connections`, без WebSocket за nginx. | | Заголовок **Host** | Не пересылается с клиента; на upstream уходит authority из `mihomo_base_url` (как для Telemt и `base_url`). | Проверки конфигурации: diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 2d97721..6ecaf45 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -20,7 +20,10 @@ export function mihomoUrl(alias: string, path: string): string { return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`; } -/** WebSocket к тому же origin (ws / wss). */ +/** + * WebSocket к тому же origin (ws / wss). + * Для /traffic и /memory во фронте предпочтительнее потоковый GET через `mihomoUrl` — тот же прокси, что и для REST; WS из браузера часто рвётся за nginx. + */ export function mihomoWsUrl(alias: string, path: string): string { const p = path.replace(/^\/+/, ''); const base = gatewayBase(); diff --git a/web/src/routes/servers/[alias]/mihomo/+page.svelte b/web/src/routes/servers/[alias]/mihomo/+page.svelte index 0d13fff..b1c1152 100644 --- a/web/src/routes/servers/[alias]/mihomo/+page.svelte +++ b/web/src/routes/servers/[alias]/mihomo/+page.svelte @@ -7,7 +7,7 @@ fetchMihomoJson, fetchMihomoMeta, mihomoPut, - mihomoWsUrl + mihomoUrl } from '$lib/api/client.js'; import type { MihomoConnectionsResponse, @@ -48,8 +48,6 @@ const MAX_POINTS = 72; let lastTick = $state(null); let pollTimer: ReturnType | null = null; - let wsTraffic: WebSocket | null = null; - let wsMemory: WebSocket | null = null; let proxiesLoading = $state(false); let proxiesErr = $state(null); @@ -58,8 +56,17 @@ let testing = $state(null); let testingGroup = $state(null); - function recordRates(now: number, up: number, down: number) { - if (lastTick != null) { + /** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */ + function recordRates( + now: number, + up: number, + down: number, + totals?: { upTotal: number; downTotal: number } + ) { + if (totals) { + totalUp = totals.upTotal; + totalDown = totals.downTotal; + } else if (lastTick != null) { const dt = (now - lastTick) / 1000; if (dt > 0 && dt < 5) { totalUp += up * dt; @@ -90,38 +97,89 @@ } } - function connectWs(a: string) { - if (!browser) return; - wsTraffic?.close(); - wsMemory?.close(); - lastTick = null; + /** + * Потоковый GET /traffic и /memory (как в hub/route/server.go Mihomo: без Upgrade — chunked JSON/s). + * Работает через тот же HTTP-прокси, что и /connections (Authorization на шлюзе). + * WebSocket с браузера часто рвётся за nginx или без заголовков — метрики остаются нулями. + */ + async function pumpMihomoTrafficStream(a: string, signal: AbortSignal) { try { - wsTraffic = new WebSocket(mihomoWsUrl(a, 'traffic')); - wsTraffic.onmessage = (ev) => { - try { - const o = JSON.parse(ev.data as string) as { up?: number; down?: number }; - recordRates(performance.now(), Number(o.up) || 0, Number(o.down) || 0); - } catch { - /* ignore */ + 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; + try { + const o = JSON.parse(line) as { + up?: number; + down?: number; + upTotal?: number; + downTotal?: number; + }; + const up = Number(o.up) || 0; + const down = Number(o.down) || 0; + const totals = + typeof o.upTotal === 'number' && typeof o.downTotal === 'number' + ? { upTotal: o.upTotal, downTotal: o.downTotal } + : undefined; + recordRates(performance.now(), up, down, totals); + } catch { + /* ignore line */ + } } - }; + } } catch { - /* ignore */ + /* aborted or network */ } + } + + async function pumpMihomoMemoryStream(a: string, signal: AbortSignal) { try { - wsMemory = new WebSocket(mihomoWsUrl(a, 'memory')); - wsMemory.onmessage = (ev) => { - try { - const o = JSON.parse(ev.data as string) as { inuse?: number }; - memKB = Number(o.inuse) || 0; - histMem = [...histMem, memKB]; - while (histMem.length > MAX_POINTS) histMem.shift(); - } catch { - /* ignore */ + 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; + try { + const o = JSON.parse(line) as { inuse?: number }; + memKB = Number(o.inuse) || 0; + histMem = [...histMem, memKB]; + while (histMem.length > MAX_POINTS) histMem.shift(); + } catch { + /* ignore */ + } } - }; + } } catch { - /* ignore */ + /* aborted or network */ + } + } + + async function runTrafficMemoryStreams(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)); } } @@ -164,20 +222,27 @@ $effect(() => { const a = alias; if (!a || !browser) return; + lastTick = null; + totalUp = 0; + totalDown = 0; + upBps = 0; + downBps = 0; + memKB = 0; + histUp = []; + histDown = []; + histMem = []; void loadMeta(a); - connectWs(a); + const streamAc = new AbortController(); + void runTrafficMemoryStreams(a, streamAc.signal); if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => void pollConnections(a), 2000); void pollConnections(a); return () => { + streamAc.abort(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } - wsTraffic?.close(); - wsMemory?.close(); - wsTraffic = null; - wsMemory = null; }; }); @@ -269,8 +334,6 @@ onDestroy(() => { if (pollTimer) clearInterval(pollTimer); - wsTraffic?.close(); - wsMemory?.close(); });