Implement Mihomo external-controller support in configuration and gateway
Publish telemt-api gateway Docker image / test (push) Successful in 34s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m55s

- 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:
Denozordec
2026-03-31 00:32:19 +07:00
parent bbd9619290
commit 2d7b06260e
17 changed files with 1093 additions and 9 deletions
+1 -1
View File
@@ -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)
+4
View File
@@ -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
+7
View File
@@ -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
+9
View File
@@ -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
+37
View File
@@ -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
+74 -5
View File
@@ -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
}
+73
View File
@@ -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},
})
}
+37
View File
@@ -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)
}
+43 -3
View File
@@ -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) {
+19
View File
@@ -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",
+1
View File
@@ -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",
+58
View File
@@ -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;
+35
View File
@@ -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;
};
+11
View File
@@ -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>
+7
View File
@@ -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}