diff --git a/README.md b/README.md index afcba9c..8eb77f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # telemt-api -HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`. +HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`. ## Быстрый старт (Linux) diff --git a/config.compose.yaml b/config.compose.yaml index e96fa3f..fb071b5 100644 --- a/config.compose.yaml +++ b/config.compose.yaml @@ -8,3 +8,7 @@ trusted_proxies: [] servers: - alias: main_srv base_url: http://host.docker.internal:9091 + # Раскомментируйте при настройке Mihomo и общей сети с контейнером mihomo: + # mihomo_base_url: http://mihomo:9090 + # или: mihomo_base_url_env: MIHOMO_CONTROLLER_URL + # mihomo_authorization_env: TELEMT_MIHOMO_AUTH diff --git a/config.example.yaml b/config.example.yaml index 322d247..1350255 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -52,6 +52,13 @@ servers: base_url: http://127.0.0.1:9091 path_prefix: /v1 # authorization_env: TELEMT_API_AUTH + # + # Опционально: Mihomo (Clash Meta) external-controller для раздела «Mihomo» в панели. + # Клиент вызывает GET /api/{alias}/mihomo/… — шлюз проксирует на {mihomo_base_url}/… с Bearer-секретом. + # Секрет должен совпадать с secret / CLASH_SECRET в конфиге Mihomo. Не храните секрет в git — только через env. + # mihomo_base_url: http://mihomo:9090 + # mihomo_base_url_env: MIHOMO_CONTROLLER_URL # если задано и env непустой — URL берётся из env (удобно в Docker) + # mihomo_authorization_env: TELEMT_MIHOMO_AUTH # в env: полная строка заголовка, например `Bearer xxx` # ivx: HTTPS + nginx location /api/ → Telemt; base_url должен заканчиваться на /api/ - alias: gt1 diff --git a/docker-compose.yml b/docker-compose.yml index 559d4a6..8e449e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,15 @@ services: - ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro environment: CONFIG_PATH: /etc/telemt-gateway/config.yaml + # Опционально: Mihomo в той же Docker-сети, что и gateway (см. docs/GATEWAY_RUN.md) + # MIHOMO_CONTROLLER_URL: http://mihomo:9090 + # TELEMT_MIHOMO_AUTH: Bearer ${CLASH_SECRET} # Доступ к Telemt на хосте Linux (host.docker.internal в config.compose.yaml): extra_hosts: - "host.docker.internal:host-gateway" + # При необходимости подключите к сети с контейнером Mihomo: + # networks: + # - proxy-net +# networks: +# proxy-net: +# external: true diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index aaaac77..0155c4a 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -13,6 +13,7 @@ 9. [Проверка](#проверка) 10. [Обновление и CI/CD](#обновление-и-cicd) 11. [Устранение неполадок](#устранение-неполадок) +12. [Mihomo (external-controller)](#mihomo-external-controller) ## Назначение @@ -77,12 +78,48 @@ servers: Значение должно **точно** совпадать с настроенным в Telemt `auth_header` (см. [API.md](API.md)). +### Mihomo (external-controller) + +Опционально для каждого `servers[]` можно включить проксирование **Mihomo** (Clash Meta) API: в Web UI появится раздел **«Mihomo»** для выбранного alias. + +- Клиент (браузер) обращается к шлюзу: `GET /api/{alias}/mihomo/proxies`, WebSocket `…/mihomo/traffic` и т.д. +- Шлюз проксирует на `{mihomo_base_url}/proxies`, `…/traffic` и т.д. с заголовком `Authorization` из переменной окружения (секрет **не** попадает в фронтенд). +- Служебный ответ: `GET /api/{alias}/mihomo/meta` — JSON с полем `controller_base` (без учётных данных) для строки «Подключено к: …» в панели. + +Поля в конфиге: + +| Поле | Описание | +|------|----------| +| `mihomo_base_url` | Базовый URL контроллера, например `http://mihomo:9090` (порт контроллера по умолчанию в контейнере Mihomo — **9090**). | +| `mihomo_base_url_env` | Имя переменной окружения; если задано и значение **непустое**, URL контроллера берётся из `os.Getenv` при старте (удобно в Docker без хардкода IP). Если env пустой, используется `mihomo_base_url`. | +| `mihomo_authorization_env` | Имя env: **полное** значение заголовка `Authorization` (например `Bearer `), как у `authorization_env` для Telemt. Должно совпадать с секретом на стороне Mihomo (`secret` / `CLASH_SECRET` в конфиге ядра). | + +Правила: + +- Если указан `mihomo_base_url` или `mihomo_base_url_env`, обязательно задайте `mihomo_authorization_env` и непустые значения в env при старте шлюза. +- Контейнер **gateway** должен иметь **сетевую связность** с контроллером Mihomo (лучше одна пользовательская Docker-сеть; имя сервиса `http://mihomo:9090` предпочтительнее статического IP). Публиковать порт **9090** на хост не обязательно: браузер ходит в шлюз, шлюз — в контейнер Mihomo по overlay-сети. +- Пример `environment` для compose (секреты не в git — через `.env`): + +```yaml +services: + gateway: + environment: + MIHOMO_CONTROLLER_URL: http://mihomo:9090 + TELEMT_MIHOMO_AUTH: Bearer ${CLASH_SECRET} +``` + +и в `config.yaml` для нужного сервера: `mihomo_base_url_env: MIHOMO_CONTROLLER_URL`, `mihomo_authorization_env: TELEMT_MIHOMO_AUTH`. + +За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`. + ## Переменные окружения | Переменная | Описание | |----------------|----------| | `CONFIG_PATH` | Путь к YAML внутри контейнера. По умолчанию: `/etc/telemt-gateway/config.yaml`. | | `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). | +| `MIHOMO_CONTROLLER_URL` | Пример: URL для `mihomo_base_url_env` (если используете в конфиге). | +| `TELEMT_MIHOMO_AUTH` | Пример: `Bearer …` для `mihomo_authorization_env` (если используете). | ## Готовый образ из registry diff --git a/internal/config/config.go b/internal/config/config.go index b5db4b5..333a4fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,6 +49,16 @@ type Server struct { BaseURL string `yaml:"base_url"` PathPrefix string `yaml:"path_prefix"` AuthorizationEnv string `yaml:"authorization_env"` + // Mihomo external-controller (optional): REST + WebSocket at controller root. + MihomoBaseURL string `yaml:"mihomo_base_url"` + MihomoBaseURLEnv string `yaml:"mihomo_base_url_env"` + MihomoAuthorizationEnv string `yaml:"mihomo_authorization_env"` +} + +// Mihomo holds resolved external-controller upstream for a server alias. +type Mihomo struct { + Base *url.URL + Auth string // full Authorization header value (e.g. Bearer ) } // Load reads and validates configuration from path. @@ -79,6 +89,9 @@ func (c *Config) Validate() error { s.BaseURL = strings.TrimSpace(s.BaseURL) s.PathPrefix = strings.TrimSpace(s.PathPrefix) s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv) + s.MihomoBaseURL = strings.TrimSpace(s.MihomoBaseURL) + s.MihomoBaseURLEnv = strings.TrimSpace(s.MihomoBaseURLEnv) + s.MihomoAuthorizationEnv = strings.TrimSpace(s.MihomoAuthorizationEnv) if s.Alias == "" { return fmt.Errorf("servers[%d]: alias is required", i) } @@ -106,6 +119,19 @@ func (c *Config) Validate() error { if !strings.HasPrefix(s.PathPrefix, "/") { s.PathPrefix = "/" + s.PathPrefix } + hasMihomoURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != "" + if hasMihomoURL && s.MihomoAuthorizationEnv == "" { + return fmt.Errorf("servers[%d]: mihomo_authorization_env is required when mihomo url is set", i) + } + if !hasMihomoURL && s.MihomoAuthorizationEnv != "" { + return fmt.Errorf("servers[%d]: mihomo_base_url or mihomo_base_url_env is required when mihomo_authorization_env is set", i) + } + if s.MihomoBaseURL != "" { + mu, err := url.Parse(s.MihomoBaseURL) + if err != nil || mu.Scheme == "" || mu.Host == "" { + return fmt.Errorf("servers[%d]: invalid mihomo_base_url %q", i, s.MihomoBaseURL) + } + } } if len(c.Servers) == 0 { return fmt.Errorf("at least one server entry is required") @@ -160,6 +186,7 @@ type Parsed struct { Trusted []netip.Prefix ByAlias map[string]*Server AuthByAlias map[string]string // non-empty Authorization value per alias + MihomoByAlias map[string]*Mihomo } // Parse compiles CIDRs and resolves authorization from environment. @@ -182,6 +209,7 @@ func (c *Config) Parse() (*Parsed, error) { } by := make(map[string]*Server, len(c.Servers)) auth := make(map[string]string) + mihomo := make(map[string]*Mihomo) for i := range c.Servers { s := &c.Servers[i] by[s.Alias] = s @@ -192,12 +220,53 @@ func (c *Config) Parse() (*Parsed, error) { } auth[s.Alias] = v } + mu, err := resolveMihomo(s) + if err != nil { + return nil, err + } + if mu != nil { + mihomo[s.Alias] = mu + } } return &Parsed{ - Config: c, - Whitelist: wl, - Trusted: tr, - ByAlias: by, - AuthByAlias: auth, + Config: c, + Whitelist: wl, + Trusted: tr, + ByAlias: by, + AuthByAlias: auth, + MihomoByAlias: mihomo, }, nil } + +// resolveMihomo returns non-nil only when Mihomo is configured for this server. +func resolveMihomo(s *Server) (*Mihomo, error) { + hasURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != "" + if !hasURL { + return nil, nil + } + if s.MihomoAuthorizationEnv == "" { + return nil, fmt.Errorf("server %q: mihomo_authorization_env is required when mihomo url is set", s.Alias) + } + var urlStr string + if s.MihomoBaseURLEnv != "" { + v := strings.TrimSpace(os.Getenv(s.MihomoBaseURLEnv)) + if v != "" { + urlStr = v + } + } + if urlStr == "" && s.MihomoBaseURL != "" { + urlStr = s.MihomoBaseURL + } + if urlStr == "" { + return nil, fmt.Errorf("server %q: mihomo controller url is empty (set mihomo_base_url or env %q)", s.Alias, s.MihomoBaseURLEnv) + } + u, err := url.Parse(urlStr) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("server %q: invalid mihomo controller url %q", s.Alias, urlStr) + } + authVal := os.Getenv(s.MihomoAuthorizationEnv) + if strings.TrimSpace(authVal) == "" { + return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.MihomoAuthorizationEnv) + } + return &Mihomo{Base: u, Auth: authVal}, nil +} diff --git a/internal/proxy/mihomo.go b/internal/proxy/mihomo.go new file mode 100644 index 0000000..16edcd8 --- /dev/null +++ b/internal/proxy/mihomo.go @@ -0,0 +1,73 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller (REST + WebSocket). +func NewMihomoForward( + target *url.URL, + stripPrefix string, + auth string, + rt http.RoundTripper, + errHandler func(http.ResponseWriter, *http.Request, error), +) http.Handler { + if errHandler == nil { + errHandler = defaultForwardErrorHandler + } + if rt == nil { + rt = http.DefaultTransport + } + rp := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + NormalizeRequestURLPath(req) + p := req.URL.Path + if !strings.HasPrefix(p, stripPrefix) { + return + } + rest := strings.TrimPrefix(p, stripPrefix) + rest = strings.TrimPrefix(rest, "/") + q := req.URL.RawQuery + dest := JoinPathPrefix(target, "/", rest) + req.URL.Scheme = dest.Scheme + req.URL.Host = dest.Host + req.URL.Path = dest.Path + req.URL.RawQuery = q + req.Host = dest.Host + req.Header.Del("Authorization") + if auth != "" { + req.Header.Set("Authorization", auth) + } + }, + Transport: rt, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + errHandler(w, r, err) + }, + } + return rp +} + +// MihomoMetaJSON returns a JSON body for GET .../mihomo/meta (display URL without credentials). +func MihomoMetaJSON(target *url.URL) []byte { + display := strings.TrimSuffix(target.String(), "/") + b, _ := json.Marshal(map[string]any{ + "ok": true, + "controller_base": display, + }) + return b +} + +// MihomoJSONError writes a JSON error for Mihomo routes when proxy is not configured. +func MihomoJSONError(w http.ResponseWriter, code, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": map[string]string{"code": code, "message": msg}, + }) +} + diff --git a/internal/proxy/mihomo_test.go b/internal/proxy/mihomo_test.go new file mode 100644 index 0000000..2d87d0d --- /dev/null +++ b/internal/proxy/mihomo_test.go @@ -0,0 +1,37 @@ +package proxy + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestMihomoForwardRewritesPathAndAuth(t *testing.T) { + target, err := url.Parse("http://127.0.0.1:9090") + if err != nil { + t.Fatal(err) + } + cap := &captureTransport{} + h := NewMihomoForward(target, "/api/mtg/mihomo", "Bearer testsecret", cap, nil) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://gw/api/mtg/mihomo/proxies", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer client-should-not-forward") + h.ServeHTTP(httptest.NewRecorder(), req) + + if cap.got == nil { + t.Fatal("no outgoing request captured") + } + if got := cap.got.Header.Get("Authorization"); got != "Bearer testsecret" { + t.Fatalf("Authorization: got %q want Bearer testsecret", got) + } + want, err := url.Parse("http://127.0.0.1:9090/proxies") + if err != nil { + t.Fatal(err) + } + assertSameURL(t, cap.got.URL, want) +} diff --git a/internal/server/gateway.go b/internal/server/gateway.go index da00e3b..280b10f 100644 --- a/internal/server/gateway.go +++ b/internal/server/gateway.go @@ -24,6 +24,7 @@ import ( type Gateway struct { parsed *config.Parsed proxies map[string]http.Handler + mihomo map[string]http.Handler agg *aggregate.Handler geo *geoip.Service log *slog.Logger @@ -45,6 +46,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa g := &Gateway{ parsed: p, proxies: make(map[string]http.Handler), + mihomo: make(map[string]http.Handler), geo: geo, log: log, transport: t, @@ -68,6 +70,19 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa }) }) } + for alias, m := range p.MihomoByAlias { + a := alias + strip := "/api/" + a + "/mihomo" + g.mihomo[a] = proxy.NewMihomoForward(m.Base, strip, m.Auth, t, func(w http.ResponseWriter, r *http.Request, err error) { + log.Error("mihomo upstream error", "alias", a, "err", err) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": map[string]string{"code": "bad_gateway", "message": "mihomo upstream unreachable"}, + }) + }) + } var aggCacheTTL time.Duration if p.Config.Aggregate != nil && p.Config.Aggregate.CacheTTLMs > 0 { aggCacheTTL = time.Duration(p.Config.Aggregate.CacheTTLMs) * time.Millisecond @@ -230,7 +245,11 @@ func routeEndpoint(path string) string { if i < 0 { return "proxy_root" } - return "proxy_" + rest[i+1:] + sub := rest[i+1:] + if strings.HasPrefix(sub, "mihomo/") { + return "mihomo_" + sub[len("mihomo/"):] + } + return "proxy_" + sub } return "ui" } @@ -285,7 +304,7 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - rp, ok := g.proxies[alias] + _, ok := g.proxies[alias] if !ok { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusNotFound) @@ -295,7 +314,28 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) { }) return } - rp.ServeHTTP(w, r) + mihomoPfx := "/api/" + alias + "/mihomo" + if strings.HasPrefix(r.URL.Path, mihomoPfx) { + mh, have := g.mihomo[alias] + if !have { + proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server") + return + } + if r.URL.Path == mihomoPfx+"/meta" && r.Method == http.MethodGet { + mu := g.parsed.MihomoByAlias[alias] + if mu == nil { + proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(proxy.MihomoMetaJSON(mu.Base)) + return + } + mh.ServeHTTP(w, r) + return + } + g.proxies[alias].ServeHTTP(w, r) } func (g *Gateway) serveLiveEvents(w http.ResponseWriter, r *http.Request) { diff --git a/web/package-lock.json b/web/package-lock.json index c13022c..fec2a7d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@types/leaflet": "^1.9.21", "bits-ui": "^2.16.4", + "chart.js": "^4.5.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", "tailwind-merge": "^3.5.0", @@ -553,6 +554,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lucide/svelte": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz", @@ -1481,6 +1488,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/web/package.json b/web/package.json index bfb51a9..4ff1d41 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "dependencies": { "@types/leaflet": "^1.9.21", "bits-ui": "^2.16.4", + "chart.js": "^4.5.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", "tailwind-merge": "^3.5.0", diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 6298670..4eeb351 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -7,12 +7,70 @@ import type { TelemtSuccess, UserInfo } from './telemt-v1.js'; +import type { MihomoMetaResponse } from './mihomo-types.js'; export function gatewayBase(): string { const u = PUBLIC_TELEMT_GATEWAY_URL || ''; return u.replace(/\/$/, ''); } +/** Путь к Mihomo external-controller через шлюз: без ведущего слэша. */ +export function mihomoUrl(alias: string, path: string): string { + const p = path.replace(/^\/+/, ''); + return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`; +} + +/** WebSocket к тому же origin (ws / wss). */ +export function mihomoWsUrl(alias: string, path: string): string { + const p = path.replace(/^\/+/, ''); + const base = gatewayBase(); + const wsBase = base.replace(/^http/, 'ws'); + return `${wsBase}/api/${encodeURIComponent(alias)}/mihomo/${p}`; +} + +export async function fetchMihomoMeta(alias: string): Promise { + const res = await fetch(mihomoUrl(alias, 'meta')); + const body = (await parseJson(res)) as Record | null; + if (res.status === 404) { + throw new ApiError('Mihomo не настроен для этой ноды', 404, body); + } + if (!res.ok) { + throw new ApiError(`Mihomo meta HTTP ${res.status}`, res.status, body); + } + if (!body || body.ok !== true) { + throw new ApiError('Mihomo meta: неверный ответ', res.status, body); + } + return body as unknown as MihomoMetaResponse; +} + +export async function fetchMihomoJson(alias: string, path: string): Promise { + const res = await fetch(mihomoUrl(alias, path)); + const body = await parseJson(res); + if (res.status === 404) { + throw new ApiError('Mihomo не настроен для этой ноды', 404, body); + } + if (!res.ok) { + throw new ApiError(`Mihomo HTTP ${res.status}`, res.status, body); + } + return body as T; +} + +export async function mihomoPut(alias: string, path: string, jsonBody: unknown): Promise { + const res = await fetch(mihomoUrl(alias, path), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(jsonBody) + }); + if (res.status === 404) { + const body = await parseJson(res); + throw new ApiError('Mihomo не настроен для этой ноды', 404, body); + } + if (!res.ok) { + const body = await parseJson(res); + throw new ApiError(`Mihomo PUT HTTP ${res.status}`, res.status, body); + } +} + export type AggEnvelope = { ok: true; generated_at: string; diff --git a/web/src/lib/api/mihomo-types.ts b/web/src/lib/api/mihomo-types.ts new file mode 100644 index 0000000..7675b11 --- /dev/null +++ b/web/src/lib/api/mihomo-types.ts @@ -0,0 +1,35 @@ +/** GET /proxies — фрагмент ответа Mihomo external-controller. */ +export type MihomoProxiesResponse = { + proxies?: Record; +}; + +export type MihomoProxyEntry = { + type?: string; + name?: string; + now?: string; + all?: string[]; + history?: { time: string; delay: number }[]; + udp?: boolean; + [key: string]: unknown; +}; + +export type MihomoConnectionsResponse = { + total?: number; + connections?: MihomoConnection[]; +}; + +export type MihomoConnection = { + metadata?: { network?: string; [k: string]: unknown }; + chains?: string[]; + [key: string]: unknown; +}; + +export type MihomoMetaResponse = { + ok: true; + controller_base: string; +}; + +export type MihomoDelayResponse = { + delay?: number; + message?: string; +}; diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index 822a04f..1267a62 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -9,6 +9,7 @@ import NetworkIcon from '@lucide/svelte/icons/network'; import SettingsIcon from '@lucide/svelte/icons/settings'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; + import ZapIcon from '@lucide/svelte/icons/zap'; import SirenIcon from '@lucide/svelte/icons/siren'; import RadioIcon from '@lucide/svelte/icons/radio'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; @@ -136,6 +137,16 @@ {/snippet} + + + {#snippet child({ props })} + + + Mihomo + + {/snippet} + + {#snippet child({ props })} diff --git a/web/src/lib/components/mihomo/mihomo-overview-charts.svelte b/web/src/lib/components/mihomo/mihomo-overview-charts.svelte new file mode 100644 index 0000000..0115c75 --- /dev/null +++ b/web/src/lib/components/mihomo/mihomo-overview-charts.svelte @@ -0,0 +1,224 @@ + + +
+ + + + Трафик + + + + + + + + + Поток (накоплено) + + + + + + + + Память (KB) + + + + + + + + Подключения + + + + + + + + + Типы сети + + + + + + + + + + Топ прокси (по сессиям) + + + + + + +
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 3a96661..cd44bd1 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,3 +1,10 @@ +/** Скорость в байтах/с (или кбит/с — подпись общая). */ +export function formatRatePerSec(value: number): string { + if (!Number.isFinite(value) || value < 0) return '—'; + if (value < 1024) return `${value < 10 ? value.toFixed(1) : Math.round(value)} B/s`; + return `${formatBytes(value)}/s`; +} + export function formatBytes(octets: number): string { if (octets < 1024) return `${octets} B`; const units = ['KiB', 'MiB', 'GiB', 'TiB']; diff --git a/web/src/routes/servers/[alias]/mihomo/+page.svelte b/web/src/routes/servers/[alias]/mihomo/+page.svelte new file mode 100644 index 0000000..0013c1d --- /dev/null +++ b/web/src/routes/servers/[alias]/mihomo/+page.svelte @@ -0,0 +1,453 @@ + + +
+
+

Mihomo

+

Обзор и прокси (external-controller)

+
+
+ + +
+
+ + + {#snippet skeleton()} + +
+ {#each Array.from({ length: 6 }) as _, i (i)} + + {/each} +
+
+ {#each Array.from({ length: 6 }) as _, i (i)} + + {/each} +
+ {/snippet} + {#snippet children()} + {#if meta} +

+ Подключено к: {meta.controller_base} +

+ {/if} + {#if tab === 'overview' && !metaErr} +
+ + + Загрузка + + {formatRatePerSec(upBps)} + + + + Скачивание + + {formatRatePerSec(downBps)} + + + + Всего загружено + + {formatBytes(totalUp)} + + + + Всего скачано + + {formatBytes(totalDown)} + + + + Активные соединения + + {connTotal} + + + + Память + + + {memKB > 0 ? `${(memKB / 1024).toFixed(1)} MiB` : '—'} + + +
+ + {/if} + {/snippet} +
+ +{#if tab === 'proxies'} +
+ +
+ + {#snippet skeleton()} +
+ {#each Array.from({ length: 6 }) as _, i (i)} + + {/each} +
+ {/snippet} + {#snippet children()} + {#if proxiesData?.proxies} +
+ {#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v.type)) as [gName, group] (gName)} +
+
+
+

{gName}

+

+ Сейчас: {group.now ?? '—'} +

+
+ +
+
+ {#each group.all ?? [] as nodeName (nodeName)} + {@const node = proxiesData.proxies?.[nodeName]} + {@const d = node ? lastDelay(node) : null} + {@const active = group.now === nodeName} +
+ +
+ +
+
+ {/each} +
+
+ {/each} +
+ {/if} + {/snippet} +
+{/if}