Implement Mihomo external-controller support in configuration and gateway
- Added optional Mihomo configuration fields in `config.compose.yaml` and `config.example.yaml` for enhanced integration with the Mihomo external-controller. - Updated the `Gateway` to handle Mihomo API requests, including proxying and error handling for Mihomo-specific endpoints. - Enhanced the documentation in `GATEWAY_RUN.md` to guide users on configuring Mihomo integration. - Introduced new utility functions in the web client for interacting with Mihomo API endpoints, improving the overall user experience. - Updated the sidebar in the Svelte components to include a link to the Mihomo section, enhancing navigation.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <secret>`), как у `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
|
||||
|
||||
|
||||
@@ -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 <secret>)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Generated
+19
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<MihomoMetaResponse> {
|
||||
const res = await fetch(mihomoUrl(alias, 'meta'));
|
||||
const body = (await parseJson(res)) as Record<string, unknown> | 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<T>(alias: string, path: string): Promise<T> {
|
||||
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<void> {
|
||||
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<T> = {
|
||||
ok: true;
|
||||
generated_at: string;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** GET /proxies — фрагмент ответа Mihomo external-controller. */
|
||||
export type MihomoProxiesResponse = {
|
||||
proxies?: Record<string, MihomoProxyEntry>;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/mihomo')} tooltipContent="Mihomo">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/mihomo" {...props}>
|
||||
<ZapIcon />
|
||||
<span>Mihomo</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
|
||||
{#snippet child({ props })}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Chart from 'chart.js/auto';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
||||
|
||||
let {
|
||||
histUp,
|
||||
histDown,
|
||||
histMem,
|
||||
histConn,
|
||||
totalUp,
|
||||
totalDown,
|
||||
tcpN,
|
||||
udpN,
|
||||
topList
|
||||
}: {
|
||||
histUp: number[];
|
||||
histDown: number[];
|
||||
histMem: number[];
|
||||
histConn: number[];
|
||||
totalUp: number;
|
||||
totalDown: number;
|
||||
tcpN: number;
|
||||
udpN: number;
|
||||
topList: { name: string; n: number }[];
|
||||
} = $props();
|
||||
|
||||
let elTraffic = $state<HTMLCanvasElement | null>(null);
|
||||
let elFlow = $state<HTMLCanvasElement | null>(null);
|
||||
let elMem = $state<HTMLCanvasElement | null>(null);
|
||||
let elConn = $state<HTMLCanvasElement | null>(null);
|
||||
let elNet = $state<HTMLCanvasElement | null>(null);
|
||||
let elTop = $state<HTMLCanvasElement | null>(null);
|
||||
|
||||
let chartTraffic: Chart | null = null;
|
||||
let chartFlow: Chart | null = null;
|
||||
let chartMem: Chart | null = null;
|
||||
let chartConn: Chart | null = null;
|
||||
let chartNet: Chart | null = null;
|
||||
let chartTop: Chart | null = null;
|
||||
|
||||
const grid = '#64748b';
|
||||
|
||||
function lineOpts() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
plugins: { legend: { labels: { color: grid } } },
|
||||
scales: {
|
||||
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
|
||||
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
|
||||
const lab = histDown.map((_, i) => String(i));
|
||||
chartTraffic = new Chart(elTraffic, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: lab,
|
||||
datasets: [
|
||||
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true },
|
||||
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true }
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
chartFlow = new Chart(elFlow, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Скачано', 'Загружено'],
|
||||
datasets: [{ data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], backgroundColor: ['#38bdf8', '#f472b6'] }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
|
||||
});
|
||||
chartMem = new Chart(elMem, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: histMem.map((_, i) => String(i)),
|
||||
datasets: [{ label: 'KB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
chartConn = new Chart(elConn, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: histConn.map((_, i) => String(i)),
|
||||
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
chartNet = new Chart(elNet, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['TCP', 'UDP'],
|
||||
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
|
||||
});
|
||||
chartTop = new Chart(elTop, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: topList.map((x) => x.name),
|
||||
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: grid }, grid: { color: '#33415555' } },
|
||||
y: { ticks: { color: grid }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const labels = histDown.map((_, i) => String(i));
|
||||
if (chartTraffic) {
|
||||
chartTraffic.data.labels = labels;
|
||||
chartTraffic.data.datasets[0].data = [...histDown];
|
||||
chartTraffic.data.datasets[1].data = [...histUp];
|
||||
chartTraffic.update('none');
|
||||
}
|
||||
if (chartMem) {
|
||||
chartMem.data.labels = histMem.map((_, i) => String(i));
|
||||
chartMem.data.datasets[0].data = [...histMem];
|
||||
chartMem.update('none');
|
||||
}
|
||||
if (chartConn) {
|
||||
chartConn.data.labels = histConn.map((_, i) => String(i));
|
||||
chartConn.data.datasets[0].data = [...histConn];
|
||||
chartConn.update('none');
|
||||
}
|
||||
if (chartFlow) {
|
||||
chartFlow.data.datasets[0].data = [Math.max(totalDown, 0), Math.max(totalUp, 0)];
|
||||
chartFlow.update('none');
|
||||
}
|
||||
if (chartNet) {
|
||||
chartNet.data.datasets[0].data = [tcpN, udpN];
|
||||
chartNet.update('none');
|
||||
}
|
||||
if (chartTop) {
|
||||
chartTop.data.labels = topList.map((x) => x.name);
|
||||
chartTop.data.datasets[0].data = topList.map((x) => x.n);
|
||||
chartTop.update('none');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
chartTraffic?.destroy();
|
||||
chartFlow?.destroy();
|
||||
chartMem?.destroy();
|
||||
chartConn?.destroy();
|
||||
chartNet?.destroy();
|
||||
chartTop?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2 text-base">
|
||||
<ActivityIcon class="size-4" /> Трафик
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56">
|
||||
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-base">Поток (накоплено)</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56">
|
||||
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-base">Память (KB)</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56">
|
||||
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-base">Подключения</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56">
|
||||
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2 text-base">
|
||||
<NetworkIcon class="size-4" /> Типы сети
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56">
|
||||
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2 text-base">
|
||||
<GaugeIcon class="size-4" /> Топ прокси (по сессиям)
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56">
|
||||
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -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'];
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { browser } from '$app/environment';
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
ApiError,
|
||||
fetchMihomoJson,
|
||||
fetchMihomoMeta,
|
||||
mihomoPut,
|
||||
mihomoWsUrl
|
||||
} from '$lib/api/client.js';
|
||||
import type {
|
||||
MihomoConnectionsResponse,
|
||||
MihomoMetaResponse,
|
||||
MihomoProxiesResponse,
|
||||
MihomoProxyEntry
|
||||
} from '$lib/api/mihomo-types.js';
|
||||
import { formatBytes, formatRatePerSec } from '$lib/format.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
|
||||
let tab = $state<'overview' | 'proxies'>('overview');
|
||||
let loadingMeta = $state(true);
|
||||
let metaErr = $state<string | null>(null);
|
||||
let meta = $state<MihomoMetaResponse | null>(null);
|
||||
|
||||
let upBps = $state(0);
|
||||
let downBps = $state(0);
|
||||
let totalUp = $state(0);
|
||||
let totalDown = $state(0);
|
||||
let memKB = $state(0);
|
||||
let connTotal = $state(0);
|
||||
let tcpN = $state(0);
|
||||
let udpN = $state(0);
|
||||
|
||||
let histUp = $state<number[]>([]);
|
||||
let histDown = $state<number[]>([]);
|
||||
let histMem = $state<number[]>([]);
|
||||
let histConn = $state<number[]>([]);
|
||||
let topList = $state<{ name: string; n: number }[]>([]);
|
||||
|
||||
const MAX_POINTS = 72;
|
||||
let lastTick = $state<number | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let wsTraffic: WebSocket | null = null;
|
||||
let wsMemory: WebSocket | null = null;
|
||||
|
||||
let proxiesLoading = $state(false);
|
||||
let proxiesErr = $state<string | null>(null);
|
||||
let proxiesData = $state<MihomoProxiesResponse | null>(null);
|
||||
let switching = $state<string | null>(null);
|
||||
let testing = $state<string | null>(null);
|
||||
let testingGroup = $state<string | null>(null);
|
||||
|
||||
function recordRates(now: number, up: number, down: number) {
|
||||
if (lastTick != null) {
|
||||
const dt = (now - lastTick) / 1000;
|
||||
if (dt > 0 && dt < 5) {
|
||||
totalUp += up * dt;
|
||||
totalDown += down * dt;
|
||||
}
|
||||
}
|
||||
lastTick = now;
|
||||
upBps = up;
|
||||
downBps = down;
|
||||
histUp = [...histUp, up];
|
||||
histDown = [...histDown, down];
|
||||
while (histUp.length > MAX_POINTS) {
|
||||
histUp.shift();
|
||||
histDown.shift();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta(a: string) {
|
||||
loadingMeta = true;
|
||||
metaErr = null;
|
||||
try {
|
||||
meta = await fetchMihomoMeta(a);
|
||||
} catch (e) {
|
||||
meta = null;
|
||||
metaErr = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
loadingMeta = false;
|
||||
}
|
||||
}
|
||||
|
||||
function connectWs(a: string) {
|
||||
if (!browser) return;
|
||||
wsTraffic?.close();
|
||||
wsMemory?.close();
|
||||
lastTick = null;
|
||||
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 */
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
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 */
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function topProxyCounts(conns: MihomoConnectionsResponse['connections']): { name: string; n: number }[] {
|
||||
const m = new Map<string, number>();
|
||||
for (const c of conns ?? []) {
|
||||
const ch = c.chains;
|
||||
if (!ch?.length) continue;
|
||||
const name = ch[ch.length - 1] ?? ch[0];
|
||||
m.set(name, (m.get(name) ?? 0) + 1);
|
||||
}
|
||||
return [...m.entries()]
|
||||
.map(([name, n]) => ({ name, n }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
async function pollConnections(a: string) {
|
||||
try {
|
||||
const j = await fetchMihomoJson<MihomoConnectionsResponse>(a, 'connections');
|
||||
const list = j.connections ?? [];
|
||||
connTotal = typeof j.total === 'number' ? j.total : list.length;
|
||||
let t = 0,
|
||||
u = 0;
|
||||
for (const c of list) {
|
||||
const net = (c.metadata?.network ?? '').toLowerCase();
|
||||
if (net === 'tcp') t++;
|
||||
else if (net === 'udp') u++;
|
||||
}
|
||||
tcpN = t;
|
||||
udpN = u;
|
||||
histConn = [...histConn, connTotal];
|
||||
while (histConn.length > MAX_POINTS) histConn.shift();
|
||||
topList = topProxyCounts(list);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const a = alias;
|
||||
if (!a || !browser) return;
|
||||
void loadMeta(a);
|
||||
connectWs(a);
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(() => void pollConnections(a), 2000);
|
||||
void pollConnections(a);
|
||||
return () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
wsTraffic?.close();
|
||||
wsMemory?.close();
|
||||
wsTraffic = null;
|
||||
wsMemory = null;
|
||||
};
|
||||
});
|
||||
|
||||
async function loadProxies() {
|
||||
const a = alias;
|
||||
if (!a) return;
|
||||
proxiesLoading = true;
|
||||
proxiesErr = null;
|
||||
try {
|
||||
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
|
||||
} catch (e) {
|
||||
proxiesData = null;
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
proxiesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const a = alias;
|
||||
if (!a || tab !== 'proxies') return;
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
function isSelectableGroup(t?: string) {
|
||||
return t === 'Selector' || t === 'URLTest';
|
||||
}
|
||||
|
||||
function lastDelay(p: MihomoProxyEntry): number | null {
|
||||
const h = p.history;
|
||||
if (!h?.length) return null;
|
||||
return h[h.length - 1]?.delay ?? null;
|
||||
}
|
||||
|
||||
async function selectProxy(groupName: string, nodeName: string) {
|
||||
const a = alias;
|
||||
if (!a) return;
|
||||
switching = groupName;
|
||||
try {
|
||||
await mihomoPut(a, `proxies/${encodeURIComponent(groupName)}`, { name: nodeName });
|
||||
await loadProxies();
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
switching = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pingOne(name: string) {
|
||||
const a = alias;
|
||||
if (!a) return;
|
||||
testing = name;
|
||||
try {
|
||||
await fetchMihomoJson(a, `proxies/${encodeURIComponent(name)}/delay?timeout=5000`);
|
||||
await loadProxies();
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
testing = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pingGroup(groupName: string) {
|
||||
const a = alias;
|
||||
if (!a) return;
|
||||
testingGroup = groupName;
|
||||
try {
|
||||
await fetchMihomoJson(a, `group/${encodeURIComponent(groupName)}/delay?timeout=5000`);
|
||||
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`);
|
||||
}
|
||||
await loadProxies();
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
} finally {
|
||||
testingGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
wsTraffic?.close();
|
||||
wsMemory?.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1>
|
||||
<p class="text-muted-foreground text-sm">Обзор и прокси (external-controller)</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={tab === 'overview' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (tab = 'overview')}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'proxies' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (tab = 'proxies')}
|
||||
>
|
||||
Прокси
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
|
||||
{#snippet skeleton()}
|
||||
<Skeleton class="mb-4 h-6 w-96 max-w-full" />
|
||||
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||
<Skeleton class="h-24 rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||
<Skeleton class="h-56 rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if meta}
|
||||
<p class="text-muted-foreground mb-4 text-sm">
|
||||
Подключено к: <span class="text-foreground font-mono">{meta.controller_base}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if tab === 'overview' && !metaErr}
|
||||
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Card.Root class="border-l-primary border-l-4 py-3">
|
||||
<Card.Header class="pb-1">
|
||||
<Card.Description>Загрузка</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="text-lg font-semibold">{formatRatePerSec(upBps)}</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-l-sky-500 border-l-4 py-3">
|
||||
<Card.Header class="pb-1">
|
||||
<Card.Description>Скачивание</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="text-lg font-semibold">{formatRatePerSec(downBps)}</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-l-amber-500 border-l-4 py-3">
|
||||
<Card.Header class="pb-1">
|
||||
<Card.Description>Всего загружено</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="text-lg font-semibold">{formatBytes(totalUp)}</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-l-cyan-500 border-l-4 py-3">
|
||||
<Card.Header class="pb-1">
|
||||
<Card.Description>Всего скачано</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="text-lg font-semibold">{formatBytes(totalDown)}</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-l-emerald-500 border-l-4 py-3">
|
||||
<Card.Header class="pb-1">
|
||||
<Card.Description>Активные соединения</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="text-lg font-semibold">{connTotal}</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-l-violet-500 border-l-4 py-3">
|
||||
<Card.Header class="pb-1">
|
||||
<Card.Description>Память</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="text-lg font-semibold">
|
||||
{memKB > 0 ? `${(memKB / 1024).toFixed(1)} MiB` : '—'}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
<MihomoOverviewCharts
|
||||
histUp={histUp}
|
||||
histDown={histDown}
|
||||
histMem={histMem}
|
||||
histConn={histConn}
|
||||
totalUp={totalUp}
|
||||
totalDown={totalDown}
|
||||
tcpN={tcpN}
|
||||
udpN={udpN}
|
||||
topList={topList}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
|
||||
{#if tab === 'proxies'}
|
||||
<div class="mb-4 flex justify-end">
|
||||
<Button variant="outline" size="sm" onclick={() => loadProxies()} disabled={proxiesLoading}>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
<DataQueryState err={proxiesErr} showSkeleton={proxiesLoading} isEmpty={false}>
|
||||
{#snippet skeleton()}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||
<Skeleton class="h-32 rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if proxiesData?.proxies}
|
||||
<div class="flex flex-col gap-8">
|
||||
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v.type)) as [gName, group] (gName)}
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{gName}</h2>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => pingGroup(gName)}
|
||||
disabled={testingGroup === gName}
|
||||
>
|
||||
{testingGroup === gName ? 'Проверка…' : 'Проверить все'}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each group.all ?? [] as nodeName (nodeName)}
|
||||
{@const node = proxiesData.proxies?.[nodeName]}
|
||||
{@const d = node ? lastDelay(node) : null}
|
||||
{@const active = group.now === nodeName}
|
||||
<div
|
||||
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
|
||||
? 'ring-primary ring-2'
|
||||
: ''}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left"
|
||||
onclick={() => selectProxy(gName, nodeName)}
|
||||
disabled={switching === gName}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium">{nodeName}</span>
|
||||
<Badge variant="outline">{node?.type ?? '—'}</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
|
||||
>
|
||||
{d != null ? `${d} ms` : '—'}
|
||||
</div>
|
||||
</button>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2"
|
||||
onclick={() => pingOne(nodeName)}
|
||||
disabled={testing === nodeName}
|
||||
>
|
||||
Ping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user