Add Radar DC Telegram functionality and configuration
- Introduced new `RadarConfig` structure in `config.go` to manage radar settings, including `statuses_url`, `http_timeout_ms`, and `ping_from`. - Implemented validation for radar configuration in `config_test.go` to ensure correct URL schemes and timeout limits. - Added new API routes for radar statuses and ping functionality in the gateway, enhancing the service's capabilities. - Updated documentation in `GATEWAY_RUN.md` to include details about the new radar features and their usage. - Enhanced the user interface to include navigation and display options for the Radar DC section in the sidebar and page titles. - Added client-side API functions for fetching radar statuses and ping responses, improving integration with the frontend.
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/…`, опционально **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`.
|
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`, **радар DC Telegram** — `GET /api/radar/statuses` и `GET /api/radar/ping-dc` (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#radar-dc-telegram)), метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`, **Радар DC** на `/radar`.
|
||||||
|
|
||||||
## Быстрый старт (Linux)
|
## Быстрый старт (Linux)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ trusted_proxies: []
|
|||||||
# - gt2
|
# - gt2
|
||||||
# cache_ttl_ms: 2000
|
# cache_ttl_ms: 2000
|
||||||
|
|
||||||
|
# Радар DC Telegram: прокси к radar.telemt.top и TCP-пинг DC с хоста шлюза (см. README).
|
||||||
|
# radar:
|
||||||
|
# statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||||
|
# http_timeout_ms: 30000
|
||||||
|
# ping_from: "stats.example.com"
|
||||||
|
|
||||||
# Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен)
|
# Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен)
|
||||||
# geoip:
|
# geoip:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
|
|||||||
@@ -146,6 +146,32 @@ services:
|
|||||||
- `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer <secret>`) задаётся в **environment** контейнера gateway, не в YAML.
|
- `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer <secret>`) задаётся в **environment** контейнера gateway, не в YAML.
|
||||||
- После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение.
|
- После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение.
|
||||||
|
|
||||||
|
<a id="radar-dc-telegram"></a>
|
||||||
|
|
||||||
|
### Радар DC Telegram
|
||||||
|
|
||||||
|
Два встроенных маршрута (аналог `radar_proxy.php` и `ping_proxy.php` из [telemt-proxy-panel-bot-firewall](https://github.com/upe4d/telemt-proxy-panel-bot-firewall)):
|
||||||
|
|
||||||
|
| Маршрут | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `GET /api/radar/statuses` | Прокси JSON с `https://radar.telemt.top/api/v1/endpoints/statuses` (без CORS в браузере к внешнему хосту). При ошибке исходящего запроса — HTTP 500 и `{"error":"failed"}`. |
|
||||||
|
| `GET /api/radar/ping-dc` | TCP-подключение с **хоста шлюза** к каждому из пяти DC Telegram на порт **443**, таймаут **2 с**; JSON с полями `ok`, `results`, `from` (как в PHP-панели). |
|
||||||
|
|
||||||
|
В **Web UI** раздел **«Радар DC»**: путь `/radar`.
|
||||||
|
|
||||||
|
Опциональный блок в `config.yaml` (см. [config.example.yaml](../config.example.yaml)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
radar:
|
||||||
|
statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||||
|
http_timeout_ms: 30000
|
||||||
|
ping_from: "stats.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле `ping_from` задаёт строку `from` в ответе `ping-dc`; если пусто — используется заголовок `Host` запроса (без порта) или значение `telemt-gateway`.
|
||||||
|
|
||||||
|
Зарезервированы **только** пути `/api/radar/statuses` и `/api/radar/ping-dc`. Остальные URL вида `/api/radar/…` по-прежнему относятся к серверу с **alias** `radar` в `servers[]`, если такой задан.
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
| Переменная | Описание |
|
| Переменная | Описание |
|
||||||
|
|||||||
@@ -8,3 +8,17 @@ require (
|
|||||||
github.com/prometheus/client_golang v1.20.5
|
github.com/prometheus/client_golang v1.20.5
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.55.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
|
||||||
|
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -25,6 +25,17 @@ type Config struct {
|
|||||||
Servers []Server `yaml:"servers"`
|
Servers []Server `yaml:"servers"`
|
||||||
Aggregate *AggregateConfig `yaml:"aggregate"`
|
Aggregate *AggregateConfig `yaml:"aggregate"`
|
||||||
GeoIP *GeoIPConfig `yaml:"geoip"`
|
GeoIP *GeoIPConfig `yaml:"geoip"`
|
||||||
|
Radar *RadarConfig `yaml:"radar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadarConfig controls GET /api/radar/statuses and /api/radar/ping-dc (Telegram DC radar).
|
||||||
|
type RadarConfig struct {
|
||||||
|
// StatusesURL defaults to https://radar.telemt.top/api/v1/endpoints/statuses when empty.
|
||||||
|
StatusesURL string `yaml:"statuses_url"`
|
||||||
|
// HTTPTimeoutMs is outbound GET timeout for statuses; 0 means 30000.
|
||||||
|
HTTPTimeoutMs uint64 `yaml:"http_timeout_ms"`
|
||||||
|
// PingFrom is copied into JSON field "from" in ping-dc; empty uses request Host (no port) or "telemt-gateway".
|
||||||
|
PingFrom string `yaml:"ping_from"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional).
|
// GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional).
|
||||||
@@ -162,6 +173,17 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]")
|
return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.Radar != nil {
|
||||||
|
if c.Radar.HTTPTimeoutMs > 600000 {
|
||||||
|
return fmt.Errorf("radar.http_timeout_ms must be within [0, 600000]")
|
||||||
|
}
|
||||||
|
if u := strings.TrimSpace(c.Radar.StatusesURL); u != "" {
|
||||||
|
pu, err := url.Parse(u)
|
||||||
|
if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" {
|
||||||
|
return fmt.Errorf("radar.statuses_url: invalid URL %q", c.Radar.StatusesURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRadarStatusesURL(t *testing.T) {
|
||||||
|
c := &Config{
|
||||||
|
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
|
||||||
|
Radar: &RadarConfig{StatusesURL: "ftp://bad"},
|
||||||
|
}
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("expected error for invalid radar.statuses_url scheme")
|
||||||
|
}
|
||||||
|
c.Radar.StatusesURL = "https://radar.example/api"
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRadarHTTPTimeoutMs(t *testing.T) {
|
||||||
|
c := &Config{
|
||||||
|
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
|
||||||
|
Radar: &RadarConfig{HTTPTimeoutMs: 600001},
|
||||||
|
}
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("expected error for radar.http_timeout_ms > 600000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type Gateway struct {
|
|||||||
promHandler http.Handler
|
promHandler http.Handler
|
||||||
corsAllowed []string
|
corsAllowed []string
|
||||||
webUI http.Handler
|
webUI http.Handler
|
||||||
|
radarStatusesURL string
|
||||||
|
radarPingFrom string
|
||||||
|
radarHTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) {
|
func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) {
|
||||||
@@ -96,6 +99,21 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
|||||||
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
|
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
|
||||||
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
|
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
|
||||||
g.webUI = webui.Handler()
|
g.webUI = webui.Handler()
|
||||||
|
|
||||||
|
statusesURL := defaultRadarStatusesURL
|
||||||
|
radarTo := 30 * time.Second
|
||||||
|
if p.Config.Radar != nil {
|
||||||
|
if u := strings.TrimSpace(p.Config.Radar.StatusesURL); u != "" {
|
||||||
|
statusesURL = u
|
||||||
|
}
|
||||||
|
if p.Config.Radar.HTTPTimeoutMs > 0 {
|
||||||
|
radarTo = time.Duration(p.Config.Radar.HTTPTimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
g.radarPingFrom = strings.TrimSpace(p.Config.Radar.PingFrom)
|
||||||
|
}
|
||||||
|
g.radarStatusesURL = statusesURL
|
||||||
|
g.radarHTTPClient = &http.Client{Transport: t, Timeout: radarTo}
|
||||||
|
|
||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +260,13 @@ func routeEndpoint(path string) string {
|
|||||||
if path == "/api/agg" {
|
if path == "/api/agg" {
|
||||||
return "agg"
|
return "agg"
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(path, "/api/radar/") {
|
||||||
|
rest := strings.TrimPrefix(path, "/api/radar/")
|
||||||
|
if rest == "" {
|
||||||
|
return "radar"
|
||||||
|
}
|
||||||
|
return "radar_" + strings.ReplaceAll(rest, "/", "_")
|
||||||
|
}
|
||||||
if strings.HasPrefix(path, "/api/live/events") {
|
if strings.HasPrefix(path, "/api/live/events") {
|
||||||
return "live_events"
|
return "live_events"
|
||||||
}
|
}
|
||||||
@@ -291,6 +316,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.serveLiveEvents(w, r)
|
g.serveLiveEvents(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.URL.Path == "/api/radar/statuses" || r.URL.Path == "/api/radar/ping-dc" {
|
||||||
|
g.serveRadar(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||||
g.webUI.ServeHTTP(w, r)
|
g.webUI.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultRadarStatusesURL = "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||||
|
|
||||||
|
// telegramDC443 — IPv4 DC Telegram для MTProto (как в ping_proxy.php исходной панели).
|
||||||
|
var telegramDC443 = map[int]string{
|
||||||
|
1: "149.154.175.50",
|
||||||
|
2: "149.154.167.51",
|
||||||
|
3: "149.154.175.100",
|
||||||
|
4: "149.154.167.91",
|
||||||
|
5: "149.154.171.5",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) serveRadar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/radar/statuses":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.serveRadarStatuses(w, r)
|
||||||
|
case "/api/radar/ping-dc":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.serveRadarPingDC(w, r)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRadarStatuses проксирует JSON radar.telemt.top (аналог radar_proxy.php).
|
||||||
|
func (g *Gateway) serveRadarStatuses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, g.radarStatusesURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
writeRadarFailed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := g.radarHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeRadarFailed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
writeRadarFailed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/json"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRadarFailed(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "failed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRadarPingDC — TCP :443 до каждого DC с таймаутом 2 с (аналог ping_proxy.php).
|
||||||
|
func (g *Gateway) serveRadarPingDC(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from := strings.TrimSpace(g.radarPingFrom)
|
||||||
|
if from == "" {
|
||||||
|
from = hostOnly(r.Host)
|
||||||
|
if from == "" {
|
||||||
|
from = "telemt-gateway"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results := make(map[string]map[string]any, len(telegramDC443))
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
ip := telegramDC443[i]
|
||||||
|
addr := net.JoinHostPort(ip, "443")
|
||||||
|
t0 := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
|
ms := int(time.Since(t0).Milliseconds())
|
||||||
|
key := strconv.Itoa(i)
|
||||||
|
if err != nil {
|
||||||
|
results[key] = map[string]any{"ok": false, "ms": nil}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
results[key] = map[string]any{"ok": true, "ms": ms}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"results": results,
|
||||||
|
"from": from,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOnly(hostPort string) string {
|
||||||
|
hostPort = strings.TrimSpace(hostPort)
|
||||||
|
if hostPort == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// IPv6 в квадратных скобках: [::1]:8080
|
||||||
|
if strings.HasPrefix(hostPort, "[") {
|
||||||
|
if i := strings.IndexByte(hostPort, ']'); i > 0 {
|
||||||
|
return hostPort[1:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h, _, err := net.SplitHostPort(hostPort)
|
||||||
|
if err != nil {
|
||||||
|
return hostPort
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHostOnly(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"example.com:8080", "example.com"},
|
||||||
|
{"[::1]:443", "::1"},
|
||||||
|
{"127.0.0.1", "127.0.0.1"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := hostOnly(tt.in); got != tt.want {
|
||||||
|
t.Errorf("hostOnly(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,3 +357,52 @@ export async function deleteUser(
|
|||||||
}
|
}
|
||||||
return parsed as TelemtSuccess<string>;
|
return parsed as TelemtSuccess<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ответ GET /api/radar/ping-dc (как ping_proxy.php). */
|
||||||
|
export type RadarPingRow = { ok: boolean; ms: number | null };
|
||||||
|
export type RadarPingResponse = { ok: boolean; results: Record<string, RadarPingRow>; from: string };
|
||||||
|
|
||||||
|
/** Прокси к radar.telemt.top — массив эндпоинтов (как radar_proxy.php). */
|
||||||
|
export async function fetchRadarStatuses(): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${gatewayBase()}/api/radar/statuses`);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text) as { error?: string };
|
||||||
|
if (j?.error) msg = j.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new ApiError(msg, res.status, text);
|
||||||
|
}
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new ApiError('radar: ожидался JSON-массив', res.status, data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRadarPingDC(): Promise<RadarPingResponse> {
|
||||||
|
const res = await fetch(`${gatewayBase()}/api/radar/ping-dc`);
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(`ping-dc HTTP ${res.status}`, res.status, parsed);
|
||||||
|
}
|
||||||
|
const body = parsed as RadarPingResponse;
|
||||||
|
if (!body || body.ok !== true || !body.results) {
|
||||||
|
throw new ApiError('ping-dc: неверный ответ', res.status, parsed);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const SEG_LABELS: Record<string, string> = {
|
|||||||
ips: 'Уникальные IP',
|
ips: 'Уникальные IP',
|
||||||
incidents: 'Инциденты',
|
incidents: 'Инциденты',
|
||||||
live: 'Поток',
|
live: 'Поток',
|
||||||
|
radar: 'Радар DC',
|
||||||
mihomo: 'Mihomo',
|
mihomo: 'Mihomo',
|
||||||
servers: 'Серверы',
|
servers: 'Серверы',
|
||||||
runtime: 'Состояние',
|
runtime: 'Состояние',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
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 RadarIcon from '@lucide/svelte/icons/radar';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -104,6 +105,16 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton isActive={active('/radar')} tooltipContent="Радар DC Telegram">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href="/radar" {...props}>
|
||||||
|
<RadarIcon />
|
||||||
|
<span>Радар DC</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active('/mihomo')} tooltipContent="Mihomo (флот)">
|
<Sidebar.MenuButton isActive={active('/mihomo')} tooltipContent="Mihomo (флот)">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const SEG_LABELS: Record<string, string> = {
|
|||||||
ips: 'Уникальные IP',
|
ips: 'Уникальные IP',
|
||||||
incidents: 'Инциденты',
|
incidents: 'Инциденты',
|
||||||
live: 'Поток',
|
live: 'Поток',
|
||||||
|
radar: 'Радар DC',
|
||||||
mihomo: 'Mihomo',
|
mihomo: 'Mihomo',
|
||||||
servers: 'Серверы',
|
servers: 'Серверы',
|
||||||
runtime: 'Состояние',
|
runtime: 'Состояние',
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fetchRadarPingDC, fetchRadarStatuses, type RadarPingRow } from '$lib/api/client.js';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import RadarIcon from '@lucide/svelte/icons/radar';
|
||||||
|
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||||
|
import InfoIcon from '@lucide/svelte/icons/info';
|
||||||
|
|
||||||
|
type RadarEp = {
|
||||||
|
group?: string;
|
||||||
|
name?: string;
|
||||||
|
results?: { success?: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const dcs = ['DC1', 'DC2', 'DC3', 'DC4', 'DC5'] as const;
|
||||||
|
const locs: Record<(typeof dcs)[number], string> = {
|
||||||
|
DC1: '🇺🇸 США',
|
||||||
|
DC2: '🇳🇱 NL',
|
||||||
|
DC3: '🇺🇸 США',
|
||||||
|
DC4: '🇳🇱 NL',
|
||||||
|
DC5: '🇸🇬 SG'
|
||||||
|
};
|
||||||
|
|
||||||
|
const legendRows = [
|
||||||
|
{ dc: 'DC1', loc: '🇺🇸 США, Майами', desc: 'Основной для пользователей Америки. Сообщения, звонки.' },
|
||||||
|
{ dc: 'DC2', loc: '🇳🇱 Нидерланды', desc: 'Основной для большинства RU/EU. Сообщения, файлы, медиа.' },
|
||||||
|
{ dc: 'DC3', loc: '🇺🇸 США, Майами', desc: 'Резервный для DC1. Медиафайлы и CDN.' },
|
||||||
|
{ dc: 'DC4', loc: '🇳🇱 Нидерланды', desc: 'Резервный для DC2. Медиа и файлы EU пользователей.' },
|
||||||
|
{ dc: 'DC5', loc: '🇸🇬 Сингапур', desc: 'Основной для пользователей Азии и Океании.' },
|
||||||
|
{ dc: 'DC203', loc: '🌐 CDN', desc: 'Специальный CDN узел для ускорения загрузки медиа.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let radarLoading = $state(false);
|
||||||
|
let radarErr = $state<string | null>(null);
|
||||||
|
let radarUpdated = $state<string | null>(null);
|
||||||
|
let summaryHtml = $state<string>('');
|
||||||
|
let matrixHtml = $state<string>('');
|
||||||
|
|
||||||
|
let diagLoading = $state(false);
|
||||||
|
let pingByDc = $state<Record<number, RadarPingRow | null>>({
|
||||||
|
1: null,
|
||||||
|
2: null,
|
||||||
|
3: null,
|
||||||
|
4: null,
|
||||||
|
5: null
|
||||||
|
});
|
||||||
|
let pingFrom = $state<string | null>(null);
|
||||||
|
|
||||||
|
function buildMatrix(data: unknown[]) {
|
||||||
|
const regions: Record<string, { ok: number; total: number }> = {
|
||||||
|
SE: { ok: 0, total: 0 },
|
||||||
|
DE: { ok: 0, total: 0 },
|
||||||
|
RU: { ok: 0, total: 0 }
|
||||||
|
};
|
||||||
|
const matrix: Record<string, Record<string, boolean | null>> = {
|
||||||
|
SE: {},
|
||||||
|
DE: {},
|
||||||
|
RU: {}
|
||||||
|
};
|
||||||
|
const ruSeen: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
for (const raw of data) {
|
||||||
|
const ep = raw as RadarEp;
|
||||||
|
const g = ep.group ?? '';
|
||||||
|
const name = ep.name ?? '';
|
||||||
|
const results = ep.results;
|
||||||
|
const last =
|
||||||
|
results && results.length > 0 ? results[results.length - 1] : null;
|
||||||
|
const ok = last ? last.success : null;
|
||||||
|
|
||||||
|
if (!g.includes('TCP 443 IPv4') || name.includes('IPv6')) continue;
|
||||||
|
|
||||||
|
const dcM = name.match(/DC(\d+)/);
|
||||||
|
if (!dcM) continue;
|
||||||
|
const dcNum = 'DC' + dcM[1];
|
||||||
|
|
||||||
|
let region: string | null = null;
|
||||||
|
if (g.includes('from SE')) region = 'SE';
|
||||||
|
else if (g.includes('from DE')) region = 'DE';
|
||||||
|
else if (g.includes('from RU')) region = 'RU';
|
||||||
|
if (!region) continue;
|
||||||
|
|
||||||
|
if (region === 'RU') {
|
||||||
|
if (ruSeen[dcNum]) continue;
|
||||||
|
ruSeen[dcNum] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix[region][dcNum] = ok ?? null;
|
||||||
|
regions[region].total++;
|
||||||
|
if (ok) regions[region].ok++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: Record<string, { flag: string; name: string; note: string }> = {
|
||||||
|
SE: {
|
||||||
|
flag: '🇸🇪',
|
||||||
|
name: 'Швеция',
|
||||||
|
note: 'Референс из мониторинга radar.telemt.top (from SE)'
|
||||||
|
},
|
||||||
|
DE: { flag: '🇩🇪', name: 'Германия', note: 'Для сравнения (from DE)' },
|
||||||
|
RU: { flag: '🇷🇺', name: 'Россия', note: 'Напрямую (from RU)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
summaryHtml = (['SE', 'DE', 'RU'] as const)
|
||||||
|
.map((code) => {
|
||||||
|
const i = info[code];
|
||||||
|
const ok = regions[code].ok || 0;
|
||||||
|
const total = regions[code].total || 5;
|
||||||
|
const color = ok === total ? 'text-emerald-600' : ok === 0 ? 'text-red-600' : 'text-amber-600';
|
||||||
|
return `<div class="rounded-lg border border-border bg-card p-3 text-sm">
|
||||||
|
<div class="font-medium">${i.flag} ${i.name}</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold ${color}">${ok}/${total}</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-xs">${i.note}</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
let tbl = `<div class="overflow-x-auto"><table class="w-full min-w-[520px] border-collapse text-sm">
|
||||||
|
<thead><tr class="border-b border-border">
|
||||||
|
<th class="p-2 text-start font-medium">Из страны</th>
|
||||||
|
${dcs.map((dc) => `<th class="p-2 text-center font-medium">${dc}<br/><span class="text-muted-foreground text-xs font-normal">${locs[dc]}</span></th>`).join('')}
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
for (const row of [
|
||||||
|
{ code: 'SE', flag: '🇸🇪', label: 'Швеция' },
|
||||||
|
{ code: 'DE', flag: '🇩🇪', label: 'Германия' },
|
||||||
|
{ code: 'RU', flag: '🇷🇺', label: 'Россия' }
|
||||||
|
] as const) {
|
||||||
|
tbl += `<tr class="border-b border-border"><td class="p-2 whitespace-nowrap">${row.flag} ${row.label}</td>`;
|
||||||
|
for (const dc of dcs) {
|
||||||
|
const v = matrix[row.code][dc];
|
||||||
|
const color =
|
||||||
|
v === true ? 'text-emerald-600' : v === false ? 'text-red-600' : 'text-muted-foreground';
|
||||||
|
const title = v === true ? 'доступен' : v === false ? 'заблокирован' : 'нет данных';
|
||||||
|
tbl += `<td class="p-2 text-center ${color}" title="${title}">●</td>`;
|
||||||
|
}
|
||||||
|
tbl += '</tr>';
|
||||||
|
}
|
||||||
|
tbl += '</tbody></table></div>';
|
||||||
|
matrixHtml = tbl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRadar() {
|
||||||
|
radarLoading = true;
|
||||||
|
radarErr = null;
|
||||||
|
try {
|
||||||
|
const data = await fetchRadarStatuses();
|
||||||
|
buildMatrix(data);
|
||||||
|
radarUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch (e) {
|
||||||
|
radarErr = e instanceof Error ? e.message : String(e);
|
||||||
|
summaryHtml = '';
|
||||||
|
matrixHtml = '';
|
||||||
|
} finally {
|
||||||
|
radarLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDiag() {
|
||||||
|
diagLoading = true;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
pingByDc = { ...pingByDc, [i]: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await fetchRadarPingDC();
|
||||||
|
pingFrom = data.from;
|
||||||
|
const next: Record<number, RadarPingRow | null> = { ...pingByDc };
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const r = data.results[String(i)];
|
||||||
|
next[i] = r ?? null;
|
||||||
|
}
|
||||||
|
pingByDc = next;
|
||||||
|
} catch {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
pingByDc = { ...pingByDc, [i]: { ok: false, ms: null } };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
diagLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pingStyle(ms: number | null, ok: boolean): { text: string; barPct: number; color: string } {
|
||||||
|
if (!ok || ms === null) {
|
||||||
|
return { text: 'блок.', barPct: 100, color: 'bg-red-500' };
|
||||||
|
}
|
||||||
|
const color = ms < 100 ? 'bg-emerald-500' : ms < 300 ? 'bg-amber-500' : 'bg-red-500';
|
||||||
|
return { text: `${ms} мс`, barPct: Math.min(ms / 3, 100), color };
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadRadar();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RadarIcon class="text-primary size-8" />
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold tracking-tight">Радар DC Telegram</h1>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Источник матрицы: <span class="text-foreground">radar.telemt.top</span>
|
||||||
|
{#if radarUpdated}
|
||||||
|
· обновлено {radarUpdated}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" disabled={radarLoading} onclick={() => loadRadar()}>
|
||||||
|
<RefreshCwIcon class="mr-1 size-4 {radarLoading ? 'animate-spin' : ''}" />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if radarErr}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Ошибка загрузки радара</AlertTitle>
|
||||||
|
<AlertDescription>{radarErr}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-base">Сводка по регионам (TCP 443 IPv4)</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Матрица ниже строится из ответа шлюза <code class="text-xs">/api/radar/statuses</code> (прокси к
|
||||||
|
radar.telemt.top).
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if radarLoading && !summaryHtml}
|
||||||
|
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
||||||
|
{:else if summaryHtml}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">{@html summaryHtml}</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-base">Матрица доступности</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
<span class="text-emerald-600">●</span> доступен
|
||||||
|
<span class="text-red-600 ms-2">●</span> заблокирован
|
||||||
|
<span class="text-muted-foreground ms-2">●</span> нет данных
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if radarLoading && !matrixHtml}
|
||||||
|
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
||||||
|
{:else if matrixHtml}
|
||||||
|
{@html matrixHtml}
|
||||||
|
{/if}
|
||||||
|
<Alert class="mt-4">
|
||||||
|
<InfoIcon class="size-4" />
|
||||||
|
<AlertTitle class="text-sm">Про ICMP и TCP 443</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">
|
||||||
|
ICMP может проходить даже при блокировке MTProto по TCP 443. Для Telegram важна доступность
|
||||||
|
TCP 443 до DC — её отражает radar и тест ниже.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-base">
|
||||||
|
<ActivityIcon class="size-4" />
|
||||||
|
Диагностика TCP с хоста шлюза
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Запрос <code class="text-xs">/api/radar/ping-dc</code> — TCP :443 до каждого DC с таймаутом 2 с
|
||||||
|
(как <code class="text-xs">ping_proxy.php</code>). Поле «from» в ответе: источник метки на
|
||||||
|
сервере.
|
||||||
|
{#if pingFrom}
|
||||||
|
<span class="mt-1 block text-foreground">from: {pingFrom}</span>
|
||||||
|
{/if}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<Button disabled={diagLoading} onclick={() => runDiag()}>
|
||||||
|
{diagLoading ? 'Тестирование…' : 'Запустить тест'}
|
||||||
|
</Button>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||||
|
{@const row = pingByDc[n]}
|
||||||
|
{@const st = row ? pingStyle(row.ms, row.ok) : { text: '—', barPct: 0, color: 'bg-muted' }}
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div class="w-24 shrink-0 text-sm font-medium">DC{n}</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="bg-muted h-2 overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full transition-all {st.color}"
|
||||||
|
style="width: {row ? st.barPct : 0}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28 shrink-0 text-sm tabular-nums {row?.ok ? '' : 'text-red-600'}">
|
||||||
|
{diagLoading && !row ? '…' : st.text}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground w-32 shrink-0 text-xs">
|
||||||
|
{#if row}
|
||||||
|
{row.ok ? 'доступен' : 'заблокирован'}
|
||||||
|
{:else if !diagLoading}
|
||||||
|
ожидание
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-base">Справка — датацентры Telegram</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="rounded-md border border-border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="w-24">DC</Table.Head>
|
||||||
|
<Table.Head>Локация</Table.Head>
|
||||||
|
<Table.Head>Назначение</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each legendRows as row (row.dc)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-sm">{row.dc}</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">{row.loc}</Table.Cell>
|
||||||
|
<Table.Cell class="text-muted-foreground text-sm">{row.desc}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground mt-3 text-xs">
|
||||||
|
Telegram назначает DC при регистрации; при блокировке своего DC нужен прокси в регионе, где DC
|
||||||
|
доступен по TCP 443.
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user