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
|
# 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)
|
## Быстрый старт (Linux)
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ trusted_proxies: []
|
|||||||
servers:
|
servers:
|
||||||
- alias: main_srv
|
- alias: main_srv
|
||||||
base_url: http://host.docker.internal:9091
|
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
|
base_url: http://127.0.0.1:9091
|
||||||
path_prefix: /v1
|
path_prefix: /v1
|
||||||
# authorization_env: TELEMT_API_AUTH
|
# 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/
|
# ivx: HTTPS + nginx location /api/ → Telemt; base_url должен заканчиваться на /api/
|
||||||
- alias: gt1
|
- alias: gt1
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ services:
|
|||||||
- ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro
|
- ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro
|
||||||
environment:
|
environment:
|
||||||
CONFIG_PATH: /etc/telemt-gateway/config.yaml
|
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):
|
# Доступ к Telemt на хосте Linux (host.docker.internal в config.compose.yaml):
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
# При необходимости подключите к сети с контейнером Mihomo:
|
||||||
|
# networks:
|
||||||
|
# - proxy-net
|
||||||
|
# networks:
|
||||||
|
# proxy-net:
|
||||||
|
# external: true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
9. [Проверка](#проверка)
|
9. [Проверка](#проверка)
|
||||||
10. [Обновление и CI/CD](#обновление-и-cicd)
|
10. [Обновление и CI/CD](#обновление-и-cicd)
|
||||||
11. [Устранение неполадок](#устранение-неполадок)
|
11. [Устранение неполадок](#устранение-неполадок)
|
||||||
|
12. [Mihomo (external-controller)](#mihomo-external-controller)
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
@@ -77,12 +78,48 @@ servers:
|
|||||||
|
|
||||||
Значение должно **точно** совпадать с настроенным в Telemt `auth_header` (см. [API.md](API.md)).
|
Значение должно **точно** совпадать с настроенным в 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`. |
|
| `CONFIG_PATH` | Путь к YAML внутри контейнера. По умолчанию: `/etc/telemt-gateway/config.yaml`. |
|
||||||
| `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). |
|
| `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). |
|
||||||
|
| `MIHOMO_CONTROLLER_URL` | Пример: URL для `mihomo_base_url_env` (если используете в конфиге). |
|
||||||
|
| `TELEMT_MIHOMO_AUTH` | Пример: `Bearer …` для `mihomo_authorization_env` (если используете). |
|
||||||
|
|
||||||
## Готовый образ из registry
|
## Готовый образ из registry
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ type Server struct {
|
|||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
PathPrefix string `yaml:"path_prefix"`
|
PathPrefix string `yaml:"path_prefix"`
|
||||||
AuthorizationEnv string `yaml:"authorization_env"`
|
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.
|
// Load reads and validates configuration from path.
|
||||||
@@ -79,6 +89,9 @@ func (c *Config) Validate() error {
|
|||||||
s.BaseURL = strings.TrimSpace(s.BaseURL)
|
s.BaseURL = strings.TrimSpace(s.BaseURL)
|
||||||
s.PathPrefix = strings.TrimSpace(s.PathPrefix)
|
s.PathPrefix = strings.TrimSpace(s.PathPrefix)
|
||||||
s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv)
|
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 == "" {
|
if s.Alias == "" {
|
||||||
return fmt.Errorf("servers[%d]: alias is required", i)
|
return fmt.Errorf("servers[%d]: alias is required", i)
|
||||||
}
|
}
|
||||||
@@ -106,6 +119,19 @@ func (c *Config) Validate() error {
|
|||||||
if !strings.HasPrefix(s.PathPrefix, "/") {
|
if !strings.HasPrefix(s.PathPrefix, "/") {
|
||||||
s.PathPrefix = "/" + 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 {
|
if len(c.Servers) == 0 {
|
||||||
return fmt.Errorf("at least one server entry is required")
|
return fmt.Errorf("at least one server entry is required")
|
||||||
@@ -160,6 +186,7 @@ type Parsed struct {
|
|||||||
Trusted []netip.Prefix
|
Trusted []netip.Prefix
|
||||||
ByAlias map[string]*Server
|
ByAlias map[string]*Server
|
||||||
AuthByAlias map[string]string // non-empty Authorization value per alias
|
AuthByAlias map[string]string // non-empty Authorization value per alias
|
||||||
|
MihomoByAlias map[string]*Mihomo
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse compiles CIDRs and resolves authorization from environment.
|
// 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))
|
by := make(map[string]*Server, len(c.Servers))
|
||||||
auth := make(map[string]string)
|
auth := make(map[string]string)
|
||||||
|
mihomo := make(map[string]*Mihomo)
|
||||||
for i := range c.Servers {
|
for i := range c.Servers {
|
||||||
s := &c.Servers[i]
|
s := &c.Servers[i]
|
||||||
by[s.Alias] = s
|
by[s.Alias] = s
|
||||||
@@ -192,12 +220,53 @@ func (c *Config) Parse() (*Parsed, error) {
|
|||||||
}
|
}
|
||||||
auth[s.Alias] = v
|
auth[s.Alias] = v
|
||||||
}
|
}
|
||||||
|
mu, err := resolveMihomo(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mu != nil {
|
||||||
|
mihomo[s.Alias] = mu
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &Parsed{
|
return &Parsed{
|
||||||
Config: c,
|
Config: c,
|
||||||
Whitelist: wl,
|
Whitelist: wl,
|
||||||
Trusted: tr,
|
Trusted: tr,
|
||||||
ByAlias: by,
|
ByAlias: by,
|
||||||
AuthByAlias: auth,
|
AuthByAlias: auth,
|
||||||
|
MihomoByAlias: mihomo,
|
||||||
}, nil
|
}, 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 {
|
type Gateway struct {
|
||||||
parsed *config.Parsed
|
parsed *config.Parsed
|
||||||
proxies map[string]http.Handler
|
proxies map[string]http.Handler
|
||||||
|
mihomo map[string]http.Handler
|
||||||
agg *aggregate.Handler
|
agg *aggregate.Handler
|
||||||
geo *geoip.Service
|
geo *geoip.Service
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
@@ -45,6 +46,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
|||||||
g := &Gateway{
|
g := &Gateway{
|
||||||
parsed: p,
|
parsed: p,
|
||||||
proxies: make(map[string]http.Handler),
|
proxies: make(map[string]http.Handler),
|
||||||
|
mihomo: make(map[string]http.Handler),
|
||||||
geo: geo,
|
geo: geo,
|
||||||
log: log,
|
log: log,
|
||||||
transport: t,
|
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
|
var aggCacheTTL time.Duration
|
||||||
if p.Config.Aggregate != nil && p.Config.Aggregate.CacheTTLMs > 0 {
|
if p.Config.Aggregate != nil && p.Config.Aggregate.CacheTTLMs > 0 {
|
||||||
aggCacheTTL = time.Duration(p.Config.Aggregate.CacheTTLMs) * time.Millisecond
|
aggCacheTTL = time.Duration(p.Config.Aggregate.CacheTTLMs) * time.Millisecond
|
||||||
@@ -230,7 +245,11 @@ func routeEndpoint(path string) string {
|
|||||||
if i < 0 {
|
if i < 0 {
|
||||||
return "proxy_root"
|
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"
|
return "ui"
|
||||||
}
|
}
|
||||||
@@ -285,7 +304,7 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rp, ok := g.proxies[alias]
|
_, ok := g.proxies[alias]
|
||||||
if !ok {
|
if !ok {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
@@ -295,7 +314,28 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
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) {
|
func (g *Gateway) serveLiveEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Generated
+19
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"bits-ui": "^2.16.4",
|
"bits-ui": "^2.16.4",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
@@ -553,6 +554,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@lucide/svelte": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz",
|
||||||
@@ -1481,6 +1488,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"bits-ui": "^2.16.4",
|
"bits-ui": "^2.16.4",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
@@ -7,12 +7,70 @@ import type {
|
|||||||
TelemtSuccess,
|
TelemtSuccess,
|
||||||
UserInfo
|
UserInfo
|
||||||
} from './telemt-v1.js';
|
} from './telemt-v1.js';
|
||||||
|
import type { MihomoMetaResponse } from './mihomo-types.js';
|
||||||
|
|
||||||
export function gatewayBase(): string {
|
export function gatewayBase(): string {
|
||||||
const u = PUBLIC_TELEMT_GATEWAY_URL || '';
|
const u = PUBLIC_TELEMT_GATEWAY_URL || '';
|
||||||
return u.replace(/\/$/, '');
|
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> = {
|
export type AggEnvelope<T> = {
|
||||||
ok: true;
|
ok: true;
|
||||||
generated_at: string;
|
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 NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||||
import SirenIcon from '@lucide/svelte/icons/siren';
|
import SirenIcon from '@lucide/svelte/icons/siren';
|
||||||
import RadioIcon from '@lucide/svelte/icons/radio';
|
import RadioIcon from '@lucide/svelte/icons/radio';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
@@ -136,6 +137,16 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</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.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
|
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
|
||||||
{#snippet child({ props })}
|
{#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 {
|
export function formatBytes(octets: number): string {
|
||||||
if (octets < 1024) return `${octets} B`;
|
if (octets < 1024) return `${octets} B`;
|
||||||
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
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