diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index 9013fcf..38541c0 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`). **Веб-панель** опрашивает `/traffic` и `/memory` короткими **HTTP GET** (первая JSON-строка за запрос), чтобы запросы были видны в Network и стабильно проходили за прокси; те же пути, что и для REST. | +| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** использует нативный **WebSocket** к шлюзу для метрик и параллельно короткие **HTTP GET** (резерв); шлюз подставляет `Authorization` к Mihomo, токен в браузер не передаётся. | | Заголовок **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 6ecaf45..091abcc 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -21,8 +21,9 @@ export function mihomoUrl(alias: string, path: string): string { } /** - * WebSocket к тому же origin (ws / wss). - * Для /traffic и /memory во фронте предпочтительнее потоковый GET через `mihomoUrl` — тот же прокси, что и для REST; WS из браузера часто рвётся за nginx. + * WebSocket к шлюзу: `ws(s)://…/api/{alias}/mihomo/{path}`. + * Authorization к Mihomo подставляет шлюз; браузеру токен не нужен. + * Обзор Mihomo: нативный WS для /traffic и /memory + резервный потоковый GET при необходимости. */ export function mihomoWsUrl(alias: string, path: string): string { const p = path.replace(/^\/+/, ''); diff --git a/web/src/routes/servers/[alias]/mihomo/+page.svelte b/web/src/routes/servers/[alias]/mihomo/+page.svelte index 13b7295..86086ed 100644 --- a/web/src/routes/servers/[alias]/mihomo/+page.svelte +++ b/web/src/routes/servers/[alias]/mihomo/+page.svelte @@ -7,7 +7,8 @@ fetchMihomoJson, fetchMihomoMeta, mihomoPut, - mihomoUrl + mihomoUrl, + mihomoWsUrl } from '$lib/api/client.js'; import type { MihomoConnectionsResponse, @@ -244,6 +245,129 @@ void Promise.all([pollTrafficLoop(a, signal), pollMemoryLoop(a, signal)]); } + /** + * Нативный WebSocket к шлюзу → Mihomo (раз в сек JSON). Переподключение при обрыве. + * Параллельно остаётся HTTP-опрос выше — что заработает в сети, то и заполнит метрики. + */ + async function trafficWsLoop(a: string, signal: AbortSignal) { + while (!signal.aborted) { + await new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + let ws: WebSocket; + try { + ws = new WebSocket(mihomoWsUrl(a, 'traffic')); + } catch { + done(); + return; + } + const onAbort = () => { + try { + ws.close(); + } catch { + /* ignore */ + } + done(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + ws.onmessage = (ev) => { + try { + const o = JSON.parse(String(ev.data)) 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 frame */ + } + }; + ws.onerror = () => { + try { + ws.close(); + } catch { + /* ignore */ + } + }; + ws.onclose = () => { + signal.removeEventListener('abort', onAbort); + done(); + }; + }); + if (signal.aborted) break; + await new Promise((r) => setTimeout(r, 1500)); + } + } + + async function memoryWsLoop(a: string, signal: AbortSignal) { + while (!signal.aborted) { + await new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + let ws: WebSocket; + try { + ws = new WebSocket(mihomoWsUrl(a, 'memory')); + } catch { + done(); + return; + } + const onAbort = () => { + try { + ws.close(); + } catch { + /* ignore */ + } + done(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + ws.onmessage = (ev) => { + try { + const o = JSON.parse(String(ev.data)) as { inuse?: number }; + memKB = Number(o.inuse) || 0; + histMem = [...histMem, memKB]; + while (histMem.length > MAX_POINTS) histMem.shift(); + } catch { + /* ignore */ + } + }; + ws.onerror = () => { + try { + ws.close(); + } catch { + /* ignore */ + } + }; + ws.onclose = () => { + signal.removeEventListener('abort', onAbort); + done(); + }; + }); + if (signal.aborted) break; + await new Promise((r) => setTimeout(r, 1500)); + } + } + + function runTrafficMemoryRealtime(a: string, signal: AbortSignal) { + runTrafficMemoryPollers(a, signal); + void trafficWsLoop(a, signal); + void memoryWsLoop(a, signal); + } + function topProxyCounts(conns: MihomoConnectionsResponse['connections']): { name: string; n: number }[] { const m = new Map(); for (const c of conns ?? []) { @@ -294,7 +418,7 @@ histMem = []; void loadMeta(a); const streamAc = new AbortController(); - runTrafficMemoryPollers(a, streamAc.signal); + runTrafficMemoryRealtime(a, streamAc.signal); if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => void pollConnections(a), 2000); void pollConnections(a);