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();
});