Compare commits
50
Commits
deb12e72c0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1095081bc | ||
|
|
ea2ecb44d2 | ||
|
|
d021e4b1d7 | ||
|
|
6b2e38d051 | ||
|
|
7c6892ce0e | ||
|
|
bfe79ca601 | ||
|
|
c51482e04e | ||
|
|
4ee9c6805c | ||
|
|
b68ca985d3 | ||
|
|
12539a22ae | ||
|
|
af37331284 | ||
|
|
68b74a861d | ||
|
|
8b996c6e25 | ||
|
|
3ce37b6067 | ||
|
|
7ae3c5a806 | ||
|
|
9db5a1e97c | ||
|
|
2373ed388b | ||
|
|
8ad627cb9b | ||
|
|
297fb79f11 | ||
|
|
ba9d2fc374 | ||
|
|
7c731b93c1 | ||
|
|
5564ab8a62 | ||
|
|
e1f16799ba | ||
|
|
4eb53c91c9 | ||
|
|
dade8253c4 | ||
|
|
81331dd406 | ||
|
|
edd0d7c25e | ||
|
|
d6e3b4561b | ||
|
|
dbbbf41a1a | ||
|
|
6f5f123aa8 | ||
|
|
c8724268e9 | ||
|
|
31d0db57f1 | ||
|
|
4f80a8c0d1 | ||
|
|
1c919a636e | ||
|
|
d5d0326bf3 | ||
|
|
e4e164aee9 | ||
|
|
9533241624 | ||
|
|
8e616cd1c4 | ||
|
|
89b6cc5185 | ||
|
|
054f58b071 | ||
|
|
ed015bd4ee | ||
|
|
5cfbf7f6a1 | ||
|
|
56832e525d | ||
|
|
2fd98f8c3d | ||
|
|
86a5360f56 | ||
|
|
55354d3270 | ||
|
|
398c776238 | ||
|
|
206fee9ff9 | ||
|
|
e2d89f2d11 | ||
|
|
8bf4bb7f35 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# 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) (в т.ч. `GET /api/agg/radar-telemt-dcs` — `stats/dcs` по всем нодам для радара), 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)
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
listen: ":8080"
|
||||
|
||||
# Диагностика: при 502 от прокси в JSON добавляется error.detail с текстом ошибки RoundTrip (TLS, DNS, таймаут).
|
||||
# Включайте только во внутренней сети — строка может содержать хосты/порты upstream.
|
||||
# expose_upstream_errors: true
|
||||
|
||||
# If true, IP whitelist is not enforced (development only).
|
||||
allow_all: false
|
||||
|
||||
@@ -39,6 +43,12 @@ trusted_proxies: []
|
||||
# - gt2
|
||||
# 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 не нужен)
|
||||
# geoip:
|
||||
# enabled: true
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
|
||||
- **`GET /api/agg/fleet-status`** дополнительно вызывает на каждом upstream **`GET /v1/health`** и **`GET /v1/system/info`** (параллельно по серверам).
|
||||
- **`GET /api/agg/radar-telemt-dcs`** — на каждом upstream параллельно **`GET /v1/stats/dcs`** (снимок ME / DC для панели «Радар DC»); см. [API.md](API.md) про `minimal_runtime_enabled` и поля `DcStatusData`.
|
||||
|
||||
**Единицы трафика в агрегатах:** поля `*_megabytes` — это **двоичные мегабайты (MiB)**, 1 MiB = 1024² октетов (как у Telemt в ответе считаются октеты, шлюз делит на MiB для удобства).
|
||||
|
||||
@@ -33,6 +34,7 @@
|
||||
| GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). |
|
||||
| GET | `/api/agg/user/{username}` | Один пользователь в том же формате, что элементы `/api/agg/users` (без списка всех). Имя в пути: `[A-Za-z0-9_.-]+`. Ответ **`404`**, если пользователь не найден ни на одном успешном upstream. |
|
||||
| GET | `/api/agg/fleet-status` | По каждому алиасу: параллельно health + system/info; в `data.servers[]` — статусы подзапросов и тела `health` / `system_info` при успехе. См. [AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml). |
|
||||
| GET | `/api/agg/radar-telemt-dcs` | По каждому алиасу: параллельно `GET /v1/stats/dcs`; в `data.servers[]` — `alias`, `ok`, при успехе объект `data` (поля `middle_proxy_enabled`, `reason`, `dcs[]` с `dc`, `coverage_pct`, `rtt_ms` и т.д.). |
|
||||
| GET | `/api/agg/incidents` | Нормализованный snapshot инцидентов для triage-панели: `critical/warning/info`, `affected_aliases`, рекомендуемые `actions` (runbook/deep links), счётчики по severity. |
|
||||
|
||||
Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза.
|
||||
|
||||
@@ -96,6 +96,18 @@ servers:
|
||||
| `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` в конфиге ядра). |
|
||||
|
||||
У **нескольких** записей `servers[]` можно задать **разные** имена переменных (`mihomo_authorization_env: TELEMT_MIHOMO_MTG` и `…_GT1`), если секреты контроллеров на нодах различаются. Одно и то же имя env для всех нод — нормально, если везде один и тот же токен.
|
||||
|
||||
#### Локальный Mihomo (HTTP в Docker) работает, удалённый (HTTPS) даёт 502 через шлюз
|
||||
|
||||
Прямой `curl` с хоста к `https://…:8443` может быть успешен, а шлюз при этом отдаёт `502` с `mihomo upstream unreachable`: исходящий запрос делает **процесс шлюза** (часто контейнер). Отличия от «рабочего» `curl`:
|
||||
|
||||
- другая сеть/DNS из контейнера;
|
||||
- другой исходящий IP на стороне nginx Mihomo (whitelist `allow`);
|
||||
- ошибка TLS при проверке сертификата из окружения процесса.
|
||||
|
||||
Включите в `config.yaml` **`expose_upstream_errors: true`**, перезапустите шлюз и повторите запрос: в JSON появится **`error.detail`** с текстом ошибки (`x509: …`, `dial tcp …`, `lookup …` и т.д.). После диагностики флаг отключите.
|
||||
|
||||
Правила:
|
||||
|
||||
- Если указан `mihomo_base_url` или `mihomo_base_url_env`, обязательно задайте `mihomo_authorization_env` и непустые значения в env при старте шлюза.
|
||||
@@ -134,6 +146,32 @@ services:
|
||||
- `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
|
||||
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=
|
||||
@@ -0,0 +1,69 @@
|
||||
package aggregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// effectiveConnectionsBad возвращает значение для порогов инцидентов: прирост от последнего сброса.
|
||||
func (h *Handler) effectiveConnectionsBad(alias string, current uint64) uint64 {
|
||||
h.badConnBaselineMu.Lock()
|
||||
defer h.badConnBaselineMu.Unlock()
|
||||
b, ok := h.badConnBaseline[alias]
|
||||
if !ok {
|
||||
return current
|
||||
}
|
||||
if current < b {
|
||||
// Счётчик на ноде уменьшился (перезапуск и т.п.) — старая отметка неприменима.
|
||||
delete(h.badConnBaseline, alias)
|
||||
return current
|
||||
}
|
||||
return current - b
|
||||
}
|
||||
|
||||
func (h *Handler) setBadConnectionBaseline(alias string, snapshot uint64) {
|
||||
h.badConnBaselineMu.Lock()
|
||||
defer h.badConnBaselineMu.Unlock()
|
||||
if h.badConnBaseline == nil {
|
||||
h.badConnBaseline = make(map[string]uint64)
|
||||
}
|
||||
h.badConnBaseline[alias] = snapshot
|
||||
}
|
||||
|
||||
// HandleResetBadConnectionsBaseline — POST /api/agg/bad-connections/reset
|
||||
// Сохраняет текущий connections_bad_total по каждой выбранной ноде как нулевую отметку для алертов.
|
||||
func (h *Handler) HandleResetBadConnectionsBaseline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
writeBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type row struct {
|
||||
Alias string `json:"alias"`
|
||||
OK bool `json:"ok"`
|
||||
Baseline uint64 `json:"baseline,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
rows := make([]row, 0, len(aliases))
|
||||
partial := false
|
||||
for _, alias := range aliases {
|
||||
data, meta := FetchTelemtGET[statsSummaryLite](ctx, h.Client, h.Parsed, alias, "stats/summary")
|
||||
if !meta.OK {
|
||||
partial = true
|
||||
rows = append(rows, row{Alias: alias, OK: false, Error: meta.Error})
|
||||
continue
|
||||
}
|
||||
h.setBadConnectionBaseline(alias, data.ConnectionsBadTotal)
|
||||
rows = append(rows, row{Alias: alias, OK: true, Baseline: data.ConnectionsBadTotal})
|
||||
}
|
||||
writeAggOK(w, partial, map[string]any{"servers": rows})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package aggregate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEffectiveConnectionsBad(t *testing.T) {
|
||||
h := &Handler{}
|
||||
|
||||
if got := h.effectiveConnectionsBad("a", 500); got != 500 {
|
||||
t.Fatalf("without baseline want 500, got %d", got)
|
||||
}
|
||||
|
||||
h.setBadConnectionBaseline("a", 400)
|
||||
if got := h.effectiveConnectionsBad("a", 900); got != 500 {
|
||||
t.Fatalf("with baseline 400 want delta 500, got %d", got)
|
||||
}
|
||||
|
||||
h.setBadConnectionBaseline("b", 1000)
|
||||
if got := h.effectiveConnectionsBad("b", 500); got != 500 {
|
||||
t.Fatalf("after counter drop want raw value 500, got %d", got)
|
||||
}
|
||||
if _, ok := h.badConnBaseline["b"]; ok {
|
||||
t.Fatal("baseline for b should be cleared when current < baseline")
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ type Handler struct {
|
||||
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
|
||||
// badConnBaseline хранит снимок connections_bad_total на момент сброса в UI;
|
||||
// инциденты bad_connections_* считаются по приросту от этой отметки (в памяти процесса шлюза).
|
||||
badConnBaselineMu sync.Mutex
|
||||
badConnBaseline map[string]uint64
|
||||
}
|
||||
|
||||
// ResolveAliasesForLive resolves aliases for external endpoints (SSE/live).
|
||||
@@ -113,6 +118,8 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, sub string) {
|
||||
h.handleUsers(w, r)
|
||||
case sub == "fleet-status":
|
||||
h.handleFleetStatus(w, r)
|
||||
case sub == "radar-telemt-dcs":
|
||||
h.handleRadarTelemtDcs(w, r)
|
||||
case sub == "incidents":
|
||||
h.handleIncidents(w, r)
|
||||
case strings.HasPrefix(sub, "user/"):
|
||||
@@ -275,6 +282,25 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRadarTelemtDcs(w http.ResponseWriter, r *http.Request) {
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
writeBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
data := FetchRadarTelemtDcs(ctx, h.Client, h.Parsed, aliases)
|
||||
partial := false
|
||||
for _, s := range data.Servers {
|
||||
if !s.OK {
|
||||
partial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleFleetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -113,6 +113,65 @@ func TestHandlerFleetStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRadarTelemtDcs(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/stats/dcs" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"data": map[string]any{
|
||||
"middle_proxy_enabled": true,
|
||||
"generated_at_epoch_secs": 1,
|
||||
"dcs": []map[string]any{{
|
||||
"dc": 1, "rtt_ms": 42.5, "coverage_pct": 100.0,
|
||||
"alive_writers": 3, "required_writers": 3, "load": 0,
|
||||
}},
|
||||
},
|
||||
"revision": "rd",
|
||||
})
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Servers: []config.Server{
|
||||
{Alias: "test", BaseURL: up.URL, PathPrefix: "/v1"},
|
||||
},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := cfg.Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewHandler(parsed, up.Client(), nil, 0)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agg/radar-telemt-dcs?aliases=test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data RadarTelemtDcsData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !env.OK || len(env.Data.Servers) != 1 {
|
||||
t.Fatalf("envelope: %+v", env)
|
||||
}
|
||||
row := env.Data.Servers[0]
|
||||
if row.Alias != "test" || !row.OK || row.Data == nil || !row.Data.MiddleProxyEnabled {
|
||||
t.Fatalf("row: %+v", row)
|
||||
}
|
||||
if len(row.Data.Dcs) != 1 || row.Data.Dcs[0].DC != 1 {
|
||||
t.Fatalf("dcs: %+v", row.Data.Dcs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerUserOne(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/stats/users" {
|
||||
|
||||
@@ -65,7 +65,8 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
||||
partial = true
|
||||
continue
|
||||
}
|
||||
if data.ConnectionsBadTotal >= 10000 {
|
||||
badEff := h.effectiveConnectionsBad(alias, data.ConnectionsBadTotal)
|
||||
if badEff >= 10000 {
|
||||
out.Items = append(out.Items, IncidentItem{
|
||||
ID: fmt.Sprintf("bad_connections_high:%s", alias),
|
||||
Kind: "bad_connections_high",
|
||||
@@ -75,13 +76,13 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
||||
Summary: "Резкий рост ошибок клиентских соединений",
|
||||
AffectedAliases: []string{alias},
|
||||
MetricName: "connections_bad_total",
|
||||
MetricValue: float64(data.ConnectionsBadTotal),
|
||||
MetricValue: float64(badEff),
|
||||
MetricThreshold: 10000,
|
||||
Actions: []IncidentAction{
|
||||
{Label: "Node dashboard", Href: "/servers/" + alias},
|
||||
},
|
||||
})
|
||||
} else if data.ConnectionsBadTotal >= 1000 {
|
||||
} else if badEff >= 1000 {
|
||||
out.Items = append(out.Items, IncidentItem{
|
||||
ID: fmt.Sprintf("bad_connections_warn:%s", alias),
|
||||
Kind: "bad_connections_warn",
|
||||
@@ -91,7 +92,7 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
||||
Summary: "Наблюдается рост ошибок клиентских соединений",
|
||||
AffectedAliases: []string{alias},
|
||||
MetricName: "connections_bad_total",
|
||||
MetricValue: float64(data.ConnectionsBadTotal),
|
||||
MetricValue: float64(badEff),
|
||||
MetricThreshold: 1000,
|
||||
Actions: []IncidentAction{
|
||||
{Label: "Node dashboard", Href: "/servers/" + alias},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package aggregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/telemt/telemt-api/internal/config"
|
||||
)
|
||||
|
||||
const statsDcsPath = "stats/dcs"
|
||||
|
||||
// FetchRadarTelemtDcs calls GET /v1/stats/dcs on each Telemt in parallel.
|
||||
func FetchRadarTelemtDcs(ctx context.Context, client *http.Client, parsed *config.Parsed, aliases []string) RadarTelemtDcsData {
|
||||
if len(aliases) == 0 {
|
||||
return RadarTelemtDcsData{}
|
||||
}
|
||||
rows := make([]RadarTelemtDcsServer, len(aliases))
|
||||
var wg sync.WaitGroup
|
||||
for i, alias := range aliases {
|
||||
i, alias := i, alias
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
data, meta := FetchTelemtGET[DcStatusPayload](ctx, client, parsed, alias, statsDcsPath)
|
||||
row := RadarTelemtDcsServer{
|
||||
Alias: alias,
|
||||
OK: meta.OK,
|
||||
HTTPStatus: meta.HTTPStatus,
|
||||
LatencyMs: meta.LatencyMs,
|
||||
Error: meta.Error,
|
||||
Revision: meta.Revision,
|
||||
}
|
||||
if meta.OK {
|
||||
row.Data = &data
|
||||
}
|
||||
rows[i] = row
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Alias < rows[j].Alias })
|
||||
return RadarTelemtDcsData{Servers: rows}
|
||||
}
|
||||
@@ -206,3 +206,37 @@ type TopUserByUniqueIPs struct {
|
||||
Username string `json:"username"`
|
||||
UniqueIPs uint64 `json:"unique_ips"`
|
||||
}
|
||||
|
||||
// DcStatusRow mirrors Telemt GET /v1/stats/dcs data.dcs[] (subset for radar UI).
|
||||
type DcStatusRow struct {
|
||||
DC int `json:"dc"`
|
||||
RttMs *float64 `json:"rtt_ms"`
|
||||
CoveragePct float64 `json:"coverage_pct"`
|
||||
AliveWriters int `json:"alive_writers"`
|
||||
RequiredWriters int `json:"required_writers"`
|
||||
Load int `json:"load"`
|
||||
}
|
||||
|
||||
// DcStatusPayload mirrors Telemt GET /v1/stats/dcs data object.
|
||||
type DcStatusPayload struct {
|
||||
MiddleProxyEnabled bool `json:"middle_proxy_enabled"`
|
||||
Reason *string `json:"reason"`
|
||||
GeneratedAtEpochSecs uint64 `json:"generated_at_epoch_secs"`
|
||||
Dcs []DcStatusRow `json:"dcs"`
|
||||
}
|
||||
|
||||
// RadarTelemtDcsServer is one upstream stats/dcs outcome for /api/agg/radar-telemt-dcs.
|
||||
type RadarTelemtDcsServer struct {
|
||||
Alias string `json:"alias"`
|
||||
OK bool `json:"ok"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Data *DcStatusPayload `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// RadarTelemtDcsData is aggregate payload for /api/agg/radar-telemt-dcs.
|
||||
type RadarTelemtDcsData struct {
|
||||
Servers []RadarTelemtDcsServer `json:"servers"`
|
||||
}
|
||||
@@ -15,14 +15,27 @@ var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// Config is the gateway YAML configuration.
|
||||
type Config struct {
|
||||
Listen string `yaml:"listen"`
|
||||
AllowAll bool `yaml:"allow_all"`
|
||||
Listen string `yaml:"listen"`
|
||||
// ExposeUpstreamErrors puts RoundTrip error text in JSON 502 bodies (error.detail). For private ops only.
|
||||
ExposeUpstreamErrors bool `yaml:"expose_upstream_errors"`
|
||||
AllowAll bool `yaml:"allow_all"`
|
||||
WhitelistCIDRs []string `yaml:"whitelist_cidrs"`
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
CorsAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||
Servers []Server `yaml:"servers"`
|
||||
Aggregate *AggregateConfig `yaml:"aggregate"`
|
||||
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).
|
||||
@@ -160,6 +173,17 @@ func (c *Config) Validate() error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
+49
-12
@@ -34,6 +34,22 @@ type Gateway struct {
|
||||
promHandler http.Handler
|
||||
corsAllowed []string
|
||||
webUI http.Handler
|
||||
radarStatusesURL string
|
||||
radarPingFrom string
|
||||
radarHTTPClient *http.Client
|
||||
}
|
||||
|
||||
func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
errObj := map[string]any{"code": code, "message": message}
|
||||
if expose && upstreamErr != nil {
|
||||
errObj["detail"] = upstreamErr.Error()
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": false,
|
||||
"error": errObj,
|
||||
})
|
||||
}
|
||||
|
||||
// NewGateway builds handlers and reverse proxies from parsed config.
|
||||
@@ -54,6 +70,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
transport: t,
|
||||
promHandler: promhttp.Handler(),
|
||||
}
|
||||
exposeErr := p.Config.ExposeUpstreamErrors
|
||||
for i := range p.Config.Servers {
|
||||
s := &p.Config.Servers[i]
|
||||
u, err := url.Parse(s.BaseURL)
|
||||
@@ -64,12 +81,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
strip := "/api/" + s.Alias
|
||||
g.proxies[s.Alias] = proxy.NewAliasForward(u, strip, s.PathPrefix, auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Error("upstream error", "alias", s.Alias, "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": "upstream unreachable"},
|
||||
})
|
||||
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "upstream unreachable", err)
|
||||
})
|
||||
}
|
||||
for alias, m := range p.MihomoByAlias {
|
||||
@@ -77,12 +89,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
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"},
|
||||
})
|
||||
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "mihomo upstream unreachable", err)
|
||||
})
|
||||
}
|
||||
var aggCacheTTL time.Duration
|
||||
@@ -92,6 +99,21 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
|
||||
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -238,6 +260,13 @@ func routeEndpoint(path string) string {
|
||||
if path == "/api/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") {
|
||||
return "live_events"
|
||||
}
|
||||
@@ -279,6 +308,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
proxy.NormalizeRequestURLPath(r)
|
||||
}
|
||||
const prefix = "/api/"
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/agg/bad-connections/reset" {
|
||||
g.agg.HandleResetBadConnectionsBaseline(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") {
|
||||
g.agg.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -287,6 +320,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
g.serveLiveEvents(w, r)
|
||||
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) {
|
||||
g.webUI.ServeHTTP(w, r)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,50 @@ export async function fetchAggFleetStatus(params?: { aliases?: string }): Promis
|
||||
return body as AggEnvelope<components['schemas']['FleetStatusData']>;
|
||||
}
|
||||
|
||||
/** Payload GET /api/agg/radar-telemt-dcs (снимок GET /v1/stats/dcs с каждой ноды). */
|
||||
export type AggRadarTelemtDcsRow = {
|
||||
alias: string;
|
||||
ok: boolean;
|
||||
http_status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
revision?: string;
|
||||
data?: {
|
||||
middle_proxy_enabled: boolean;
|
||||
reason?: string | null;
|
||||
generated_at_epoch_secs?: number;
|
||||
dcs?: {
|
||||
dc: number;
|
||||
rtt_ms?: number | null;
|
||||
coverage_pct: number;
|
||||
alive_writers: number;
|
||||
required_writers: number;
|
||||
load: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type AggRadarTelemtDcsData = {
|
||||
servers: AggRadarTelemtDcsRow[];
|
||||
};
|
||||
|
||||
export async function fetchAggRadarTelemtDcs(params?: {
|
||||
aliases?: string;
|
||||
}): Promise<AggEnvelope<AggRadarTelemtDcsData>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.aliases) q.set('aliases', params.aliases);
|
||||
const url = `${gatewayBase()}/api/agg/radar-telemt-dcs${q.toString() ? `?${q}` : ''}`;
|
||||
const res = await fetch(url);
|
||||
const body = (await parseJson(res)) as Record<string, unknown> | null;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`radar-telemt-dcs HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
if (!body || body.ok !== true) {
|
||||
throw new ApiError('radar-telemt-dcs: ok !== true', res.status, body);
|
||||
}
|
||||
return body as AggEnvelope<AggRadarTelemtDcsData>;
|
||||
}
|
||||
|
||||
export async function fetchAggUniqueIps(params?: {
|
||||
aliases?: string;
|
||||
geo?: boolean;
|
||||
@@ -238,6 +282,35 @@ export async function fetchAggIncidents(params?: { aliases?: string }): Promise<
|
||||
return body as AggEnvelope<IncidentsData>;
|
||||
}
|
||||
|
||||
export type ResetBadConnectionsRow = {
|
||||
alias: string;
|
||||
ok: boolean;
|
||||
baseline?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ResetBadConnectionsData = {
|
||||
servers: ResetBadConnectionsRow[];
|
||||
};
|
||||
|
||||
/** POST: зафиксировать текущий connections_bad_total как «ноль» для алертов bad connections на шлюзе. */
|
||||
export async function postAggResetBadConnectionsBaseline(params?: {
|
||||
aliases?: string;
|
||||
}): Promise<AggEnvelope<ResetBadConnectionsData>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.aliases) q.set('aliases', params.aliases);
|
||||
const url = `${gatewayBase()}/api/agg/bad-connections/reset${q.toString() ? `?${q}` : ''}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
const body = (await parseJson(res)) as Record<string, unknown> | null;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`bad-connections/reset HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
if (!body || body.ok !== true) {
|
||||
throw new ApiError('bad-connections/reset: ok !== true', res.status, body);
|
||||
}
|
||||
return body as AggEnvelope<ResetBadConnectionsData>;
|
||||
}
|
||||
|
||||
export function liveEventsUrl(params?: { aliases?: string }): string {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.aliases) q.set('aliases', params.aliases);
|
||||
@@ -357,3 +430,52 @@ export async function deleteUser(
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type BreadcrumbItem = { href: string; label: string; current?: boolean };
|
||||
|
||||
const SEG_LABELS: Record<string, string> = {
|
||||
users: 'Пользователи',
|
||||
ips: 'Уникальные IP',
|
||||
incidents: 'Инциденты',
|
||||
live: 'Поток',
|
||||
radar: 'Радар DC',
|
||||
mihomo: 'Mihomo',
|
||||
servers: 'Серверы',
|
||||
runtime: 'Состояние',
|
||||
security: 'Безопасность',
|
||||
upstreams: 'Апстримы и ДЦ',
|
||||
config: 'Конфигурация',
|
||||
update: 'Обновление'
|
||||
};
|
||||
|
||||
function segLabel(seg: string): string {
|
||||
return SEG_LABELS[seg] ?? decodeURIComponent(seg);
|
||||
}
|
||||
|
||||
export function breadcrumbsFromPath(pathname: string): BreadcrumbItem[] {
|
||||
const p = pathname || '/';
|
||||
if (p === '/') {
|
||||
return [{ href: '/', label: 'Обзор', current: true }];
|
||||
}
|
||||
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
const items: BreadcrumbItem[] = [{ href: '/', label: 'Обзор' }];
|
||||
let acc = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const seg = parts[i];
|
||||
acc += `/${seg}`;
|
||||
items.push({
|
||||
href: acc,
|
||||
label: segLabel(seg),
|
||||
current: i === parts.length - 1
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/** Цвета для Chart.js из CSS-переменных темы (светлая/тёмная). */
|
||||
|
||||
export function readCssVar(name: string): string {
|
||||
if (typeof document === 'undefined') return '';
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
export function chartTickColor(): string {
|
||||
return readCssVar('--muted-foreground') || 'oklch(0.552 0.016 285.938)';
|
||||
}
|
||||
|
||||
export function chartGridColor(): string {
|
||||
const b = readCssVar('--border');
|
||||
return b ? `color-mix(in oklch, ${b} 45%, transparent)` : 'oklch(0.5 0.02 285 / 0.12)';
|
||||
}
|
||||
|
||||
export function chartSeriesColors(): string[] {
|
||||
return [
|
||||
readCssVar('--chart-1') || '#6366f1',
|
||||
readCssVar('--chart-2') || '#22c55e',
|
||||
readCssVar('--chart-3') || '#eab308',
|
||||
readCssVar('--chart-4') || '#f97316',
|
||||
readCssVar('--chart-5') || '#ec4899'
|
||||
];
|
||||
}
|
||||
|
||||
export function chartLegendColor(): string {
|
||||
return chartTickColor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет альфа-канал к цвету из темы (oklch/hex/rgb).
|
||||
* Нельзя склеивать `oklch(...) + "33"` — в canvas это даёт невалидный цвет и чёрную заливку.
|
||||
*/
|
||||
export function chartColorWithAlpha(base: string, alpha: number): string {
|
||||
const t = base.trim();
|
||||
const indigo = `rgba(99, 102, 241, ${alpha})`;
|
||||
if (!t) return indigo;
|
||||
|
||||
const oklch = /^oklch\(\s*(.+)\s*\)$/i.exec(t);
|
||||
if (oklch) {
|
||||
let inner = oklch[1].trim().replace(/\s*\/\s*[\d.]+%?\s*$/, '').trim();
|
||||
return `oklch(${inner} / ${alpha})`;
|
||||
}
|
||||
|
||||
if (t.startsWith('#')) {
|
||||
let h = t.slice(1);
|
||||
if (h.length === 3) {
|
||||
h = [...h].map((c) => c + c).join('');
|
||||
}
|
||||
if (h.length === 6 || h.length === 8) {
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^rgba?\(/i.test(t)) {
|
||||
const m = t.match(/rgba?\(\s*([^)]+)\s*\)/i);
|
||||
if (m) {
|
||||
const parts = m[1].split(',').map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `color-mix(in srgb, ${t} ${Math.round(alpha * 100)}%, transparent)`;
|
||||
}
|
||||
|
||||
/** Лёгкая заливка под линией: сверху чуть насыщеннее, к низу к прозрачному. */
|
||||
export function chartLineAreaGradient(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
chartArea: { top: number; bottom: number },
|
||||
lineColor: string
|
||||
): CanvasGradient {
|
||||
const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
||||
const c = lineColor.trim() || '#6366f1';
|
||||
g.addColorStop(0, chartColorWithAlpha(c, 0.16));
|
||||
g.addColorStop(0.45, chartColorWithAlpha(c, 0.045));
|
||||
g.addColorStop(1, chartColorWithAlpha(c, 0));
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Callback для Chart.js `dataset.backgroundColor` у линейных графиков с заливкой. */
|
||||
export function chartLineAreaFillCallback(lineColor: string) {
|
||||
return (context: { chart: { ctx: CanvasRenderingContext2D; chartArea?: { top: number; bottom: number } } }) => {
|
||||
const { chart } = context;
|
||||
const area = chart.chartArea;
|
||||
if (!area) return 'transparent';
|
||||
return chartLineAreaGradient(chart.ctx, area, lineColor);
|
||||
};
|
||||
}
|
||||
@@ -1,41 +1,43 @@
|
||||
<script lang="ts">
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
availableAliases = [],
|
||||
value = $bindable("all"),
|
||||
label = "Alias",
|
||||
allLabel = "All nodes"
|
||||
allLabel = "All nodes",
|
||||
onValueChange
|
||||
}: {
|
||||
availableAliases: string[];
|
||||
value?: string;
|
||||
label?: string;
|
||||
allLabel?: string;
|
||||
onValueChange?: (next: string) => void;
|
||||
} = $props();
|
||||
|
||||
let prev = value || "all";
|
||||
|
||||
$effect(() => {
|
||||
if ((value ?? "all") !== "all" && !availableAliases.includes(value ?? "all")) {
|
||||
value = "all";
|
||||
}
|
||||
if (!value) {
|
||||
value = "all";
|
||||
return;
|
||||
}
|
||||
if (value !== prev) {
|
||||
prev = value;
|
||||
untrack(() => onValueChange?.(value));
|
||||
}
|
||||
});
|
||||
|
||||
let selectedLabel = $derived((value ?? "all") === "all" ? allLabel : (value ?? "all"));
|
||||
</script>
|
||||
|
||||
<label class="text-xs text-muted-foreground">
|
||||
{label}
|
||||
<Select.Root type="single" bind:value>
|
||||
<Select.Trigger class="mt-1 w-[220px]">
|
||||
{selectedLabel}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="z-[1200]">
|
||||
<Select.Item value="all">{allLabel}</Select.Item>
|
||||
{#each availableAliases as alias (alias)}
|
||||
<Select.Item value={alias}>{alias}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<select
|
||||
class="border-input bg-background mt-1 h-8 w-[220px] rounded-lg border px-2.5 text-sm outline-none focus-visible:border-ring"
|
||||
bind:value
|
||||
>
|
||||
<option value="all">{allLabel}</option>
|
||||
{#each availableAliases as alias (alias)}
|
||||
<option value={alias}>{alias}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
@@ -12,6 +12,7 @@
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import SirenIcon from '@lucide/svelte/icons/siren';
|
||||
import RadioIcon from '@lucide/svelte/icons/radio';
|
||||
import RadarIcon from '@lucide/svelte/icons/radar';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
|
||||
let {
|
||||
@@ -85,7 +86,7 @@
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents">
|
||||
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Инциденты">
|
||||
{#snippet child({ props })}
|
||||
<a href="/incidents" {...props}>
|
||||
<SirenIcon />
|
||||
@@ -104,6 +105,26 @@
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</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.MenuButton isActive={active('/mihomo')} tooltipContent="Mihomo (флот)">
|
||||
{#snippet child({ props })}
|
||||
<a href="/mihomo" {...props}>
|
||||
<ZapIcon />
|
||||
<span>Mihomo</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
{#if serverAliases.length > 0}
|
||||
@@ -133,11 +154,11 @@
|
||||
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Dashboard">
|
||||
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Обзор">
|
||||
{#snippet child({ props })}
|
||||
<a href={serverPath} {...props}>
|
||||
<LayoutDashboardIcon />
|
||||
<span>Dashboard</span>
|
||||
<span>Обзор</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
@@ -153,61 +174,61 @@
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Состояние">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/runtime" {...props}>
|
||||
<ActivityIcon />
|
||||
<span>Runtime</span>
|
||||
<span>Состояние</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Users">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Пользователи">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/users" {...props}>
|
||||
<UsersIcon />
|
||||
<span>Users</span>
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Security">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Безопасность">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/security" {...props}>
|
||||
<ShieldIcon />
|
||||
<span>Security</span>
|
||||
<span>Безопасность</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Upstreams">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Апстримы и ДЦ">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/upstreams" {...props}>
|
||||
<NetworkIcon />
|
||||
<span>Upstreams & DCs</span>
|
||||
<span>Апстримы и ДЦ</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Configuration">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Конфигурация">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/config" {...props}>
|
||||
<SettingsIcon />
|
||||
<span>Configuration</span>
|
||||
<span>Конфигурация</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Update">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Обновление">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/update" {...props}>
|
||||
<RefreshCwIcon />
|
||||
<span>Update</span>
|
||||
<span>Обновление</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { kpiAccent, type KpiAccentKey } from '$lib/kpi-accents.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
accent,
|
||||
icon,
|
||||
class: className,
|
||||
toolbar,
|
||||
children
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
accent?: KpiAccentKey;
|
||||
icon?: Snippet;
|
||||
toolbar?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
density="compact"
|
||||
class={cn(
|
||||
'overflow-hidden rounded-lg border border-border shadow-sm',
|
||||
accent && 'border-l-4',
|
||||
accent && kpiAccent[accent].border,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#if title}
|
||||
<Card.Header class="border-b border-border bg-muted/30 px-4 py-3.5">
|
||||
<div class="flex gap-3">
|
||||
{#if icon && accent}
|
||||
<div
|
||||
class={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
||||
kpiAccent[accent].iconWrap
|
||||
)}
|
||||
>
|
||||
{@render icon()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<Card.Title class="text-base font-semibold">{title}</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
{/if}
|
||||
{#if toolbar}
|
||||
{@render toolbar()}
|
||||
{/if}
|
||||
<Card.Content class="p-0">
|
||||
{@render children()}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { SortDir } from '$lib/table-sort.js';
|
||||
|
||||
let {
|
||||
active,
|
||||
dir,
|
||||
onToggle,
|
||||
class: className,
|
||||
children,
|
||||
numeric
|
||||
}: {
|
||||
active: boolean;
|
||||
dir: SortDir | null;
|
||||
onToggle: () => void;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
numeric?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Table.Head
|
||||
class={cn(
|
||||
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
|
||||
numeric && 'text-right',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-md',
|
||||
'text-muted-foreground transition-colors',
|
||||
'hover:bg-accent/60 hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45',
|
||||
active && 'text-foreground',
|
||||
numeric && 'ms-auto'
|
||||
)}
|
||||
onclick={onToggle}
|
||||
>
|
||||
{@render children()}
|
||||
{#if active && dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
{:else if active && dir === 'desc'}
|
||||
<ArrowDownIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||
import InboxIcon from '@lucide/svelte/icons/inbox';
|
||||
|
||||
let {
|
||||
title,
|
||||
description
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-10">
|
||||
<Empty.Root class="max-w-sm border-0">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<InboxIcon />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{title}</Empty.Title>
|
||||
{#if description}
|
||||
<Empty.Description>{description}</Empty.Description>
|
||||
{/if}
|
||||
</Empty.Header>
|
||||
</Empty.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import Chart from 'chart.js/auto';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import {
|
||||
chartColorWithAlpha,
|
||||
chartGridColor,
|
||||
chartLegendColor,
|
||||
chartSeriesColors,
|
||||
chartTickColor,
|
||||
readCssVar
|
||||
} from '$lib/chart-theme.js';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
|
||||
|
||||
let {
|
||||
nodesOk,
|
||||
nodesDegraded,
|
||||
topUsers
|
||||
}: {
|
||||
nodesOk: number;
|
||||
nodesDegraded: number;
|
||||
topUsers: { username: string; total_megabytes: number }[];
|
||||
} = $props();
|
||||
|
||||
let elDonut = $state<HTMLCanvasElement | null>(null);
|
||||
let elBar = $state<HTMLCanvasElement | null>(null);
|
||||
let chartDonut: Chart | null = null;
|
||||
let chartBar: Chart | null = null;
|
||||
|
||||
function buildDonut() {
|
||||
if (!elDonut) return;
|
||||
const colors = chartSeriesColors();
|
||||
const legend = chartLegendColor();
|
||||
chartDonut?.destroy();
|
||||
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
const okColor = chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88);
|
||||
const degradedColor = chartColorWithAlpha(colors[4] ?? '#ef4444', 0.88);
|
||||
chartDonut = new Chart(elDonut, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['OK', 'Degraded'],
|
||||
datasets: [
|
||||
{
|
||||
data: [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)],
|
||||
backgroundColor: [okColor, degradedColor],
|
||||
hoverBackgroundColor: [okColor, degradedColor],
|
||||
borderWidth: 2,
|
||||
borderColor: hole,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: hole,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildBar() {
|
||||
if (!elBar) return;
|
||||
const colors = chartSeriesColors();
|
||||
const tickC = chartTickColor();
|
||||
const grid = chartGridColor();
|
||||
const slice = topUsers.slice(0, 8);
|
||||
const barColor = chartColorWithAlpha(colors[0] ?? '#6366f1', 0.52);
|
||||
chartBar?.destroy();
|
||||
chartBar = new Chart(elBar, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: slice.map((x) => x.username),
|
||||
datasets: [
|
||||
{
|
||||
label: 'MiB',
|
||||
data: slice.map((x) => x.total_megabytes),
|
||||
backgroundColor: barColor,
|
||||
hoverBackgroundColor: barColor,
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tickC }, grid: { color: grid } },
|
||||
y: { ticks: { color: tickC }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildAll() {
|
||||
buildDonut();
|
||||
buildBar();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
void tick().then(() => {
|
||||
if (!cancelled) rebuildAll();
|
||||
});
|
||||
const obs = new MutationObserver(() => {
|
||||
rebuildAll();
|
||||
});
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
obs.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
nodesOk;
|
||||
nodesDegraded;
|
||||
topUsers;
|
||||
if (chartDonut) {
|
||||
chartDonut.data.datasets[0].data = [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)];
|
||||
chartDonut.update('none');
|
||||
}
|
||||
if (chartBar) {
|
||||
const slice = topUsers.slice(0, 8);
|
||||
chartBar.data.labels = slice.map((x) => x.username);
|
||||
chartBar.data.datasets[0].data = slice.map((x) => x.total_megabytes);
|
||||
chartBar.update('none');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
chartDonut?.destroy();
|
||||
chartBar?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card.Root density="compact" class="overflow-hidden rounded-lg border border-border shadow-sm">
|
||||
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<ServerIcon class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Card.Title class="text-base">Статус нод</Card.Title>
|
||||
<Card.Description class="text-xs">OK vs degraded по fleet-status</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56 p-4">
|
||||
<canvas bind:this={elDonut} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root density="compact" class="overflow-hidden rounded-lg border border-border shadow-sm">
|
||||
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<ChartNoAxesCombinedIcon class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Card.Title class="text-base">Топ трафика</Card.Title>
|
||||
<Card.Description class="text-xs">До 8 пользователей, MiB</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="h-56 p-4">
|
||||
<canvas bind:this={elBar} class="max-h-full w-full"></canvas>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import TrendingDownIcon from '@lucide/svelte/icons/trending-down';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import { formatSignedPercent, percentChange, trendFromZero } from '$lib/kpi-trend-math.js';
|
||||
|
||||
let {
|
||||
label,
|
||||
insight,
|
||||
valueDisplay,
|
||||
prevValue,
|
||||
currentValue,
|
||||
invertTrend = false,
|
||||
class: className
|
||||
}: {
|
||||
label: string;
|
||||
/** Короткий текст под разделителем */
|
||||
insight: string;
|
||||
/** Отображаемое основное значение */
|
||||
valueDisplay: string;
|
||||
prevValue: number | null;
|
||||
currentValue: number | null;
|
||||
/** true: рост значения = хуже (красный «вверх») */
|
||||
invertTrend?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
type TrendOut =
|
||||
| null
|
||||
| { raw: 'flat'; label: string; good: boolean }
|
||||
| { raw: 'up' | 'down'; label: string; good: boolean };
|
||||
|
||||
const trend = $derived.by((): TrendOut => {
|
||||
if (prevValue == null || currentValue == null) return null;
|
||||
if (!Number.isFinite(prevValue) || !Number.isFinite(currentValue)) return null;
|
||||
const dir = trendFromZero(prevValue, currentValue);
|
||||
if (dir === null || dir === 'flat') {
|
||||
if (dir === 'flat' && prevValue === currentValue)
|
||||
return {
|
||||
raw: 'flat' as const,
|
||||
label: '0%',
|
||||
good: true
|
||||
};
|
||||
return null;
|
||||
}
|
||||
const raw: 'up' | 'down' = dir === 'up' ? 'up' : 'down';
|
||||
const good = invertTrend ? raw === 'down' : raw === 'up';
|
||||
const pct = percentChange(prevValue, currentValue);
|
||||
if (pct != null) {
|
||||
return { raw, label: formatSignedPercent(pct), good };
|
||||
}
|
||||
if (prevValue === 0 && currentValue !== 0) {
|
||||
return { raw, label: 'от нуля', good };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class={cn(
|
||||
'border-l-4 border-l-muted-foreground/35 bg-card shadow-sm transition-colors dark:bg-card/80',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Card.Header class="space-y-0 pb-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<Card.Description class="text-xs sm:text-sm">{label}</Card.Description>
|
||||
{#if trend}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class={cn(
|
||||
'gap-0.5 border-0 font-medium tabular-nums',
|
||||
trend.good &&
|
||||
'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
!trend.good && trend.raw !== 'flat' && 'bg-red-500/15 text-red-700 dark:text-red-400',
|
||||
trend.raw === 'flat' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{#if trend.raw === 'up'}
|
||||
<TrendingUpIcon class="size-3.5" aria-hidden="true" />
|
||||
{:else if trend.raw === 'down'}
|
||||
<TrendingDownIcon class="size-3.5" aria-hidden="true" />
|
||||
{:else}
|
||||
<MinusIcon class="size-3.5" aria-hidden="true" />
|
||||
{/if}
|
||||
{trend.label}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<Card.Title class="pt-1 text-2xl font-semibold tabular-nums tracking-tight">{valueDisplay}</Card.Title>
|
||||
</Card.Header>
|
||||
<Separator />
|
||||
<Card.Content class="pt-3 text-sm text-muted-foreground">{insight}</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import Chart from 'chart.js/auto';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
@@ -8,6 +8,15 @@
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
||||
import {
|
||||
chartColorWithAlpha,
|
||||
chartGridColor,
|
||||
chartLegendColor,
|
||||
chartLineAreaFillCallback,
|
||||
chartSeriesColors,
|
||||
chartTickColor,
|
||||
readCssVar
|
||||
} from '$lib/chart-theme.js';
|
||||
|
||||
let {
|
||||
histUp,
|
||||
@@ -45,85 +54,232 @@
|
||||
let chartNet: Chart | null = null;
|
||||
let chartTop: Chart | null = null;
|
||||
|
||||
const grid = '#64748b';
|
||||
|
||||
function lineOpts() {
|
||||
const tick = chartTickColor();
|
||||
const grid = chartGridColor();
|
||||
const legend = chartLegendColor();
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
plugins: { legend: { labels: { color: grid } } },
|
||||
plugins: { legend: { labels: { color: legend } } },
|
||||
scales: {
|
||||
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
|
||||
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
|
||||
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
|
||||
function rebuildAll() {
|
||||
const colors = chartSeriesColors();
|
||||
const legend = chartLegendColor();
|
||||
const grid = chartGridColor();
|
||||
const tick = chartTickColor();
|
||||
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: 'KiB', 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 } }
|
||||
|
||||
if (elTraffic) {
|
||||
chartTraffic?.destroy();
|
||||
const c0 = colors[0] ?? '#38bdf8';
|
||||
const c4 = colors[4] ?? '#f472b6';
|
||||
chartTraffic = new Chart(elTraffic, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: lab,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Скачивание',
|
||||
data: [...histDown],
|
||||
borderColor: c0,
|
||||
backgroundColor: chartLineAreaFillCallback(c0),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c0,
|
||||
pointHoverBorderColor: c0,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Загрузка',
|
||||
data: [...histUp],
|
||||
borderColor: c4,
|
||||
backgroundColor: chartLineAreaFillCallback(c4),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c4,
|
||||
pointHoverBorderColor: c4,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
}
|
||||
|
||||
if (elFlow) {
|
||||
chartFlow?.destroy();
|
||||
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
const downColor = chartColorWithAlpha(colors[0] ?? '#38bdf8', 0.88);
|
||||
const upColor = chartColorWithAlpha(colors[4] ?? '#f472b6', 0.88);
|
||||
chartFlow = new Chart(elFlow, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Скачано', 'Загружено'],
|
||||
datasets: [
|
||||
{
|
||||
data: [Math.max(totalDown, 0), Math.max(totalUp, 0)],
|
||||
backgroundColor: [downColor, upColor],
|
||||
hoverBackgroundColor: [downColor, upColor],
|
||||
borderWidth: 2,
|
||||
borderColor: hole,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: hole,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (elMem) {
|
||||
chartMem?.destroy();
|
||||
const c2 = colors[2] ?? '#a78bfa';
|
||||
chartMem = new Chart(elMem, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: histMem.map((_, i) => String(i)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'KiB',
|
||||
data: [...histMem],
|
||||
borderColor: c2,
|
||||
backgroundColor: chartLineAreaFillCallback(c2),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c2,
|
||||
pointHoverBorderColor: c2,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
}
|
||||
|
||||
if (elConn) {
|
||||
chartConn?.destroy();
|
||||
const c1 = colors[1] ?? '#34d399';
|
||||
chartConn = new Chart(elConn, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: histConn.map((_, i) => String(i)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Соединения',
|
||||
data: [...histConn],
|
||||
borderColor: c1,
|
||||
backgroundColor: chartLineAreaFillCallback(c1),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c1,
|
||||
pointHoverBorderColor: c1,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
}
|
||||
|
||||
if (elNet) {
|
||||
chartNet?.destroy();
|
||||
const holeNet = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
const tcpColor = chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88);
|
||||
const udpColor = chartColorWithAlpha(colors[3] ?? '#eab308', 0.88);
|
||||
chartNet = new Chart(elNet, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['TCP', 'UDP'],
|
||||
datasets: [
|
||||
{
|
||||
data: [tcpN, udpN],
|
||||
backgroundColor: [tcpColor, udpColor],
|
||||
hoverBackgroundColor: [tcpColor, udpColor],
|
||||
borderWidth: 2,
|
||||
borderColor: holeNet,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: holeNet,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (elTop) {
|
||||
chartTop?.destroy();
|
||||
const topBarColor = chartColorWithAlpha(colors[0] ?? '#818cf8', 0.52);
|
||||
chartTop = new Chart(elTop, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: topList.map((x) => x.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Сессии',
|
||||
data: topList.map((x) => x.n),
|
||||
backgroundColor: topBarColor,
|
||||
hoverBackgroundColor: topBarColor,
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
void tick().then(() => {
|
||||
if (!cancelled) rebuildAll();
|
||||
});
|
||||
const obs = new MutationObserver(() => rebuildAll());
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
obs.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -171,7 +327,7 @@
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<KpiPanelCard
|
||||
accent="teal"
|
||||
accent="neutral"
|
||||
title="Трафик"
|
||||
description="Скорость загрузки и скачивания (история из /traffic)"
|
||||
headerClass="pb-2"
|
||||
@@ -183,7 +339,7 @@
|
||||
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="info"
|
||||
accent="neutral"
|
||||
title="Поток (накоплено)"
|
||||
description="Соотношение накопленного down / up"
|
||||
headerClass="pb-2"
|
||||
@@ -195,7 +351,7 @@
|
||||
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="violet"
|
||||
accent="neutral"
|
||||
title="Память (KiB)"
|
||||
description="Использование по снимкам (ось — KiB)"
|
||||
headerClass="pb-2"
|
||||
@@ -207,7 +363,7 @@
|
||||
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="success"
|
||||
accent="neutral"
|
||||
title="Подключения"
|
||||
description="Число активных соединений во времени"
|
||||
headerClass="pb-2"
|
||||
@@ -219,7 +375,7 @@
|
||||
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="warning"
|
||||
accent="neutral"
|
||||
title="Типы сети"
|
||||
description="TCP и UDP по текущему снимку"
|
||||
headerClass="pb-2"
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { ApiError, fetchMihomoJson, mihomoPut } from '$lib/api/client.js';
|
||||
import type { MihomoProxiesResponse, MihomoProxyEntry } from '$lib/api/mihomo-types.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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
|
||||
let {
|
||||
alias,
|
||||
/** Плотная сетка мелких ячеек — для флота и списков из многих нод. */
|
||||
compact = false,
|
||||
/** Скрыть верхнюю панель «Обновить» (кнопка в Card.Action у родителя). */
|
||||
hideToolbar = false,
|
||||
/** Увеличьте, чтобы принудительно перезагрузить список снаружи. */
|
||||
refreshNonce = 0,
|
||||
/** Состояние загрузки для индикатора у родителя. */
|
||||
onLoadingChange,
|
||||
/** Увеличьте — проверить задержку для всех групп (флот: кнопка в шапке карточки). */
|
||||
delayAllNonce = 0,
|
||||
/** Не показывать «Все задержки» внутри группы (кнопка на родителе). */
|
||||
hideCompactPerGroupDelayButton = false,
|
||||
/** Идёт проверка задержек по всем группам. */
|
||||
onDelayAllChange
|
||||
}: {
|
||||
alias: string;
|
||||
compact?: boolean;
|
||||
hideToolbar?: boolean;
|
||||
refreshNonce?: number;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
delayAllNonce?: number;
|
||||
hideCompactPerGroupDelayButton?: boolean;
|
||||
onDelayAllChange?: (busy: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
|
||||
|
||||
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 delayQuery(testUrl?: string) {
|
||||
const u =
|
||||
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
|
||||
return `timeout=5000&url=${encodeURIComponent(u)}`;
|
||||
}
|
||||
|
||||
async function loadProxies() {
|
||||
const a = untrack(() => alias);
|
||||
if (!a) return;
|
||||
const notify = untrack(() => onLoadingChange);
|
||||
proxiesLoading = true;
|
||||
// Иначе $effect подпишется на проп-колбэк: новая fn каждый рендер родителя → бесконечный цикл /proxies
|
||||
untrack(() => notify?.(true));
|
||||
proxiesErr = null;
|
||||
try {
|
||||
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
// не затираем успешный снимок при сбое обновления — без мерцания UI
|
||||
} finally {
|
||||
proxiesLoading = false;
|
||||
untrack(() => notify?.(false));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const a = alias;
|
||||
void refreshNonce;
|
||||
if (!a) return;
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
function isSelectableGroup(p: MihomoProxyEntry) {
|
||||
const t = (p.type ?? '').toLowerCase();
|
||||
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
|
||||
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Стабильный порядок групп — меньше скачков DOM при обновлении JSON. */
|
||||
let selectableGroups = $derived.by((): [string, MihomoProxyEntry][] => {
|
||||
const p = proxiesData?.proxies;
|
||||
if (!p) return [];
|
||||
return Object.entries(p)
|
||||
.filter(([_, v]) => isSelectableGroup(v))
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
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 = untrack(() => alias);
|
||||
if (!a) return;
|
||||
testing = name;
|
||||
try {
|
||||
const testUrl = untrack(() => proxiesData?.proxies?.[name]?.testUrl);
|
||||
await fetchMihomoJson(
|
||||
a,
|
||||
`proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}`
|
||||
);
|
||||
await loadProxies();
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
testing = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pingGroup(groupName: string) {
|
||||
const a = untrack(() => alias);
|
||||
if (!a) return;
|
||||
// Снимок до await: иначе $effect(delayAllNonce) подпишется на proxiesData и
|
||||
// после loadProxies() перезапустится при том же nonce → бесконечные delay/ping.
|
||||
const snap = untrack(() => {
|
||||
const p = proxiesData?.proxies;
|
||||
const g = p?.[groupName];
|
||||
const names = g?.all ?? [];
|
||||
return {
|
||||
groupTestUrl: g?.testUrl,
|
||||
names,
|
||||
nodeTestUrls: names.map((n) => p?.[n]?.testUrl)
|
||||
};
|
||||
});
|
||||
testingGroup = groupName;
|
||||
try {
|
||||
await fetchMihomoJson(
|
||||
a,
|
||||
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(snap.groupTestUrl)}`
|
||||
);
|
||||
await loadProxies();
|
||||
} catch {
|
||||
try {
|
||||
for (let i = 0; i < snap.names.length; i++) {
|
||||
const n = snap.names[i]!;
|
||||
await fetchMihomoJson(
|
||||
a,
|
||||
`proxies/${encodeURIComponent(n)}/delay?${delayQuery(snap.nodeTestUrls[i])}`
|
||||
);
|
||||
}
|
||||
await loadProxies();
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
} finally {
|
||||
testingGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const a = alias;
|
||||
const n = delayAllNonce;
|
||||
if (!a || n <= 0) return;
|
||||
const groupsSnapshot = untrack(() => [...selectableGroups]);
|
||||
void (async () => {
|
||||
untrack(() => onDelayAllChange?.(true));
|
||||
try {
|
||||
for (const [gName] of groupsSnapshot) {
|
||||
await pingGroup(gName);
|
||||
}
|
||||
} finally {
|
||||
untrack(() => onDelayAllChange?.(false));
|
||||
}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !hideToolbar}
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-wrap items-center justify-end gap-1.5',
|
||||
compact ? 'mb-2' : 'mb-4'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class={cn(compact ? 'h-7 gap-1.5 px-2 text-xs' : 'gap-2')}
|
||||
onclick={() => loadProxies()}
|
||||
disabled={proxiesLoading || !alias}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class={cn('size-3.5 shrink-0', proxiesLoading && 'animate-spin')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DataQueryState
|
||||
err={proxiesErr}
|
||||
hasStaleData={proxiesData != null && proxiesErr != null}
|
||||
showSkeleton={proxiesLoading && proxiesData === null}
|
||||
isEmpty={false}
|
||||
>
|
||||
{#snippet skeleton()}
|
||||
{#if compact}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each Array.from({ length: 10 }) as _, i (i)}
|
||||
<Skeleton class="h-8 w-[5.5rem] rounded-md" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if proxiesData?.proxies}
|
||||
<div class={cn('flex flex-col', compact ? 'gap-2' : 'gap-8')}>
|
||||
{#each selectableGroups as [gName, group] (gName)}
|
||||
{#if compact}
|
||||
<div class="space-y-2" role="group" aria-label="Группа {gName}">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Badge variant="outline" class="h-5 px-1.5 font-mono text-[10px] uppercase tracking-wide">
|
||||
{gName}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
<span class="text-foreground/90 font-medium">{group.now ?? '—'}</span>
|
||||
</span>
|
||||
</div>
|
||||
{#if !hideCompactPerGroupDelayButton}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 shrink-0 px-2 text-xs"
|
||||
onclick={() => pingGroup(gName)}
|
||||
disabled={testingGroup === gName}
|
||||
>
|
||||
{testingGroup === gName ? '…' : 'Все задержки'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5" role="list">
|
||||
{#each group.all ?? [] as nodeName (nodeName)}
|
||||
{@const node = proxiesData.proxies?.[nodeName]}
|
||||
{@const d = node ? lastDelay(node) : null}
|
||||
{@const active = group.now === nodeName}
|
||||
<div
|
||||
class={cn(
|
||||
'bg-background flex min-w-0 max-w-full items-stretch overflow-hidden rounded-md border text-left text-xs transition-colors',
|
||||
active
|
||||
? 'border-primary/60 bg-muted/30'
|
||||
: 'border-border/60 hover:bg-muted/25'
|
||||
)}
|
||||
role="listitem"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted/50 min-w-0 flex-1 px-2 py-1.5 text-left transition-colors disabled:opacity-50"
|
||||
onclick={() => selectProxy(gName, nodeName)}
|
||||
disabled={switching === gName}
|
||||
title="Выбрать {nodeName}"
|
||||
>
|
||||
<div class="truncate font-medium leading-none">{nodeName}</div>
|
||||
<div
|
||||
class="text-muted-foreground mt-1 flex items-center justify-between gap-2 text-[10px] leading-none"
|
||||
>
|
||||
<span class="truncate opacity-90">{node?.type ?? '—'}</span>
|
||||
<span
|
||||
class={cn(
|
||||
'tabular-nums',
|
||||
d != null && d < 500 ? 'text-emerald-600 dark:text-emerald-400' : ''
|
||||
)}
|
||||
>
|
||||
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/70">ms</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-muted-foreground hover:text-foreground size-8 shrink-0 rounded-none border-l px-0"
|
||||
onclick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
void pingOne(nodeName);
|
||||
}}
|
||||
disabled={testing === nodeName}
|
||||
aria-label="Задержка для {nodeName}"
|
||||
title="Проверить задержку"
|
||||
>
|
||||
{#if testing === nodeName}
|
||||
<span class="text-[10px]">…</span>
|
||||
{:else}
|
||||
<ActivityIcon class="size-3.5 opacity-70" aria-hidden="true" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import BracesIcon from '@lucide/svelte/icons/braces';
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
accent = 'info',
|
||||
payload,
|
||||
class: className,
|
||||
preClass = 'max-h-[70vh]',
|
||||
iconSnippet
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
accent?: KpiAccentKey;
|
||||
payload: unknown;
|
||||
class?: string;
|
||||
preClass?: string;
|
||||
iconSnippet?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<KpiPanelCard {accent} {title} {description} class={className} contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
{#if iconSnippet}
|
||||
{@render iconSnippet()}
|
||||
{:else}
|
||||
<BracesIcon class="size-5" aria-hidden="true" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
<pre
|
||||
class={[
|
||||
preClass,
|
||||
'overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs leading-relaxed'
|
||||
]}
|
||||
>{JSON.stringify(payload, null, 2)}</pre>
|
||||
</KpiPanelCard>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
loading = false,
|
||||
showRefresh = true,
|
||||
onRefresh,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
loading?: boolean;
|
||||
showRefresh?: boolean;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{#if subtitle}
|
||||
<p class="text-sm text-muted-foreground">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{@render actions?.()}
|
||||
{#if showRefresh}
|
||||
<Button variant="outline" size="sm" onclick={onRefresh} disabled={loading || !onRefresh}>
|
||||
<RefreshCwIcon class={['mr-1 size-4', loading && 'animate-spin']} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
|
||||
import InboxIcon from '@lucide/svelte/icons/inbox';
|
||||
|
||||
let {
|
||||
loading = false,
|
||||
err = null,
|
||||
isEmpty = false,
|
||||
loadingTitle = 'Загрузка данных',
|
||||
loadingDescription = 'Пожалуйста, подождите...',
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription = 'По этому endpoint пока нет содержимого.',
|
||||
children
|
||||
}: {
|
||||
loading?: boolean;
|
||||
err?: string | null;
|
||||
isEmpty?: boolean;
|
||||
loadingTitle?: string;
|
||||
loadingDescription?: string;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<Alert variant="destructive" class="mb-4">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title={loadingTitle}
|
||||
description={loadingDescription}
|
||||
contentClass="pt-0 text-sm text-muted-foreground"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<LoaderCircleIcon class="size-5 animate-spin" aria-hidden="true" />
|
||||
{/snippet}
|
||||
Идет запрос к ноде, обновляем показатели dashboard.
|
||||
</KpiPanelCard>
|
||||
{:else if !err && isEmpty}
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
contentClass="pt-0 text-sm text-muted-foreground"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<InboxIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
Проверьте параметры запроса или состояние сервера.
|
||||
</KpiPanelCard>
|
||||
{:else if !loading && !isEmpty}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { breadcrumbsFromPath } from '$lib/breadcrumb-trail.js';
|
||||
import { setMode, resetMode, userPrefersMode } from 'mode-watcher';
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
|
||||
const items = $derived(breadcrumbsFromPath(page.url.pathname));
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="me-1 h-4" />
|
||||
<Breadcrumb.Root class="min-w-0">
|
||||
<Breadcrumb.List class="flex-wrap">
|
||||
{#each items as it, i (it.href + String(i))}
|
||||
<Breadcrumb.Item class="min-w-0 max-w-[140px] sm:max-w-[220px]">
|
||||
{#if it.current}
|
||||
<Breadcrumb.Page class="truncate">{it.label}</Breadcrumb.Page>
|
||||
{:else}
|
||||
<Breadcrumb.Link href={it.href} class="truncate">{it.label}</Breadcrumb.Link>
|
||||
{/if}
|
||||
</Breadcrumb.Item>
|
||||
{#if i < items.length - 1}
|
||||
<Breadcrumb.Separator />
|
||||
{/if}
|
||||
{/each}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="relative shrink-0"
|
||||
aria-label="Тема оформления"
|
||||
>
|
||||
<SunIcon
|
||||
class="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
|
||||
/>
|
||||
<MoonIcon
|
||||
class="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
|
||||
/>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-44">
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Тема</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2"
|
||||
onclick={() => {
|
||||
setMode('light');
|
||||
}}
|
||||
>
|
||||
<SunIcon class="size-4" /> Светлая
|
||||
{#if userPrefersMode.current === 'light'}
|
||||
<span class="ms-auto text-xs text-muted-foreground">✓</span>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2"
|
||||
onclick={() => {
|
||||
setMode('dark');
|
||||
}}
|
||||
>
|
||||
<MoonIcon class="size-4" /> Тёмная
|
||||
{#if userPrefersMode.current === 'dark'}
|
||||
<span class="ms-auto text-xs text-muted-foreground">✓</span>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2"
|
||||
onclick={() => {
|
||||
resetMode();
|
||||
}}
|
||||
>
|
||||
<MonitorIcon class="size-4" /> Системная
|
||||
{#if userPrefersMode.current === 'system'}
|
||||
<span class="ms-auto text-xs text-muted-foreground">✓</span>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import Loader2Icon from '@lucide/svelte/icons/loader-circle';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/circle-alert';
|
||||
|
||||
let {
|
||||
variant
|
||||
}: {
|
||||
variant: 'ok' | 'degraded' | 'pending' | 'error';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'ok'}
|
||||
<Badge
|
||||
class={cn(
|
||||
'gap-1 border-0 bg-emerald-500/15 font-medium text-emerald-700 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
<CheckIcon class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
OK
|
||||
</Badge>
|
||||
{:else if variant === 'degraded'}
|
||||
<Badge variant="destructive" class="gap-1 font-medium">
|
||||
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
degraded
|
||||
</Badge>
|
||||
{:else if variant === 'pending'}
|
||||
<Badge variant="secondary" class="gap-1 font-medium text-muted-foreground">
|
||||
<Loader2Icon class="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
|
||||
ожидание
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive" class="gap-1 font-medium">
|
||||
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
ошибка
|
||||
</Badge>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("size-5 [&>svg]:size-4 flex items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-item"
|
||||
class={cn("gap-1 inline-flex items-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
href = undefined,
|
||||
child,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||
} = $props();
|
||||
|
||||
const attrs = $derived({
|
||||
"data-slot": "breadcrumb-link",
|
||||
class: cn("hover:text-foreground transition-colors", className),
|
||||
href,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: attrs })}
|
||||
{:else}
|
||||
<a bind:this={ref} {...attrs}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLOlAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-list"
|
||||
class={cn("text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ol>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
class={cn("text-foreground font-normal", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("[&>svg]:size-3.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<ChevronRightIcon />
|
||||
{/if}
|
||||
</li>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb"
|
||||
aria-label="breadcrumb"
|
||||
class={cn("cn-breadcrumb", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
@@ -0,0 +1,25 @@
|
||||
import Root from "./breadcrumb.svelte";
|
||||
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||
import Item from "./breadcrumb-item.svelte";
|
||||
import Separator from "./breadcrumb-separator.svelte";
|
||||
import Link from "./breadcrumb-link.svelte";
|
||||
import List from "./breadcrumb-list.svelte";
|
||||
import Page from "./breadcrumb-page.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Ellipsis,
|
||||
Item,
|
||||
Separator,
|
||||
Link,
|
||||
List,
|
||||
Page,
|
||||
//
|
||||
Root as Breadcrumb,
|
||||
Ellipsis as BreadcrumbEllipsis,
|
||||
Item as BreadcrumbItem,
|
||||
Separator as BreadcrumbSeparator,
|
||||
Link as BreadcrumbLink,
|
||||
List as BreadcrumbList,
|
||||
Page as BreadcrumbPage,
|
||||
};
|
||||
@@ -7,15 +7,25 @@
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
density = "default",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
size?: "default" | "sm";
|
||||
density?: "default" | "compact";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
data-density={density}
|
||||
class={cn(
|
||||
"ring-foreground/10 bg-card text-card-foreground overflow-hidden rounded-xl text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
|
||||
"data-[density=default]:gap-4 data-[density=default]:py-4 data-[density=default]:data-[size=sm]:gap-3 data-[density=default]:data-[size=sm]:py-3",
|
||||
"data-[density=compact]:gap-0 data-[density=compact]:py-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div
|
||||
data-slot="checkbox-indicator"
|
||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||
>
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</CheckboxPrimitive.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-content"
|
||||
class={cn(
|
||||
"gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-description"
|
||||
class={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-header"
|
||||
class={cn("gap-2 flex max-w-sm flex-col items-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const emptyMediaVariants = tv({
|
||||
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
class={cn(emptyMediaVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="empty-title" class={cn("text-sm font-medium tracking-tight", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty"
|
||||
class={cn(
|
||||
"gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
import Root from "./empty.svelte";
|
||||
import Header from "./empty-header.svelte";
|
||||
import Media from "./empty-media.svelte";
|
||||
import Title from "./empty-title.svelte";
|
||||
import Description from "./empty-description.svelte";
|
||||
import Content from "./empty-content.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Header,
|
||||
Media,
|
||||
Title,
|
||||
Description,
|
||||
Content,
|
||||
//
|
||||
Root as Empty,
|
||||
Header as EmptyHeader,
|
||||
Media as EmptyMedia,
|
||||
Title as EmptyTitle,
|
||||
Description as EmptyDescription,
|
||||
Content as EmptyContent,
|
||||
};
|
||||
@@ -13,13 +13,13 @@
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<span>Next</span>
|
||||
<span>Следующая</span>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.NextButton
|
||||
bind:ref
|
||||
aria-label="Go to next page"
|
||||
aria-label="Перейти на следующую страницу"
|
||||
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
|
||||
{...restProps}
|
||||
>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
</script>
|
||||
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
aria-label="Перейти на следующую страницу"
|
||||
size="default"
|
||||
class={cn("pr-1.5!", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<span class="cn-pagination-next-text hidden sm:block">Next</span>
|
||||
<span class="cn-pagination-next-text hidden sm:block">Следующая</span>
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</PaginationLink>
|
||||
@@ -14,12 +14,12 @@
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
<span>Previous</span>
|
||||
<span>Предыдущая</span>
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.PrevButton
|
||||
bind:ref
|
||||
aria-label="Go to previous page"
|
||||
aria-label="Перейти на предыдущую страницу"
|
||||
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
|
||||
{...restProps}
|
||||
>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
</script>
|
||||
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
aria-label="Перейти на предыдущую страницу"
|
||||
size="default"
|
||||
class={cn("pl-1.5!", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
<span class="cn-pagination-previous-text hidden sm:block">Previous</span>
|
||||
<span class="cn-pagination-previous-text hidden sm:block">Предыдущая</span>
|
||||
</PaginationLink>
|
||||
@@ -18,7 +18,7 @@
|
||||
bind:ref
|
||||
bind:page
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
aria-label="Пагинация"
|
||||
data-slot="pagination"
|
||||
{count}
|
||||
{perPage}
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
{side}
|
||||
>
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>Sidebar</Sheet.Title>
|
||||
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
|
||||
<Sheet.Title>Боковая панель</Sheet.Title>
|
||||
<Sheet.Description>Отображает мобильную боковую панель.</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
{@render children?.()}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Доля изменения для тренд-бейджа; null если сравнивать не с чем. */
|
||||
export function percentChange(prev: number, cur: number): number | null {
|
||||
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
|
||||
if (prev === 0 && cur === 0) return null;
|
||||
if (prev === 0) return cur === 0 ? null : null; // избегаем бесконечного %; покажем только направление отдельно
|
||||
const p = ((cur - prev) / Math.abs(prev)) * 100;
|
||||
return Number.isFinite(p) ? p : null;
|
||||
}
|
||||
|
||||
/** Для prev===0 и cur>0 возвращает «рост от нуля». */
|
||||
export function trendFromZero(prev: number, cur: number): 'up' | 'down' | 'flat' | null {
|
||||
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
|
||||
if (prev === 0 && cur === 0) return null;
|
||||
if (prev === 0) return cur > 0 ? 'up' : cur < 0 ? 'down' : 'flat';
|
||||
if (cur > prev) return 'up';
|
||||
if (cur < prev) return 'down';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
export function formatSignedPercent(p: number): string {
|
||||
const sign = p > 0 ? '+' : '';
|
||||
return `${sign}${p.toFixed(1)}%`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const SEG_LABELS: Record<string, string> = {
|
||||
users: 'Пользователи',
|
||||
ips: 'Уникальные IP',
|
||||
incidents: 'Инциденты',
|
||||
live: 'Поток',
|
||||
radar: 'Радар DC',
|
||||
mihomo: 'Mihomo',
|
||||
servers: 'Серверы',
|
||||
runtime: 'Состояние',
|
||||
security: 'Безопасность',
|
||||
upstreams: 'Апстримы и ДЦ',
|
||||
config: 'Конфигурация',
|
||||
update: 'Обновление'
|
||||
};
|
||||
|
||||
export function panelTitleFromPath(pathname: string): string {
|
||||
const p = pathname === '' ? '/' : pathname;
|
||||
if (p === '/') return 'Обзор флота';
|
||||
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return 'Telemt Panel';
|
||||
|
||||
if (parts[0] === 'servers' && parts.length >= 2) {
|
||||
const alias = decodeURIComponent(parts[1]);
|
||||
if (parts.length === 2) return `Нода ${alias}`;
|
||||
const sub = parts.slice(2).map((s) => SEG_LABELS[s] ?? s).join(' · ');
|
||||
return `${alias} · ${sub}`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return SEG_LABELS[parts[0]] ?? parts[0];
|
||||
}
|
||||
|
||||
if (parts[0] === 'users' && parts[1]) {
|
||||
return `Пользователь ${decodeURIComponent(parts[1])}`;
|
||||
}
|
||||
|
||||
return parts.map((s) => SEG_LABELS[s] ?? decodeURIComponent(s)).join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export function nextSortDir(
|
||||
current: SortDir | null,
|
||||
key: string,
|
||||
activeKey: string | null
|
||||
): SortDir | null {
|
||||
if (activeKey !== key) return 'asc';
|
||||
if (current === 'asc') return 'desc';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function compareStrings(a: string, b: string, dir: SortDir): number {
|
||||
const c = a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||
return dir === 'asc' ? c : -c;
|
||||
}
|
||||
|
||||
export function compareNum(a: number, b: number, dir: SortDir): number {
|
||||
return dir === 'asc' ? a - b : b - a;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import { page } from '$app/state';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
@@ -9,9 +9,15 @@
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { Toaster } from '$lib/components/ui/sonner/index.js';
|
||||
import SiteHeader from '$lib/components/site-header.svelte';
|
||||
import { panelTitleFromPath } from '$lib/page-title.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
|
||||
/** Нельзя использовать `!== null`: при `navigating === undefined` выражение истинно и полоска «зависает». */
|
||||
const isNavigating = $derived(!!navigating);
|
||||
|
||||
let serverAliases = $state<string[]>([]);
|
||||
|
||||
const serverMatch = $derived.by(() => {
|
||||
@@ -42,7 +48,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<title>Telemt Panel</title>
|
||||
<title>{docTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<ModeWatcher />
|
||||
@@ -50,10 +56,13 @@
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar {serverAliases} {serverPath} />
|
||||
<Sidebar.Inset>
|
||||
<header
|
||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"
|
||||
>
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
{#if isNavigating}
|
||||
<div class="h-0.5 w-full overflow-hidden bg-transparent">
|
||||
<div class="h-full w-1/3 animate-pulse rounded-full bg-primary"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<SiteHeader />
|
||||
</header>
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
{@render children()}
|
||||
|
||||
+633
-267
@@ -9,35 +9,44 @@
|
||||
fetchAggUniqueIps,
|
||||
fetchStatsSummary
|
||||
} from '$lib/api/client.js';
|
||||
import type { SummaryData } from '$lib/api/summary-types.js';
|
||||
import type { SummaryData, TopUser } from '$lib/api/summary-types.js';
|
||||
import type { components } from '$lib/api/aggregate.gen.js';
|
||||
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
||||
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
|
||||
import FleetOverviewCharts from '$lib/components/fleet/fleet-overview-charts.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import UnplugIcon from '@lucide/svelte/icons/unplug';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
|
||||
import InfoIcon from '@lucide/svelte/icons/info';
|
||||
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||
import TableEmpty from '$lib/components/data-table/table-empty.svelte';
|
||||
import StatusBadge from '$lib/components/status-badge.svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
|
||||
|
||||
let loading = $state(true);
|
||||
let err = $state<string | null>(null);
|
||||
@@ -61,6 +70,46 @@
|
||||
>
|
||||
>({});
|
||||
|
||||
function sumBad(stats: typeof nodeStats): number {
|
||||
let n = 0;
|
||||
for (const v of Object.values(stats)) {
|
||||
if (v && 'ok' in v && v.ok && typeof v.bad === 'number') n += v.bad;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countUniqueIps(rows: components['schemas']['UniqueIPsRow'][] | null): number {
|
||||
const set = new Set<string>();
|
||||
for (const row of rows ?? []) {
|
||||
for (const ip of row.ips ?? []) {
|
||||
if (ip.ip) set.add(ip.ip);
|
||||
}
|
||||
}
|
||||
return set.size;
|
||||
}
|
||||
|
||||
function countReadOnlyNodes(f: components['schemas']['FleetStatusData'] | null): number {
|
||||
let n = 0;
|
||||
for (const s of f?.servers ?? []) {
|
||||
if (s.health?.read_only) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Таблицы: сортировка и поиск */
|
||||
let ipFilter = $state('');
|
||||
let ipSortKey = $state<'ip' | 'user' | null>(null);
|
||||
let ipSortDir = $state<SortDir | null>(null);
|
||||
|
||||
let nodeFilter = $state('');
|
||||
let nodeStatusFilter = $state<'all' | 'ok' | 'degraded'>('all');
|
||||
let nodeSortKey = $state<'alias' | 'latency' | 'bad' | null>(null);
|
||||
let nodeSortDir = $state<SortDir | null>(null);
|
||||
|
||||
let topFilter = $state('');
|
||||
let topSortKey = $state<'user' | 'traffic' | 'ips' | null>(null);
|
||||
let topSortDir = $state<SortDir | null>(null);
|
||||
|
||||
function parseAliasFilter(raw: string | null): string {
|
||||
const value = raw?.trim() ?? '';
|
||||
return !value || value.includes(',') ? 'all' : value;
|
||||
@@ -90,10 +139,15 @@
|
||||
summary = s.data;
|
||||
fleet = f.data ?? null;
|
||||
uniqueIps = u.data ?? null;
|
||||
availableAliases = collectAliases(
|
||||
const nextAliases = collectAliases(
|
||||
(fleet?.servers ?? []).map((x) => x.alias),
|
||||
(summary?.servers ?? []).map((x) => x.alias)
|
||||
);
|
||||
// Не сужаем список до текущего фильтра: иначе выбранный alias
|
||||
// может исчезать из селекта при частичных/пустых ответах.
|
||||
availableAliases = Array.from(new Set([...availableAliases, ...nextAliases])).sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
lastSuccessAtMs = Date.now();
|
||||
|
||||
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
|
||||
@@ -166,19 +220,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function applyAliasIfChanged(nextAlias: string) {
|
||||
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||
if (nextAlias === currentAlias) return;
|
||||
const q = new URLSearchParams(page.url.searchParams);
|
||||
if (nextAlias === 'all') q.delete('aliases');
|
||||
else q.set('aliases', nextAlias);
|
||||
const qs = q.toString();
|
||||
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true
|
||||
});
|
||||
}
|
||||
async function handleAliasFilterChange(nextAlias: string) {
|
||||
aliasFilter = nextAlias;
|
||||
const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||
if (nextAlias === currentUrlAlias) return;
|
||||
const q = new URLSearchParams(page.url.searchParams);
|
||||
if (nextAlias === 'all') q.delete('aliases');
|
||||
else q.set('aliases', nextAlias);
|
||||
const qs = q.toString();
|
||||
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
let queryKey = $derived(page.url.searchParams.toString());
|
||||
$effect(() => {
|
||||
@@ -191,11 +247,6 @@
|
||||
void load();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void applyAliasIfChanged(aliasFilter);
|
||||
});
|
||||
|
||||
let staleSeconds = $derived.by(() =>
|
||||
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
|
||||
);
|
||||
@@ -266,26 +317,15 @@
|
||||
return n;
|
||||
});
|
||||
|
||||
let nodesHealthAccent = $derived.by((): KpiAccentKey => {
|
||||
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0;
|
||||
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0;
|
||||
if (t === 0) return 'neutral';
|
||||
if (ok === t) return 'success';
|
||||
if (ok === 0) return 'danger';
|
||||
return 'warning';
|
||||
let nodesOkChart = $derived.by(() => {
|
||||
const list = fleet?.servers ?? [];
|
||||
return list.filter((s) => s.health_ok && s.system_info_ok).length;
|
||||
});
|
||||
|
||||
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */
|
||||
let badConnectionsAccent = $derived.by((): KpiAccentKey => {
|
||||
if (totalBad === 0) return 'success';
|
||||
if (totalBad < 10_000) return 'warning';
|
||||
return 'danger';
|
||||
let nodesDegradedChart = $derived.by(() => {
|
||||
const list = fleet?.servers ?? [];
|
||||
return list.filter((s) => !s.health_ok || !s.system_info_ok).length;
|
||||
});
|
||||
|
||||
let readOnlyAccent = $derived.by((): KpiAccentKey =>
|
||||
readOnlyNodes === 0 ? 'success' : 'warning'
|
||||
);
|
||||
|
||||
function chipLabel(ip: components['schemas']['IPAssignments']): string {
|
||||
const cc = ip.country_code?.trim();
|
||||
const flag = flagEmoji(cc ?? undefined);
|
||||
@@ -373,19 +413,106 @@
|
||||
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
|
||||
return out;
|
||||
});
|
||||
|
||||
let filteredSortedIpRows = $derived.by(() => {
|
||||
let rows = activeIpServerRows;
|
||||
const q = ipFilter.trim().toLowerCase();
|
||||
if (q) {
|
||||
rows = rows.filter(
|
||||
(r) =>
|
||||
r.username.toLowerCase().includes(q) ||
|
||||
r.ip.toLowerCase().includes(q) ||
|
||||
r.activeLabel.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const k = ipSortKey;
|
||||
const d = ipSortDir;
|
||||
if (!k || !d) return rows;
|
||||
const copy = [...rows];
|
||||
if (k === 'ip') copy.sort((a, b) => compareStrings(a.ip, b.ip, d));
|
||||
else copy.sort((a, b) => compareStrings(a.username, b.username, d));
|
||||
return copy;
|
||||
});
|
||||
|
||||
type NodeRow = components['schemas']['FleetServerStatus'];
|
||||
|
||||
let filteredSortedNodeRows = $derived.by((): NodeRow[] => {
|
||||
let rows = [...(fleet?.servers ?? [])];
|
||||
const q = nodeFilter.trim().toLowerCase();
|
||||
if (q) rows = rows.filter((s) => (s.alias ?? '').toLowerCase().includes(q));
|
||||
if (nodeStatusFilter === 'ok') rows = rows.filter((s) => s.health_ok && s.system_info_ok);
|
||||
if (nodeStatusFilter === 'degraded')
|
||||
rows = rows.filter((s) => !s.health_ok || !s.system_info_ok);
|
||||
const k = nodeSortKey;
|
||||
const d = nodeSortDir;
|
||||
if (k && d) {
|
||||
rows.sort((a, b) => {
|
||||
if (k === 'alias') return compareStrings(a.alias ?? '', b.alias ?? '', d);
|
||||
if (k === 'latency') {
|
||||
const la = a.health_latency_ms ?? -1;
|
||||
const lb = b.health_latency_ms ?? -1;
|
||||
return compareNum(la, lb, d);
|
||||
}
|
||||
if (k === 'bad') {
|
||||
const ba =
|
||||
a.alias && nodeStats[a.alias] && 'bad' in nodeStats[a.alias]!
|
||||
? (nodeStats[a.alias] as { bad?: number }).bad ?? -1
|
||||
: -1;
|
||||
const bb =
|
||||
b.alias && nodeStats[b.alias] && 'bad' in nodeStats[b.alias]!
|
||||
? (nodeStats[b.alias] as { bad?: number }).bad ?? -1
|
||||
: -1;
|
||||
return compareNum(ba, bb, d);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
let filteredSortedTopUsers = $derived.by((): TopUser[] => {
|
||||
if (!summary) return [];
|
||||
let rows = [...summary.top_users.slice(0, 10)];
|
||||
const q = topFilter.trim().toLowerCase();
|
||||
if (q) rows = rows.filter((t) => t.username.toLowerCase().includes(q));
|
||||
const k = topSortKey;
|
||||
const d = topSortDir;
|
||||
if (k && d) {
|
||||
rows.sort((a, b) => {
|
||||
if (k === 'user') return compareStrings(a.username, b.username, d);
|
||||
if (k === 'traffic') return compareNum(a.total_megabytes, b.total_megabytes, d);
|
||||
return compareNum(uniqueIpCountByUser[a.username] ?? 0, uniqueIpCountByUser[b.username] ?? 0, d);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
function toggleIpSort(key: 'ip' | 'user') {
|
||||
const next = nextSortDir(ipSortDir, key, ipSortKey);
|
||||
ipSortKey = next === null ? null : key;
|
||||
ipSortDir = next;
|
||||
}
|
||||
function toggleNodeSort(key: 'alias' | 'latency' | 'bad') {
|
||||
const next = nextSortDir(nodeSortDir, key, nodeSortKey);
|
||||
nodeSortKey = next === null ? null : key;
|
||||
nodeSortDir = next;
|
||||
}
|
||||
function toggleTopSort(key: 'user' | 'traffic' | 'ips') {
|
||||
const next = nextSortDir(topSortDir, key, topSortKey);
|
||||
topSortKey = next === null ? null : key;
|
||||
topSortDir = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Telemt multi-server overview
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1>
|
||||
{#if generatedAt}
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Снимок: {new Date(generatedAt).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Состояние нод и агрегированные метрики Telemt
|
||||
{#if generatedAt}
|
||||
<span class="hidden sm:inline"> · Снимок: {new Date(generatedAt).toLocaleString()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||
@@ -397,7 +524,11 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||
<AliasFilterSelect
|
||||
availableAliases={availableAliases}
|
||||
bind:value={aliasFilter}
|
||||
onValueChange={(next) => void handleAliasFilterChange(next)}
|
||||
/>
|
||||
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||
</div>
|
||||
|
||||
@@ -420,229 +551,464 @@
|
||||
{#snippet skeleton()}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex gap-3">
|
||||
<Skeleton class="size-10 shrink-0 rounded-lg" />
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<Skeleton class="h-8 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Skeleton class="h-3 w-28" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Skeleton class="h-28 rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
<Skeleton class="mb-4 h-32 w-full rounded-lg" />
|
||||
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
|
||||
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if summary}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос">
|
||||
{#snippet icon()}
|
||||
<ServerIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users">
|
||||
{#snippet icon()}
|
||||
<UsersIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary.fleet_total_connections}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)">
|
||||
{#snippet icon()}
|
||||
<UnplugIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{totalBad}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips">
|
||||
{#snippet icon()}
|
||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{uniqueIpCount}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB">
|
||||
{#snippet icon()}
|
||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{formatMiB(summary.fleet_total_megabytes)}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only">
|
||||
{#snippet icon()}
|
||||
<LockKeyholeIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{readOnlyNodes}
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
|
||||
{@const totN = fleet?.servers_total ?? summary.servers_total}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<KpiStatCard accent="violet" label="Ноды OK / всего" footer="health + system/info" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<ServerIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{okN}/{totN}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="info"
|
||||
label="Соединения (флот)"
|
||||
footer="агрегат stats/users"
|
||||
valueClass="text-xl"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<PlugIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary.fleet_total_connections}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="danger" label="Плохие соединения" footer="сумма по нодам" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<ShieldAlertIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{totalBad}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="teal" label="Уникальные IP" footer="по снимку unique-ips" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{uniqueIpCount}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="warning"
|
||||
label="Трафик флота (MiB)"
|
||||
footer="суммарный трафик summary"
|
||||
valueClass="text-lg"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<CloudUploadIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{formatMiB(summary.fleet_total_megabytes)}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="neutral" label="Read-only API (нод)" footer="health.read_only" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<LockIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{readOnlyNodes}
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
|
||||
<KpiPanelCard
|
||||
class="mb-6"
|
||||
accent="violet"
|
||||
title="Активные IP (агрегат)"
|
||||
description="С GeoIP при включённой базе на шлюзе"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm">Клиент</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
Серверы
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Пояснение колонки"
|
||||
>
|
||||
<InfoIcon class="size-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" class="max-w-xs">
|
||||
<p class="text-xs">
|
||||
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены
|
||||
группы.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if (activeIpServerRows?.length ?? 0) === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground">
|
||||
Нет активных подключений
|
||||
</Table.Cell>
|
||||
<FleetOverviewCharts
|
||||
nodesOk={nodesOkChart}
|
||||
nodesDegraded={nodesDegradedChart}
|
||||
topUsers={summary.top_users.slice(0, 8)}
|
||||
/>
|
||||
|
||||
<DataTableCard
|
||||
class="mb-6"
|
||||
title="Активные IP (агрегат)"
|
||||
description="С GeoIP при включённой базе на шлюзе"
|
||||
accent="teal"
|
||||
>
|
||||
{#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-xs flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
class="h-8 ps-8"
|
||||
placeholder="Поиск IP, пользователя…"
|
||||
bind:value={ipFilter}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
ipFilter = '';
|
||||
ipSortKey = null;
|
||||
ipSortDir = null;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
{/snippet}
|
||||
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<SortableTh
|
||||
active={ipSortKey === 'ip'}
|
||||
dir={ipSortDir}
|
||||
onToggle={() => toggleIpSort('ip')}
|
||||
>IP / страна</SortableTh>
|
||||
<SortableTh
|
||||
active={ipSortKey === 'user'}
|
||||
dir={ipSortDir}
|
||||
onToggle={() => toggleIpSort('user')}
|
||||
>Пользователь</SortableTh>
|
||||
<Table.Head class="text-xs sm:text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
Серверы
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Пояснение колонки"
|
||||
>
|
||||
<InfoIcon class="size-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" class="max-w-xs">
|
||||
<p class="text-xs">
|
||||
Активные и недавние — на момент снимка шлюза.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each activeIpServerRows as r (r.username + '|' + r.ip)}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if activeIpServerRows.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
|
||||
{r.activeLabel}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs sm:text-sm">
|
||||
<a
|
||||
href="/users/{encodeURIComponent(r.username)}"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{r.username}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
|
||||
<IpServerPresence
|
||||
active_on_servers={r.active_on_servers}
|
||||
recent_on_servers={r.recent_on_servers}
|
||||
primary_server={r.primary_server}
|
||||
<Table.Cell colspan={4} class="p-0">
|
||||
<TableEmpty
|
||||
title="Нет активных подключений"
|
||||
description="В снимке unique-ips нет IP с активными серверами."
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</KpiPanelCard>
|
||||
{:else if filteredSortedIpRows.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="p-0">
|
||||
<TableEmpty
|
||||
title="Нет строк по фильтру"
|
||||
description="Измените поиск или сбросьте фильтры."
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each filteredSortedIpRows as r (r.username + '|' + r.ip)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
|
||||
<Badge variant="secondary" class="font-mono font-normal">{r.activeLabel}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs sm:text-sm">
|
||||
<a
|
||||
href="/users/{encodeURIComponent(r.username)}"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{r.username}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
|
||||
<IpServerPresence
|
||||
active_on_servers={r.active_on_servers}
|
||||
recent_on_servers={r.recent_on_servers}
|
||||
primary_server={r.primary_server}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-10 text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
|
||||
<MoreVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
window.open(
|
||||
`/users/${encodeURIComponent(r.username)}`,
|
||||
'_blank'
|
||||
)}
|
||||
>Открыть пользователя</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</ScrollArea>
|
||||
</DataTableCard>
|
||||
|
||||
<KpiPanelCard accent="info" title="По нодам" description="Health, версия, аптайм, трафик ноды" contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
<ServerIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Сервер</Table.Head>
|
||||
<Table.Head>Статус</Table.Head>
|
||||
<Table.Head class="text-right">Latency</Table.Head>
|
||||
<Table.Head class="text-right">Версия</Table.Head>
|
||||
<Table.Head class="text-right">Аптайм</Table.Head>
|
||||
<Table.Head class="text-right">Bad conn.</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each fleet?.servers ?? [] as srv (srv.alias)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a class="text-primary hover:underline" href="/servers/{encodeURIComponent(srv.alias ?? '')}">
|
||||
{srv.alias}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if srv.health_ok && srv.system_info_ok}
|
||||
<Badge class="bg-emerald-600/20 text-emerald-400">OK</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive">degraded</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||
{srv.health_latency_ms ?? '—'} ms
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground">
|
||||
{srv.system_info?.version ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground">
|
||||
{srv.system_info && srv.system_info.uptime_seconds != null
|
||||
? formatDurationSeconds(srv.system_info.uptime_seconds)
|
||||
: '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{#if srv.alias}
|
||||
{@const st = nodeStats[srv.alias]}
|
||||
{#if !st}
|
||||
<span title="Нет ответа stats/summary для этой ноды">—</span>
|
||||
{:else if st && 'ok' in st && !st.ok && 'error' in st}
|
||||
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
|
||||
{:else if st && 'bad' in st && st.ok}
|
||||
{st.bad ?? 0}
|
||||
{:else}
|
||||
<span title="Неожиданное состояние stats">—</span>
|
||||
{/if}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</KpiPanelCard>
|
||||
<DataTableCard
|
||||
class="mb-6"
|
||||
title="По нодам"
|
||||
description="Health, версия, аптайм, bad connections"
|
||||
accent="violet"
|
||||
>
|
||||
{#snippet icon()}<ServerIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-xs flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
class="h-8 ps-8"
|
||||
placeholder="Поиск по alias…"
|
||||
bind:value={nodeFilter}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={nodeStatusFilter === 'all' ? 'default' : 'outline'}
|
||||
class="h-8"
|
||||
onclick={() => (nodeStatusFilter = 'all')}
|
||||
>Все</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={nodeStatusFilter === 'ok' ? 'default' : 'outline'}
|
||||
class="h-8"
|
||||
onclick={() => (nodeStatusFilter = 'ok')}
|
||||
>OK</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={nodeStatusFilter === 'degraded' ? 'default' : 'outline'}
|
||||
class="h-8"
|
||||
onclick={() => (nodeStatusFilter = 'degraded')}
|
||||
>Degraded</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
nodeFilter = '';
|
||||
nodeStatusFilter = 'all';
|
||||
nodeSortKey = null;
|
||||
nodeSortDir = null;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
{/snippet}
|
||||
<ScrollArea class="max-h-[min(70vh,480px)] w-full" orientation="both">
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<SortableTh
|
||||
active={nodeSortKey === 'alias'}
|
||||
dir={nodeSortDir}
|
||||
onToggle={() => toggleNodeSort('alias')}
|
||||
>Сервер</SortableTh>
|
||||
<Table.Head>Статус</Table.Head>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={nodeSortKey === 'latency'}
|
||||
dir={nodeSortDir}
|
||||
onToggle={() => toggleNodeSort('latency')}
|
||||
class="text-right"
|
||||
>Latency</SortableTh>
|
||||
<Table.Head class="text-right">Версия</Table.Head>
|
||||
<Table.Head class="text-right">Аптайм</Table.Head>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={nodeSortKey === 'bad'}
|
||||
dir={nodeSortDir}
|
||||
onToggle={() => toggleNodeSort('bad')}
|
||||
class="text-right"
|
||||
>Bad conn.</SortableTh>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if filteredSortedNodeRows.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="p-0">
|
||||
<TableEmpty title="Нет нод по фильтру" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each filteredSortedNodeRows as srv (srv.alias)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
class="text-primary hover:underline"
|
||||
href="/servers/{encodeURIComponent(srv.alias ?? '')}"
|
||||
>
|
||||
{srv.alias}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if srv.health_ok && srv.system_info_ok}
|
||||
<StatusBadge variant="ok" />
|
||||
{:else}
|
||||
<StatusBadge variant="degraded" />
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||
{srv.health_latency_ms ?? '—'} ms
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground">
|
||||
<Badge variant="secondary">{srv.system_info?.version ?? '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground">
|
||||
{srv.system_info && srv.system_info.uptime_seconds != null
|
||||
? formatDurationSeconds(srv.system_info.uptime_seconds)
|
||||
: '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{#if srv.alias}
|
||||
{@const st = nodeStats[srv.alias]}
|
||||
{#if !st}
|
||||
<StatusBadge variant="pending" />
|
||||
{:else if st && 'ok' in st && !st.ok && 'error' in st}
|
||||
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
|
||||
{:else if st && 'bad' in st && st.ok}
|
||||
{st.bad ?? 0}
|
||||
{:else}
|
||||
<span title="Неожиданное состояние stats">—</span>
|
||||
{/if}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
|
||||
<MoreVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
window.open(
|
||||
`/servers/${encodeURIComponent(srv.alias ?? '')}`,
|
||||
'_blank'
|
||||
)}
|
||||
>Открыть ноду</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</ScrollArea>
|
||||
</DataTableCard>
|
||||
|
||||
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass="">
|
||||
{#snippet icon()}
|
||||
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Пользователь</Table.Head>
|
||||
<Table.Head class="text-right">Трафик</Table.Head>
|
||||
<Table.Head class="text-right">Замеченные IP</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each summary.top_users.slice(0, 10) as t (t.username)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
|
||||
{t.username}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">{formatMiB(t.total_megabytes)}</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||
{uniqueIpCountByUser[t.username] ?? 0}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</KpiPanelCard>
|
||||
<DataTableCard
|
||||
class="mt-6"
|
||||
title="Топ пользователей по трафику"
|
||||
description="До 10 записей из summary"
|
||||
accent="info"
|
||||
>
|
||||
{#snippet icon()}<UsersIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-xs flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-8 ps-8" placeholder="Поиск пользователя…" bind:value={topFilter} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
topFilter = '';
|
||||
topSortKey = null;
|
||||
topSortDir = null;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
{/snippet}
|
||||
<ScrollArea class="max-h-[min(60vh,400px)] w-full" orientation="vertical">
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<SortableTh
|
||||
active={topSortKey === 'user'}
|
||||
dir={topSortDir}
|
||||
onToggle={() => toggleTopSort('user')}
|
||||
>Пользователь</SortableTh>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={topSortKey === 'traffic'}
|
||||
dir={topSortDir}
|
||||
onToggle={() => toggleTopSort('traffic')}
|
||||
class="text-right"
|
||||
>Трафик</SortableTh>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={topSortKey === 'ips'}
|
||||
dir={topSortDir}
|
||||
onToggle={() => toggleTopSort('ips')}
|
||||
class="text-right"
|
||||
>IP</SortableTh>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if filteredSortedTopUsers.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="p-0">
|
||||
<TableEmpty title="Нет данных" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each filteredSortedTopUsers as t (t.username)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
|
||||
{t.username}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||
{formatMiB(t.total_megabytes)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||
{uniqueIpCountByUser[t.username] ?? 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
|
||||
<MoreVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
window.open(`/users/${encodeURIComponent(t.username)}`, '_blank')}
|
||||
>Открыть</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</ScrollArea>
|
||||
</DataTableCard>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
@@ -6,16 +6,19 @@
|
||||
ApiError,
|
||||
fetchAggIncidents,
|
||||
fetchAggSummary,
|
||||
postAggResetBadConnectionsBaseline,
|
||||
type IncidentItem,
|
||||
type IncidentsData
|
||||
} from '$lib/api/client.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
|
||||
import InfoSmallIcon from '@lucide/svelte/icons/info';
|
||||
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
@@ -29,6 +32,10 @@
|
||||
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import InfoIcon from '@lucide/svelte/icons/info';
|
||||
import SirenIcon from '@lucide/svelte/icons/siren';
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
|
||||
import InfoIconBadge from '@lucide/svelte/icons/info';
|
||||
import EraserIcon from '@lucide/svelte/icons/eraser';
|
||||
|
||||
type TriageState = {
|
||||
ack?: boolean;
|
||||
@@ -54,6 +61,11 @@
|
||||
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let triageById = $state<Record<string, TriageState>>({});
|
||||
|
||||
let incSearch = $state('');
|
||||
let incSortKey = $state<'sev' | 'title' | null>(null);
|
||||
let incSortDir = $state<SortDir | null>(null);
|
||||
let resetBadBusy = $state(false);
|
||||
|
||||
function parseRefresh(raw: string | null): number {
|
||||
if (raw == null || raw.trim() === '') return 30;
|
||||
const n = Number(raw);
|
||||
@@ -115,6 +127,12 @@
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function severityLabel(sev: IncidentItem['severity']): string {
|
||||
if (sev === 'critical') return 'критично';
|
||||
if (sev === 'warning') return 'предупреждение';
|
||||
return 'инфо';
|
||||
}
|
||||
|
||||
function restartPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
@@ -127,6 +145,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function sevRank(s: IncidentItem['severity']): number {
|
||||
if (s === 'critical') return 3;
|
||||
if (s === 'warning') return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function load(aliasOverride?: string) {
|
||||
loading = true;
|
||||
err = null;
|
||||
@@ -225,18 +249,91 @@
|
||||
|
||||
let hasStaleData = $derived(err != null && data != null);
|
||||
let showSkeleton = $derived(loading && data === null && !err);
|
||||
|
||||
let filteredIncidents = $derived.by((): IncidentItem[] => {
|
||||
const items = data?.items ?? [];
|
||||
const q = incSearch.trim().toLowerCase();
|
||||
let out = items;
|
||||
if (q) {
|
||||
out = items.filter(
|
||||
(it) =>
|
||||
(it.title ?? '').toLowerCase().includes(q) ||
|
||||
(it.summary ?? '').toLowerCase().includes(q) ||
|
||||
(it.severity ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const k = incSortKey;
|
||||
const d = incSortDir;
|
||||
if (k && d) {
|
||||
out = [...out].sort((a, b) => {
|
||||
if (k === 'sev') return compareNum(sevRank(a.severity), sevRank(b.severity), d);
|
||||
return compareStrings(a.title ?? '', b.title ?? '', d);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function toggleIncSort(key: 'sev' | 'title') {
|
||||
const next = nextSortDir(incSortDir, key, incSortKey);
|
||||
incSortKey = next === null ? null : key;
|
||||
incSortDir = next;
|
||||
}
|
||||
|
||||
async function resetBadConnectionsBaseline() {
|
||||
if (resetBadBusy) return;
|
||||
resetBadBusy = true;
|
||||
try {
|
||||
const env = await postAggResetBadConnectionsBaseline({
|
||||
aliases: aliasFilter === 'all' ? undefined : aliasFilter
|
||||
});
|
||||
const failed = env.data.servers.filter((s) => !s.ok);
|
||||
if (failed.length > 0) {
|
||||
toast.warning(
|
||||
`Не на всех нодах удалось прочитать stats/summary: ${failed.map((f) => `${f.alias}: ${f.error ?? 'ошибка'}`).join('; ')}`
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
'Для алертов bad connections зафиксированы текущие значения счётчиков (отметка на шлюзе).'
|
||||
);
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e));
|
||||
} finally {
|
||||
resetBadBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1>
|
||||
<p class="text-sm text-muted-foreground">Сводка и triage по всему флоту.</p>
|
||||
<p class="text-sm text-muted-foreground">Сводка и разбор инцидентов по всему флоту.</p>
|
||||
{#if generatedAt}
|
||||
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={resetBadBusy || loading}
|
||||
onclick={() => void resetBadConnectionsBaseline()}
|
||||
>
|
||||
<EraserIcon class="mr-1 size-4 {resetBadBusy ? 'animate-pulse' : ''}" />
|
||||
Сброс bad connections
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-xs">
|
||||
<p class="text-xs">
|
||||
Запомнить текущий <span class="font-mono">connections_bad_total</span> на выбранных нодах как ноль для
|
||||
инцидентов на этом шлюзе (память процесса). Сырые счётчики на нодах Telemt не меняются.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
@@ -277,35 +374,62 @@
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||
<KpiStatCard accent="danger" label="Critical" footer="открытые critical">
|
||||
<KpiStatCard accent="danger" label="Критичные" footer="Открытые инциденты" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<CircleAlertIcon class="size-5" aria-hidden="true" />
|
||||
<SirenIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{data?.critical_total ?? 0}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="warning" label="Warning" footer="открытые warning">
|
||||
<KpiStatCard accent="warning" label="Предупреждения" footer="Открытые инциденты" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<TriangleAlertIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{data?.warning_total ?? 0}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="info" label="Info" footer="открытые info">
|
||||
<KpiStatCard accent="info" label="Инфо" footer="Открытые инциденты" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<InfoSmallIcon class="size-5" aria-hidden="true" />
|
||||
<InfoIconBadge class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{data?.info_total ?? 0}
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
|
||||
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
<ClipboardListIcon class="size-5" aria-hidden="true" />
|
||||
<DataTableCard title="Инциденты" description="Список и разбор" accent="danger">
|
||||
{#snippet icon()}<SirenIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-md flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-8 ps-8" placeholder="Поиск по заголовку, описанию, критичности…" bind:value={incSearch} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
incSearch = '';
|
||||
incSortKey = null;
|
||||
incSortDir = null;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
{/snippet}
|
||||
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Severity</Table.Head>
|
||||
<Table.Head>Инцидент</Table.Head>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<SortableTh
|
||||
active={incSortKey === 'sev'}
|
||||
dir={incSortDir}
|
||||
onToggle={() => toggleIncSort('sev')}
|
||||
>Критичность</SortableTh>
|
||||
<SortableTh
|
||||
active={incSortKey === 'title'}
|
||||
dir={incSortDir}
|
||||
onToggle={() => toggleIncSort('title')}
|
||||
>Инцидент</SortableTh>
|
||||
<Table.Head>
|
||||
<div class="flex items-center gap-1">
|
||||
Серверы
|
||||
@@ -322,8 +446,8 @@
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Table.Head>
|
||||
<Table.Head>Runbook</Table.Head>
|
||||
<Table.Head>Triage</Table.Head>
|
||||
<Table.Head>Инструкции</Table.Head>
|
||||
<Table.Head>Разбор</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -333,11 +457,17 @@
|
||||
Инцидентов нет
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if filteredIncidents.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="p-4 text-center text-muted-foreground">
|
||||
Нет строк по фильтру
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each data?.items ?? [] as item (item.id)}
|
||||
{#each filteredIncidents as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
||||
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[360px]">
|
||||
<div class="font-medium">{item.title}</div>
|
||||
@@ -360,29 +490,33 @@
|
||||
{/each}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[280px]">
|
||||
<Table.Cell class="min-w-[240px]">
|
||||
{@const triage = triageFor(item.id)}
|
||||
<div class="mb-2 flex flex-wrap gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
class="h-7 px-2 text-xs"
|
||||
variant={triage.ack ? 'default' : 'outline'}
|
||||
onclick={() => toggleAck(item.id)}
|
||||
title={triage.ack ? 'Снять подтверждение' : 'Подтвердить'}
|
||||
>
|
||||
{triage.ack ? 'unack' : 'ack'}
|
||||
{triage.ack ? 'Снять' : 'Подтв.'}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
class="h-7 px-2 text-xs"
|
||||
variant={triage.resolved ? 'default' : 'outline'}
|
||||
onclick={() => toggleResolved(item.id)}
|
||||
title={triage.resolved ? 'Вернуть в работу' : 'Решить'}
|
||||
>
|
||||
{triage.resolved ? 'unresolve' : 'resolve'}
|
||||
{triage.resolved ? 'В работу' : 'Решить'}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<Input
|
||||
type="text"
|
||||
class="h-8 w-full text-xs"
|
||||
placeholder="owner"
|
||||
placeholder="Ответственный"
|
||||
value={triage.owner ?? ''}
|
||||
oninput={(e) =>
|
||||
patchTriage(item.id, {
|
||||
@@ -394,7 +528,7 @@
|
||||
<Input
|
||||
type="text"
|
||||
class="h-8 w-full text-xs"
|
||||
placeholder="note"
|
||||
placeholder="Примечание"
|
||||
value={triage.note ?? ''}
|
||||
oninput={(e) =>
|
||||
patchTriage(item.id, {
|
||||
@@ -408,6 +542,7 @@
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</KpiPanelCard>
|
||||
</ScrollArea>
|
||||
</DataTableCard>
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
+175
-16
@@ -9,6 +9,13 @@
|
||||
import { flagEmoji } from '$lib/format.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { nextSortDir, compareStrings, type SortDir } from '$lib/table-sort.js';
|
||||
import MapIcon from '@lucide/svelte/icons/map';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
@@ -49,6 +56,77 @@
|
||||
let mapStatus = $state<string | null>(null);
|
||||
let renderAttempts = $state(0);
|
||||
|
||||
type IpFlatRow = {
|
||||
username: string;
|
||||
ip: components['schemas']['IPAssignments'];
|
||||
};
|
||||
|
||||
let ipSearch = $state('');
|
||||
let ipSortKey = $state<'user' | 'ip' | 'geo' | null>(null);
|
||||
let ipSortDir = $state<SortDir | null>(null);
|
||||
const ipsPerPage = 25;
|
||||
let ipsPage = $state(1);
|
||||
|
||||
let flatIpRows = $derived.by((): IpFlatRow[] => {
|
||||
const out: IpFlatRow[] = [];
|
||||
for (const ur of rows) {
|
||||
const u = ur.username ?? '';
|
||||
for (const ipa of ur.ips ?? []) {
|
||||
if (ipa?.ip) out.push({ username: u, ip: ipa });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
let displayedIpRows = $derived.by(() => {
|
||||
let r = flatIpRows;
|
||||
const q = ipSearch.trim().toLowerCase();
|
||||
if (q) {
|
||||
r = r.filter(
|
||||
({ username, ip }) =>
|
||||
username.toLowerCase().includes(q) ||
|
||||
(ip.ip ?? '').toLowerCase().includes(q) ||
|
||||
(ip.country_code ?? '').toLowerCase().includes(q) ||
|
||||
(ip.city_name ?? '').toLowerCase().includes(q) ||
|
||||
String(ip.asn ?? '').includes(q)
|
||||
);
|
||||
}
|
||||
const k = ipSortKey;
|
||||
const d = ipSortDir;
|
||||
if (k && d) {
|
||||
r = [...r].sort((a, b) => {
|
||||
if (k === 'user') return compareStrings(a.username, b.username, d);
|
||||
if (k === 'ip') return compareStrings(a.ip.ip ?? '', b.ip.ip ?? '', d);
|
||||
const ga = `${a.ip.country_code ?? ''} ${a.ip.city_name ?? ''}`;
|
||||
const gb = `${b.ip.country_code ?? ''} ${b.ip.city_name ?? ''}`;
|
||||
return compareStrings(ga, gb, d);
|
||||
});
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
let ipsPageCount = $derived(Math.max(1, Math.ceil(displayedIpRows.length / ipsPerPage)));
|
||||
let pagedIpRows = $derived.by(() => {
|
||||
const start = (ipsPage - 1) * ipsPerPage;
|
||||
return displayedIpRows.slice(start, start + ipsPerPage);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
ipSearch;
|
||||
aliasFilter;
|
||||
ipsPage = 1;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ipsPage > ipsPageCount) ipsPage = ipsPageCount;
|
||||
});
|
||||
|
||||
function toggleIpSort(key: 'user' | 'ip' | 'geo') {
|
||||
const next = nextSortDir(ipSortDir, key, ipSortKey);
|
||||
ipSortKey = next === null ? null : key;
|
||||
ipSortDir = next;
|
||||
}
|
||||
|
||||
function parseAliasFilter(raw: string | null): string {
|
||||
const value = raw?.trim() ?? '';
|
||||
return !value || value.includes(',') ? 'all' : value;
|
||||
@@ -373,6 +451,14 @@
|
||||
noScroll: true,
|
||||
keepFocus: true
|
||||
});
|
||||
// Страхуемся от кейсов, когда URL уже сменился, а перезапрос не стартовал.
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleAliasFilterChange(nextAlias: string) {
|
||||
if (nextAlias === aliasFilter) return;
|
||||
aliasFilter = nextAlias;
|
||||
await applyAliasIfChanged(nextAlias);
|
||||
}
|
||||
|
||||
let queryKey = $derived(page.url.searchParams.toString());
|
||||
@@ -431,7 +517,7 @@
|
||||
let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
@@ -456,7 +542,11 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||
<AliasFilterSelect
|
||||
availableAliases={availableAliases}
|
||||
bind:value={aliasFilter}
|
||||
onValueChange={(next) => void handleAliasFilterChange(next)}
|
||||
/>
|
||||
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||
</div>
|
||||
|
||||
@@ -528,16 +618,52 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||
<DataTableCard title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" accent="teal">
|
||||
{#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-md flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-8 ps-8" placeholder="Поиск: пользователь, IP, страна, город…" bind:value={ipSearch} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
ipSearch = '';
|
||||
ipSortKey = null;
|
||||
ipSortDir = null;
|
||||
ipsPage = 1;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
{/snippet}
|
||||
<div class="flex w-full flex-col">
|
||||
<ScrollArea
|
||||
class="h-[min(75vh,560px)] w-full shrink-0"
|
||||
orientation="both"
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Пользователь</Table.Head>
|
||||
<Table.Head>IP</Table.Head>
|
||||
<Table.Head>Geo</Table.Head>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<SortableTh
|
||||
active={ipSortKey === 'user'}
|
||||
dir={ipSortDir}
|
||||
onToggle={() => toggleIpSort('user')}
|
||||
>Пользователь</SortableTh>
|
||||
<SortableTh
|
||||
active={ipSortKey === 'ip'}
|
||||
dir={ipSortDir}
|
||||
onToggle={() => toggleIpSort('ip')}
|
||||
>IP</SortableTh>
|
||||
<SortableTh
|
||||
active={ipSortKey === 'geo'}
|
||||
dir={ipSortDir}
|
||||
onToggle={() => toggleIpSort('geo')}
|
||||
>Geo</SortableTh>
|
||||
<Table.Head>
|
||||
<div class="flex items-center gap-1">
|
||||
Серверы
|
||||
@@ -559,12 +685,18 @@
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as ur (ur.username)}
|
||||
{#each ur.ips ?? [] as ip (ur.username + (ip.ip ?? ''))}
|
||||
{#if displayedIpRows.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
|
||||
Нет строк по фильтру или снимок пуст.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pagedIpRows as { username, ip } (username + (ip.ip ?? ''))}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a href="/users/{encodeURIComponent(ur.username ?? '')}" class="text-primary hover:underline">
|
||||
{ur.username}
|
||||
<a href="/users/{encodeURIComponent(username)}" class="text-primary hover:underline">
|
||||
{username}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
|
||||
@@ -587,10 +719,37 @@
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</KpiPanelCard>
|
||||
</ScrollArea>
|
||||
{#if displayedIpRows.length > ipsPerPage}
|
||||
<div
|
||||
class="relative z-10 flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
{displayedIpRows.length === 0
|
||||
? '0'
|
||||
: `${(ipsPage - 1) * ipsPerPage + 1}–${Math.min(ipsPage * ipsPerPage, displayedIpRows.length)}`} из {displayedIpRows.length}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={ipsPage <= 1}
|
||||
onclick={() => (ipsPage = Math.max(1, ipsPage - 1))}>Назад</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={ipsPage >= ipsPageCount}
|
||||
onclick={() => (ipsPage = Math.min(ipsPageCount, ipsPage + 1))}>Вперёд</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DataTableCard>
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
|
||||
|
||||
@@ -5,16 +5,24 @@
|
||||
import {
|
||||
liveEventsUrl,
|
||||
fetchAggSummary,
|
||||
postAggResetBadConnectionsBaseline,
|
||||
ApiError,
|
||||
type IncidentItem,
|
||||
type IncidentSeverity,
|
||||
type IncidentStatus
|
||||
} from '$lib/api/client.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
import LayersIcon from '@lucide/svelte/icons/layers';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
@@ -25,6 +33,7 @@
|
||||
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import InfoIcon from '@lucide/svelte/icons/info';
|
||||
import EraserIcon from '@lucide/svelte/icons/eraser';
|
||||
|
||||
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
||||
|
||||
@@ -56,18 +65,95 @@
|
||||
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let es: EventSource | null = null;
|
||||
|
||||
let liveIncSearch = $state('');
|
||||
let liveSortKey = $state<'sev' | 'title' | null>(null);
|
||||
let liveSortDir = $state<SortDir | null>(null);
|
||||
let resetBadBusy = $state(false);
|
||||
|
||||
function liveSevRank(s: IncidentSeverity): number {
|
||||
if (s === 'critical') return 3;
|
||||
if (s === 'warning') return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
let filteredLiveIncidents = $derived.by((): IncidentItem[] => {
|
||||
const items = snapshot?.incidents ?? [];
|
||||
const q = liveIncSearch.trim().toLowerCase();
|
||||
let out = items;
|
||||
if (q) {
|
||||
out = items.filter(
|
||||
(it) =>
|
||||
(it.title ?? '').toLowerCase().includes(q) ||
|
||||
(it.summary ?? '').toLowerCase().includes(q) ||
|
||||
(it.severity ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const k = liveSortKey;
|
||||
const d = liveSortDir;
|
||||
if (k && d) {
|
||||
out = [...out].sort((a, b) => {
|
||||
if (k === 'sev') return compareNum(liveSevRank(a.severity), liveSevRank(b.severity), d);
|
||||
return compareStrings(a.title, b.title, d);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
async function resetBadConnectionsBaseline() {
|
||||
if (resetBadBusy) return;
|
||||
resetBadBusy = true;
|
||||
try {
|
||||
const env = await postAggResetBadConnectionsBaseline({
|
||||
aliases: aliasFilter === 'all' ? undefined : aliasFilter
|
||||
});
|
||||
const failed = env.data.servers.filter((s) => !s.ok);
|
||||
if (failed.length > 0) {
|
||||
toast.warning(
|
||||
`Не на всех нодах удалось прочитать stats/summary: ${failed.map((f) => `${f.alias}: ${f.error ?? 'ошибка'}`).join('; ')}`
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
'Для алертов bad connections зафиксированы текущие значения счётчиков (отметка на шлюзе). Следующий снимок потока обновит список.'
|
||||
);
|
||||
}
|
||||
connectStream();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e));
|
||||
} finally {
|
||||
resetBadBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLiveSort(key: 'sev' | 'title') {
|
||||
const next = nextSortDir(liveSortDir, key, liveSortKey);
|
||||
liveSortKey = next === null ? null : key;
|
||||
liveSortDir = next;
|
||||
}
|
||||
|
||||
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (sev === 'critical') return 'destructive';
|
||||
if (sev === 'warning') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function severityLabel(sev: IncidentSeverity): string {
|
||||
if (sev === 'critical') return 'критично';
|
||||
if (sev === 'warning') return 'предупреждение';
|
||||
return 'инфо';
|
||||
}
|
||||
|
||||
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
|
||||
if (st === 'critical') return 'destructive';
|
||||
if (st === 'degraded') return 'secondary';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function statusLabel(st: LiveStatus | undefined): string {
|
||||
if (st === 'critical') return 'критично';
|
||||
if (st === 'degraded') return 'деградация';
|
||||
return 'норма';
|
||||
}
|
||||
|
||||
function parseAliasFilter(raw: string | null): string {
|
||||
const value = raw?.trim() ?? '';
|
||||
return !value || value.includes(',') ? 'all' : value;
|
||||
@@ -268,12 +354,12 @@
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Live</h1>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Поток</h1>
|
||||
<p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant={connected ? 'default' : 'secondary'}>
|
||||
{connected ? 'connected' : 'disconnected'}
|
||||
{connected ? 'подключено' : 'отключено'}
|
||||
</Badge>
|
||||
<FleetLiveStaleBadge
|
||||
{isStale}
|
||||
@@ -283,9 +369,28 @@
|
||||
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
|
||||
: `Последнее событие потока: ${staleSeconds ?? 0} с назад.`}
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={resetBadBusy}
|
||||
onclick={() => void resetBadConnectionsBaseline()}
|
||||
>
|
||||
<EraserIcon class="mr-1 size-4 {resetBadBusy ? 'animate-pulse' : ''}" />
|
||||
Сброс bad connections
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-xs">
|
||||
<p class="text-xs">
|
||||
Запомнить текущий <span class="font-mono">connections_bad_total</span> на выбранных нодах как ноль для
|
||||
инцидентов на этом шлюзе (память процесса). Сырые счётчики на нодах Telemt не меняются.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Button variant="outline" size="sm" onclick={() => connectStream()}>
|
||||
<RefreshCwIcon class="mr-1 size-4" />
|
||||
Reconnect
|
||||
Переподключить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,11 +401,11 @@
|
||||
|
||||
{#if err}
|
||||
<Alert class="mb-4">
|
||||
<AlertTitle>Live stream</AlertTitle>
|
||||
<AlertTitle>Поток событий</AlertTitle>
|
||||
<AlertDescription>
|
||||
{err}
|
||||
{#if reconnectInSec != null}
|
||||
Переподключение через {reconnectInSec}s.
|
||||
Переподключение через {reconnectInSec} с.
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -311,19 +416,17 @@
|
||||
{#snippet icon()}
|
||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<Badge variant={statusVariant(snapshot?.status)}>
|
||||
{snapshot?.status ?? 'healthy'}
|
||||
</Badge>
|
||||
<Badge variant={statusVariant(snapshot?.status)}>{statusLabel(snapshot?.status)}</Badge>
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок">
|
||||
<KpiStatCard accent="warning" label="Частичность" footer="неполный снимок">
|
||||
{#snippet icon()}
|
||||
<LayersIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{snapshot?.partial ? 'yes' : 'no'}
|
||||
{snapshot?.partial ? 'да' : 'нет'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="info"
|
||||
label="Timestamp"
|
||||
label="Временная метка"
|
||||
footer="время snapshot"
|
||||
valueClass="text-base font-normal tabular-nums leading-tight"
|
||||
>
|
||||
@@ -334,21 +437,47 @@
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
|
||||
<KpiPanelCard
|
||||
accent="danger"
|
||||
title="Последние incidents из snapshot"
|
||||
<DataTableCard
|
||||
title="Последние инциденты из снимка"
|
||||
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
|
||||
contentClass="p-0"
|
||||
accent="warning"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<BellIcon class="size-5" aria-hidden="true" />
|
||||
{#snippet icon()}<ActivityIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-md flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-8 ps-8" placeholder="Поиск по критичности, заголовку, описанию…" bind:value={liveIncSearch} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
liveIncSearch = '';
|
||||
liveSortKey = null;
|
||||
liveSortDir = null;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
{/snippet}
|
||||
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Severity</Table.Head>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Summary</Table.Head>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<SortableTh
|
||||
active={liveSortKey === 'sev'}
|
||||
dir={liveSortDir}
|
||||
onToggle={() => toggleLiveSort('sev')}
|
||||
>Критичность</SortableTh>
|
||||
<SortableTh
|
||||
active={liveSortKey === 'title'}
|
||||
dir={liveSortDir}
|
||||
onToggle={() => toggleLiveSort('title')}
|
||||
>Заголовок</SortableTh>
|
||||
<Table.Head>Описание</Table.Head>
|
||||
<Table.Head>
|
||||
<div class="flex items-center gap-1">
|
||||
Серверы
|
||||
@@ -374,11 +503,17 @@
|
||||
Инцидентов нет
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if filteredLiveIncidents.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
|
||||
Нет строк по фильтру
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each snapshot?.incidents ?? [] as item (item.id)}
|
||||
{#each filteredLiveIncidents as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
||||
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{item.title}</Table.Cell>
|
||||
<Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell>
|
||||
@@ -390,4 +525,5 @@
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</KpiPanelCard>
|
||||
</ScrollArea>
|
||||
</DataTableCard>
|
||||
@@ -0,0 +1,425 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
ApiError,
|
||||
fetchAggSummary,
|
||||
fetchMihomoJson,
|
||||
fetchMihomoMeta
|
||||
} from '$lib/api/client.js';
|
||||
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
|
||||
import { formatBytes } from '$lib/format.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
|
||||
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
|
||||
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
|
||||
type RowStatus = 'ok' | 'no_mihomo' | 'error';
|
||||
|
||||
type FleetRow = {
|
||||
alias: string;
|
||||
metaStatus: RowStatus;
|
||||
meta: MihomoMetaResponse | null;
|
||||
metaMessage?: string;
|
||||
alive?: boolean;
|
||||
connections?: number;
|
||||
memory?: number;
|
||||
uploadTotal?: number;
|
||||
downloadTotal?: number;
|
||||
pollErr?: string | null;
|
||||
};
|
||||
|
||||
const POLL_MS = 8000;
|
||||
|
||||
let loading = $state(true);
|
||||
let err = $state<string | null>(null);
|
||||
let rows = $state<FleetRow[]>([]);
|
||||
/** Список alias с рабочим meta; обновляется только при полной перезагрузке. */
|
||||
let mihomoOkAliases: string[] = [];
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** Принудительная перезагрузка /proxies для карточки ноды. */
|
||||
let proxyRefreshNonce = $state<Record<string, number>>({});
|
||||
let proxyDelayAllNonce = $state<Record<string, number>>({});
|
||||
let nodeProxyLoading = $state<Record<string, boolean>>({});
|
||||
let nodeDelayAllBusy = $state<Record<string, boolean>>({});
|
||||
|
||||
function bumpProxyRefresh(a: string) {
|
||||
proxyRefreshNonce = { ...proxyRefreshNonce, [a]: (proxyRefreshNonce[a] ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function bumpProxyDelayAll(a: string) {
|
||||
proxyDelayAllNonce = { ...proxyDelayAllNonce, [a]: (proxyDelayAllNonce[a] ?? 0) + 1 };
|
||||
}
|
||||
|
||||
let withMihomo = $derived(rows.filter((r) => r.metaStatus === 'ok').length);
|
||||
let aliveCount = $derived(rows.filter((r) => r.metaStatus === 'ok' && r.alive).length);
|
||||
let sumConn = $derived(
|
||||
rows.reduce((acc, r) => acc + (r.metaStatus === 'ok' && typeof r.connections === 'number' ? r.connections : 0), 0)
|
||||
);
|
||||
let sumUpload = $derived(
|
||||
rows.reduce((acc, r) => acc + (typeof r.uploadTotal === 'number' ? r.uploadTotal : 0), 0)
|
||||
);
|
||||
let sumDownload = $derived(
|
||||
rows.reduce((acc, r) => acc + (typeof r.downloadTotal === 'number' ? r.downloadTotal : 0), 0)
|
||||
);
|
||||
let sumMemory = $derived(
|
||||
rows.reduce((acc, r) => acc + (typeof r.memory === 'number' && r.memory > 0 ? r.memory : 0), 0)
|
||||
);
|
||||
|
||||
async function probeMeta(alias: string): Promise<FleetRow> {
|
||||
try {
|
||||
const meta = await fetchMihomoMeta(alias);
|
||||
return {
|
||||
alias,
|
||||
metaStatus: 'ok',
|
||||
meta,
|
||||
alive: false,
|
||||
pollErr: null
|
||||
};
|
||||
} catch (e) {
|
||||
const ae = e instanceof ApiError ? e : null;
|
||||
if (ae?.status === 404) {
|
||||
return {
|
||||
alias,
|
||||
metaStatus: 'no_mihomo',
|
||||
meta: null,
|
||||
metaMessage: ae.message
|
||||
};
|
||||
}
|
||||
return {
|
||||
alias,
|
||||
metaStatus: 'error',
|
||||
meta: null,
|
||||
metaMessage: ae?.message ?? String(e)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function pollMetrics() {
|
||||
if (mihomoOkAliases.length === 0) return;
|
||||
await Promise.all(
|
||||
mihomoOkAliases.map(async (alias) => {
|
||||
try {
|
||||
const j = await fetchMihomoJson<MihomoConnectionsResponse>(alias, 'connections');
|
||||
const list = j.connections ?? [];
|
||||
const connTotal = typeof j.total === 'number' ? j.total : list.length;
|
||||
rows = rows.map((r) =>
|
||||
r.alias !== alias
|
||||
? r
|
||||
: {
|
||||
...r,
|
||||
alive: true,
|
||||
connections: connTotal,
|
||||
memory: typeof j.memory === 'number' ? j.memory : undefined,
|
||||
uploadTotal: typeof j.uploadTotal === 'number' ? j.uploadTotal : undefined,
|
||||
downloadTotal: typeof j.downloadTotal === 'number' ? j.downloadTotal : undefined,
|
||||
pollErr: null
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e);
|
||||
rows = rows.map((r) =>
|
||||
r.alias !== alias ? r : { ...r, alive: false, pollErr: msg }
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function reloadAll() {
|
||||
if (!browser) return;
|
||||
loading = true;
|
||||
err = null;
|
||||
try {
|
||||
const env = await fetchAggSummary({ top_n: 1 });
|
||||
const set = new Set<string>();
|
||||
for (const s of env.data.servers ?? []) {
|
||||
if (s.alias) set.add(s.alias);
|
||||
}
|
||||
const aliases = [...set].sort((a, b) => a.localeCompare(b));
|
||||
if (aliases.length === 0) {
|
||||
rows = [];
|
||||
mihomoOkAliases = [];
|
||||
return;
|
||||
}
|
||||
const probed = await Promise.all(aliases.map((a) => probeMeta(a)));
|
||||
probed.sort((a, b) => a.alias.localeCompare(b.alias));
|
||||
rows = probed;
|
||||
mihomoOkAliases = probed.filter((r) => r.metaStatus === 'ok').map((r) => r.alias);
|
||||
await pollMetrics();
|
||||
} catch (e) {
|
||||
err = e instanceof ApiError ? e.message : String(e);
|
||||
rows = [];
|
||||
mihomoOkAliases = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
void reloadAll();
|
||||
pollTimer = setInterval(() => void pollMetrics(), POLL_MS);
|
||||
return () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function statusBadge(r: FleetRow) {
|
||||
if (r.metaStatus === 'no_mihomo') return { label: 'Нет Mihomo', variant: 'secondary' as const };
|
||||
if (r.metaStatus === 'error') return { label: 'Meta ошибка', variant: 'destructive' as const };
|
||||
if (r.alive === false && r.pollErr) return { label: 'Недоступен', variant: 'destructive' as const };
|
||||
if (r.alive) return { label: 'Онлайн', variant: 'default' as const };
|
||||
return { label: 'Ожидание', variant: 'outline' as const };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Mihomo (флот)</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Сводка по нодам и управление прокси. Опрос соединений каждые {POLL_MS / 1000} с.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => void reloadAll()} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataQueryState {err} showSkeleton={loading} isEmpty={!loading && rows.length === 0 && !err}>
|
||||
{#snippet empty()}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
В снимке агрегата нет серверов. Проверьте шлюз и конфигурацию <code class="text-xs">servers</code>.
|
||||
</p>
|
||||
{/snippet}
|
||||
{#snippet skeleton()}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{#each Array.from({ length: 5 }) as _, i (i)}
|
||||
<Skeleton class="h-28 rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
<Skeleton class="h-64 w-full rounded-lg" />
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if rows.length > 0}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<KpiStatCard accent="violet" label="Нод с Mihomo" footer="meta OK" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<ServerIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{withMihomo}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="success" label="Онлайн (опрос)" footer="успешный /connections" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<CheckCircleIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{aliveCount}/{withMihomo}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="info" label="Соединения ∑" footer="по снимку" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<PlugIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{sumConn}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="neutral" label="Память ∑" footer="поле memory" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<MemoryStickIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{sumMemory > 0 ? formatBytes(sumMemory) : '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="warning" label="Трафик ∑" footer="upload / download" valueClass="text-lg">
|
||||
{#snippet icon()}
|
||||
<CloudUploadIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<span class="block truncate" title="{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓">
|
||||
{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓
|
||||
</span>
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
|
||||
<DataTableCard
|
||||
title="Ноды"
|
||||
description="Контроллер и метрики. Ниже — компактные группы прокси (автозагрузка). «Детально» — обзор с графиками."
|
||||
accent="violet"
|
||||
>
|
||||
{#snippet icon()}<ServerIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
<ScrollArea class="w-full" orientation="both">
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<Table.Head class="text-xs sm:text-sm">Alias</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm">Контроллер</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm">Статус</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm text-right">Conn</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm text-right">Память</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm text-right">Трафик</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm w-[1%]">Прокси</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm w-[1%]"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as r (r.alias)}
|
||||
{@const b = statusBadge(r)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium text-sm">{r.alias}</Table.Cell>
|
||||
<Table.Cell class="max-w-[200px] truncate font-mono text-xs sm:text-sm">
|
||||
{r.meta?.controller_base ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={b.variant}>{b.label}</Badge>
|
||||
{#if r.metaStatus !== 'ok' && r.metaMessage}
|
||||
<p class="text-muted-foreground mt-1 max-w-xs text-xs">{r.metaMessage}</p>
|
||||
{/if}
|
||||
{#if r.pollErr}
|
||||
<p class="text-destructive mt-1 max-w-xs text-xs">{r.pollErr}</p>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-sm">
|
||||
{r.metaStatus === 'ok' ? (r.connections ?? '—') : '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-sm">
|
||||
{r.memory != null && r.memory > 0 ? formatBytes(r.memory) : '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right font-mono text-xs sm:text-sm">
|
||||
{#if r.metaStatus === 'ok' && (r.uploadTotal != null || r.downloadTotal != null)}
|
||||
{formatBytes(r.uploadTotal ?? 0)} ↑<br />
|
||||
{formatBytes(r.downloadTotal ?? 0)} ↓
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if r.metaStatus === 'ok'}
|
||||
<a
|
||||
href="#mihomo-proxies-{encodeURIComponent(r.alias)}"
|
||||
class="text-primary text-sm hover:underline"
|
||||
>
|
||||
К настройке
|
||||
</a>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if r.metaStatus === 'ok'}
|
||||
<a
|
||||
href="/servers/{encodeURIComponent(r.alias)}/mihomo"
|
||||
class="text-primary inline-flex items-center gap-1 text-sm hover:underline"
|
||||
>
|
||||
Детально
|
||||
<ExternalLinkIcon class="size-3.5 opacity-70" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</ScrollArea>
|
||||
</DataTableCard>
|
||||
|
||||
<div class="mt-8 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold tracking-tight">Прокси по нодам</h2>
|
||||
<p class="text-muted-foreground mt-0.5 max-w-2xl text-xs leading-relaxed">
|
||||
Автозагрузка групп. В шапке: «Все задержки» и обновление списка. Клик по узлу — выбор; справа —
|
||||
проверка одного узла.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
{#each rows.filter((r) => r.metaStatus === 'ok') as r (r.alias)}
|
||||
<Card.Root
|
||||
id="mihomo-proxies-{encodeURIComponent(r.alias)}"
|
||||
size="sm"
|
||||
density="compact"
|
||||
class="min-h-0 min-w-0 scroll-mt-20 overflow-hidden shadow-sm"
|
||||
>
|
||||
<Card.Header class="border-border/80 border-b px-3 pb-3 pt-3 sm:px-4">
|
||||
<Card.Title class="font-mono text-sm font-semibold leading-none tracking-tight">
|
||||
{r.alias}
|
||||
</Card.Title>
|
||||
{#if r.meta?.controller_base}
|
||||
<Card.Description
|
||||
class="text-muted-foreground line-clamp-2 pt-1 font-mono text-[10px] leading-snug"
|
||||
>
|
||||
{r.meta.controller_base}
|
||||
</Card.Description>
|
||||
{/if}
|
||||
<Card.Action class="flex flex-wrap items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
disabled={!!nodeProxyLoading[r.alias] || !!nodeDelayAllBusy[r.alias]}
|
||||
onclick={() => bumpProxyDelayAll(r.alias)}
|
||||
>
|
||||
{nodeDelayAllBusy[r.alias] ? 'Проверка…' : 'Все задержки'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8 shrink-0"
|
||||
aria-label="Обновить прокси для {r.alias}"
|
||||
disabled={!!nodeProxyLoading[r.alias] || !!nodeDelayAllBusy[r.alias]}
|
||||
onclick={() => bumpProxyRefresh(r.alias)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class={cn(
|
||||
'size-3.5',
|
||||
nodeProxyLoading[r.alias] ? 'animate-spin' : ''
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Content class="px-0 pb-0 pt-0">
|
||||
<div class="px-2 py-2 sm:px-3 sm:py-2.5">
|
||||
<MihomoProxyGroups
|
||||
alias={r.alias}
|
||||
compact={true}
|
||||
hideToolbar={true}
|
||||
hideCompactPerGroupDelayButton={true}
|
||||
refreshNonce={proxyRefreshNonce[r.alias] ?? 0}
|
||||
delayAllNonce={proxyDelayAllNonce[r.alias] ?? 0}
|
||||
onLoadingChange={(v) => {
|
||||
nodeProxyLoading = { ...nodeProxyLoading, [r.alias]: v };
|
||||
}}
|
||||
onDelayAllChange={(v) => {
|
||||
nodeDelayAllBusy = { ...nodeDelayAllBusy, [r.alias]: v };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
{#if rows.filter((r) => r.metaStatus === 'ok').length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Нет нод с настроенным Mihomo — переключать прокси негде. Проверьте конфиг шлюза.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
@@ -0,0 +1,492 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchAggRadarTelemtDcs,
|
||||
fetchRadarPingDC,
|
||||
fetchRadarStatuses,
|
||||
type AggRadarTelemtDcsRow,
|
||||
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);
|
||||
|
||||
let telemtLoading = $state(false);
|
||||
let telemtErr = $state<string | null>(null);
|
||||
let telemtPartial = $state(false);
|
||||
let telemtUpdated = $state<string | null>(null);
|
||||
let telemtServers = $state<AggRadarTelemtDcsRow[]>([]);
|
||||
|
||||
const telemtDcNums = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
function dcRowFind(row: AggRadarTelemtDcsRow, dc: number) {
|
||||
return row.data?.dcs?.find((d) => d.dc === dc);
|
||||
}
|
||||
|
||||
function telemtCoverageClass(cov: number | undefined): string {
|
||||
if (cov === undefined) return 'text-muted-foreground';
|
||||
if (cov >= 90) return 'text-emerald-600 font-medium';
|
||||
if (cov >= 50) return 'text-amber-600 font-medium';
|
||||
return 'text-red-600 font-medium';
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async function loadTelemtDcs() {
|
||||
telemtLoading = true;
|
||||
telemtErr = null;
|
||||
try {
|
||||
const env = await fetchAggRadarTelemtDcs();
|
||||
telemtServers = env.data.servers ?? [];
|
||||
telemtPartial = !!env.partial;
|
||||
telemtUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (e) {
|
||||
telemtErr = e instanceof Error ? e.message : String(e);
|
||||
telemtServers = [];
|
||||
telemtPartial = false;
|
||||
} finally {
|
||||
telemtLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRadarAndTelemt() {
|
||||
await Promise.all([loadRadar(), loadTelemtDcs()]);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void refreshRadarAndTelemt();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full 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 || telemtLoading}
|
||||
onclick={() => void refreshRadarAndTelemt()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class="mr-1 size-4 {radarLoading || telemtLoading ? '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="text-base">Telemt ME — снимок по нодам</Card.Title>
|
||||
<Card.Description>
|
||||
<code class="text-xs">GET /api/agg/radar-telemt-dcs</code> — параллельно
|
||||
<code class="text-xs">GET /v1/stats/dcs</code> на каждом upstream (как на странице «Состояние» ноды).
|
||||
Требуется включённый minimal runtime API на Telemt; иначе в ячейках будет причина отключения.
|
||||
{#if telemtUpdated}
|
||||
<span class="text-foreground"> · обновлено {telemtUpdated}</span>
|
||||
{/if}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if telemtErr}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка загрузки stats/dcs</AlertTitle>
|
||||
<AlertDescription>{telemtErr}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if telemtLoading && telemtServers.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
||||
{:else if telemtServers.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Нет серверов в конфигурации шлюза.</p>
|
||||
{:else}
|
||||
{#if telemtPartial}
|
||||
<Alert class="mb-4">
|
||||
<InfoIcon class="size-4" />
|
||||
<AlertTitle class="text-sm">Частичные данные</AlertTitle>
|
||||
<AlertDescription class="text-xs">
|
||||
Не все ноды ответили успешно — смотрите ошибки в строках.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="overflow-x-auto rounded-md border border-border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-28">Нода</Table.Head>
|
||||
{#each telemtDcNums as dc (dc)}
|
||||
<Table.Head class="min-w-[5.5rem] text-center">DC{dc}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each telemtServers as srv (srv.alias)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-sm font-medium">{srv.alias}</Table.Cell>
|
||||
{#each telemtDcNums as dc (dc)}
|
||||
<Table.Cell class="align-top text-center text-sm">
|
||||
{#if !srv.ok}
|
||||
<span class="text-destructive text-xs leading-tight" title={srv.error ?? ''}
|
||||
>ошибка</span>
|
||||
{:else if !srv.data?.middle_proxy_enabled}
|
||||
<span
|
||||
class="text-muted-foreground text-xs leading-tight"
|
||||
title={srv.data?.reason ?? ''}
|
||||
>
|
||||
{srv.data?.reason === 'feature_disabled'
|
||||
? 'ME API off'
|
||||
: (srv.data?.reason ?? 'нет данных')}
|
||||
</span>
|
||||
{:else}
|
||||
{@const cell = dcRowFind(srv, dc)}
|
||||
{#if cell}
|
||||
<div class={telemtCoverageClass(cell.coverage_pct)}>
|
||||
{cell.coverage_pct.toFixed(0)}%
|
||||
</div>
|
||||
{#if cell.rtt_ms != null && cell.rtt_ms !== undefined}
|
||||
<div class="text-muted-foreground mt-0.5 text-xs tabular-nums">
|
||||
{Number(cell.rtt_ms).toFixed(0)} ms
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-3 text-xs">
|
||||
Покрытие — доля alive writers к required для DC (Telemt). Это не сырой TCP-пинг с интернета, а
|
||||
состояние middle proxy на процессе Telemt.
|
||||
</p>
|
||||
{/if}
|
||||
</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> выполняется на <strong>процессе шлюза</strong>
|
||||
(не на каждой ноде Telemt): TCP :443 до каждого DC, таймаут 2 с (как
|
||||
<code class="text-xs">ping_proxy.php</code>). Для вида «с каждой ноды» используйте таблицу ME выше.
|
||||
Поле «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>
|
||||
@@ -1,22 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
ApiError,
|
||||
fetchStatsSummary,
|
||||
fetchTelemt
|
||||
} from '$lib/api/client.js';
|
||||
import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
|
||||
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { formatDurationSeconds } from '$lib/format.js';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
import UnplugIcon from '@lucide/svelte/icons/unplug';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import TimerOffIcon from '@lucide/svelte/icons/timer-off';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||
@@ -30,6 +25,15 @@
|
||||
let summary = $state<StatsSummaryData | null>(null);
|
||||
let sys = $state<SystemInfoData | null>(null);
|
||||
|
||||
function num(v: unknown): number {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function loadFor(a: string) {
|
||||
loading = true;
|
||||
err = null;
|
||||
@@ -64,17 +68,19 @@
|
||||
let hasStaleData = $derived(err != null && (health != null || summary != null));
|
||||
let showSkeleton = $derived(loading && !err && health == null && summary == null);
|
||||
|
||||
let badConnAccent = $derived.by((): 'danger' | 'success' => {
|
||||
const n = summary?.connections_bad_total;
|
||||
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) return 'success';
|
||||
return 'danger';
|
||||
});
|
||||
let uptimeSec = $derived(num(sys?.uptime_seconds));
|
||||
let connTotal = $derived(num(summary?.connections_total));
|
||||
let badTotal = $derived(num(summary?.connections_bad_total));
|
||||
let cfgUsers = $derived(num(summary?.configured_users));
|
||||
let handshakeT = $derived(num(summary?.handshake_timeouts_total));
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground">{alias}</p>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Сервер: обзор</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Ключевые метрики и состояние Telemt для <span class="font-mono">{alias}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
@@ -90,68 +96,86 @@
|
||||
partial={false}
|
||||
>
|
||||
{#snippet skeleton()}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array.from({ length: 4 }) as _, i (i)}
|
||||
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex gap-3">
|
||||
<Skeleton class="size-10 shrink-0 rounded-lg" />
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton class="h-3 w-24" />
|
||||
<Skeleton class="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Skeleton class="h-3 w-32" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{#each Array.from({ length: 5 }) as _, i (i)}
|
||||
<Skeleton class="h-28 rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
<Skeleton class="h-40 w-full rounded-lg" />
|
||||
<Skeleton class="h-64 w-full rounded-lg" />
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if health?.status === 'ok'}
|
||||
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
|
||||
<AlertTitle>Telemt отвечает</AlertTitle>
|
||||
<AlertDescription>
|
||||
read_only: {health.read_only ? 'да' : 'нет'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if health?.status === 'ok'}
|
||||
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
|
||||
<AlertTitle>Telemt отвечает</AlertTitle>
|
||||
<AlertDescription>
|
||||
read_only: {health.read_only ? 'да' : 'нет'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<ClockIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<PlugIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary?.connections_total ?? '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<UnplugIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary?.connections_bad_total ?? '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<UsersIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary?.configured_users ?? '—'}
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<KpiStatCard
|
||||
accent="violet"
|
||||
label="Аптайм"
|
||||
valueClass="text-xl"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<ClockIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="info"
|
||||
label="Соединения (всего)"
|
||||
valueClass="text-xl"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<PlugIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary ? String(connTotal) : '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="danger"
|
||||
label="Плохие соединения"
|
||||
valueClass="text-xl"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<ShieldAlertIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary ? String(badTotal) : '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="warning"
|
||||
label="Таймауты рукопожатия"
|
||||
valueClass="text-xl"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<TimerOffIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary ? String(handshakeT) : '—'}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard
|
||||
accent="neutral"
|
||||
label="Пользователей в конфиге"
|
||||
valueClass="text-xl"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<UsersIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{summary ? String(cfgUsers) : '—'}
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
|
||||
{#if sys}
|
||||
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#snippet icon()}
|
||||
<CpuIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{#if sys}
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Система"
|
||||
description="Информация о версии, платформе и конфигурации ноды"
|
||||
contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<CpuIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<div><span class="text-muted-foreground">version</span> {sys.version}</div>
|
||||
<div><span class="text-muted-foreground">OS / arch</span> {sys.target_os} / {sys.target_arch}</div>
|
||||
<div><span class="text-muted-foreground">build</span> {sys.build_profile}</div>
|
||||
@@ -162,7 +186,7 @@
|
||||
<span class="text-muted-foreground">hash</span> {sys.config_hash}
|
||||
</div>
|
||||
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
@@ -1,22 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ServerPageHeader from '$lib/components/server-page-header.svelte';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight">Configuration</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API
|
||||
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
|
||||
</p>
|
||||
</div>
|
||||
<ServerPageHeader
|
||||
title="Конфигурация"
|
||||
subtitle="Конфигурация этой ноды не отдается через Control API"
|
||||
showRefresh={false}
|
||||
/>
|
||||
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
|
||||
{#snippet icon()}
|
||||
<SettingsIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<p class="mb-2">
|
||||
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API.
|
||||
Правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
|
||||
</p>
|
||||
См. <a
|
||||
class="text-primary underline"
|
||||
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
|
||||
|
||||
@@ -2,34 +2,21 @@
|
||||
import { page } from '$app/state';
|
||||
import { browser } from '$app/environment';
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
ApiError,
|
||||
fetchMihomoJson,
|
||||
fetchMihomoMeta,
|
||||
mihomoPut,
|
||||
mihomoUrl,
|
||||
mihomoWsUrl
|
||||
} from '$lib/api/client.js';
|
||||
import type {
|
||||
MihomoConnectionsResponse,
|
||||
MihomoMetaResponse,
|
||||
MihomoProxiesResponse,
|
||||
MihomoProxyEntry
|
||||
} from '$lib/api/mihomo-types.js';
|
||||
import { ApiError, fetchMihomoJson, fetchMihomoMeta, mihomoUrl, mihomoWsUrl } from '$lib/api/client.js';
|
||||
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
|
||||
import { formatBytes, formatRatePerSec } from '$lib/format.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import ArrowBigUpIcon from '@lucide/svelte/icons/arrow-big-up';
|
||||
import ArrowBigDownIcon from '@lucide/svelte/icons/arrow-big-down';
|
||||
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
|
||||
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte';
|
||||
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
@@ -62,19 +49,9 @@
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||
return bytes / 1024;
|
||||
}
|
||||
/** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */
|
||||
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
|
||||
|
||||
let lastTick = $state<number | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | 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);
|
||||
|
||||
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
|
||||
function recordRates(
|
||||
now: number,
|
||||
@@ -116,12 +93,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function delayQuery(testUrl?: string) {
|
||||
const u =
|
||||
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
|
||||
return `timeout=5000&url=${encodeURIComponent(u)}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
@@ -520,110 +491,15 @@
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
|
||||
function isSelectableGroup(p: MihomoProxyEntry) {
|
||||
const t = (p.type ?? '').toLowerCase();
|
||||
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
|
||||
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
const testUrl = proxiesData?.proxies?.[name]?.testUrl;
|
||||
await fetchMihomoJson(
|
||||
a,
|
||||
`proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}`
|
||||
);
|
||||
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;
|
||||
const groupTestUrl = proxiesData?.proxies?.[groupName]?.testUrl;
|
||||
try {
|
||||
await fetchMihomoJson(
|
||||
a,
|
||||
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(groupTestUrl)}`
|
||||
);
|
||||
await loadProxies();
|
||||
} catch {
|
||||
try {
|
||||
const g = proxiesData?.proxies?.[groupName];
|
||||
const names = g?.all ?? [];
|
||||
for (const n of names) {
|
||||
const u = proxiesData?.proxies?.[n]?.testUrl;
|
||||
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?${delayQuery(u)}`);
|
||||
}
|
||||
await loadProxies();
|
||||
} catch (e) {
|
||||
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
} finally {
|
||||
testingGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1>
|
||||
<p class="text-muted-foreground text-sm">Обзор и прокси (external-controller)</p>
|
||||
<p class="text-sm text-muted-foreground">Обзор и прокси (external-controller)</p>
|
||||
</div>
|
||||
<Tabs.Root bind:value={tab} class="w-fit shrink-0">
|
||||
<Tabs.List class="min-w-[220px] gap-0.5 p-1">
|
||||
@@ -734,84 +610,5 @@
|
||||
</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)) 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>
|
||||
<MihomoProxyGroups alias={alias} />
|
||||
{/if}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ServerPageHeader from '$lib/components/server-page-header.svelte';
|
||||
import ServerQueryState from '$lib/components/server-query-state.svelte';
|
||||
import ServerJsonCard from '$lib/components/server-json-card.svelte';
|
||||
import BuildingIcon from '@lucide/svelte/icons/building-2';
|
||||
import BracesIcon from '@lucide/svelte/icons/braces';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { Alert, AlertDescription } from '$lib/components/ui/alert/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
|
||||
@@ -56,44 +57,32 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Runtime</h1>
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
<ServerPageHeader title="Состояние" {loading} onRefresh={load} />
|
||||
|
||||
{#if err}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if loading}
|
||||
<p class="text-muted-foreground">Загрузка…</p>
|
||||
{:else}
|
||||
<ServerQueryState {loading} {err} isEmpty={false}>
|
||||
{#snippet children()}
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
{#if gates}
|
||||
<Badge variant="secondary" class="px-3 py-1">accepting: {onOff(gates.accepting_new_connections)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">conditional cast: {onOff(gates.conditional_cast_enabled)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">me2dc fallback: {onOff(gates.me2dc_fallback_enabled)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">ME ready: {onOff(gates.me_runtime_ready)}</Badge>
|
||||
<Badge variant="outline" class="px-3 py-1">middle proxy: {onOff(gates.use_middle_proxy)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">Приём новых: {onOff(gates.accepting_new_connections)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">Условный cast: {onOff(gates.conditional_cast_enabled)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">Резерв me2dc: {onOff(gates.me2dc_fallback_enabled)}</Badge>
|
||||
<Badge variant="secondary" class="px-3 py-1">ME готов: {onOff(gates.me_runtime_ready)}</Badge>
|
||||
<Badge variant="outline" class="px-3 py-1">Промежуточный прокси: {onOff(gates.use_middle_proxy)}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
|
||||
<KpiPanelCard class="mb-6" accent="info" title="Datacenter status" contentClass="p-0">
|
||||
<KpiPanelCard class="mb-6" accent="info" title="Статус дата-центров" contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
<BuildingIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<Table.Head>DC</Table.Head>
|
||||
<Table.Head class="text-right">RTT</Table.Head>
|
||||
<Table.Head class="text-right">Writers</Table.Head>
|
||||
<Table.Head class="text-right">Coverage</Table.Head>
|
||||
<Table.Head class="text-right">Писатели</Table.Head>
|
||||
<Table.Head class="text-right">Покрытие</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -114,18 +103,14 @@
|
||||
</KpiPanelCard>
|
||||
{:else if dcs?.reason}
|
||||
<Alert class="mb-4">
|
||||
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
|
||||
<AlertDescription>Статус ДЦ: {dcs.reason}</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<KpiPanelCard accent="violet" title="ME pool state" description="Сырой JSON" contentClass="">
|
||||
{#snippet icon()}
|
||||
<ServerJsonCard accent="violet" title="ME pool state" description="Сырой JSON" payload={pool} preClass="max-h-[480px]">
|
||||
{#snippet iconSnippet()}
|
||||
<BracesIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify(
|
||||
pool,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
</ServerJsonCard>
|
||||
{/snippet}
|
||||
</ServerQueryState>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ServerPageHeader from '$lib/components/server-page-header.svelte';
|
||||
import ServerQueryState from '$lib/components/server-query-state.svelte';
|
||||
import ServerJsonCard from '$lib/components/server-json-card.svelte';
|
||||
import ShieldIcon from '@lucide/svelte/icons/shield';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
|
||||
@@ -39,30 +38,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Security</h1>
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
<ServerPageHeader title="Безопасность" {loading} onRefresh={load} />
|
||||
|
||||
{#if err}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if loading}
|
||||
<p class="text-muted-foreground">Загрузка…</p>
|
||||
{:else if data}
|
||||
<KpiPanelCard accent="danger" title="Security posture" description="POST /security/posture" contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
<ShieldIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<pre class="overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
|
||||
data,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Endpoint вернул пустой ответ.">
|
||||
{#snippet children()}
|
||||
{#if data}
|
||||
<ServerJsonCard accent="danger" title="Профиль безопасности" description="POST /security/posture" payload={data}>
|
||||
{#snippet iconSnippet()}
|
||||
<ShieldIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
</ServerJsonCard>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ServerQueryState>
|
||||
@@ -3,10 +3,9 @@
|
||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ServerPageHeader from '$lib/components/server-page-header.svelte';
|
||||
import ServerQueryState from '$lib/components/server-query-state.svelte';
|
||||
import PackageIcon from '@lucide/svelte/icons/package';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
|
||||
@@ -40,29 +39,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Update</h1>
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
<ServerPageHeader title="Обновление" {loading} onRefresh={load} />
|
||||
|
||||
{#if err}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if loading}
|
||||
<p class="text-muted-foreground">Загрузка…</p>
|
||||
{:else if sys}
|
||||
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
|
||||
{#snippet icon()}
|
||||
<PackageIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<p><span class="text-muted-foreground">version</span> {sys.version}</p>
|
||||
<p><span class="text-muted-foreground">git</span> {sys.git_commit ?? '—'}</p>
|
||||
<p><span class="text-muted-foreground">build</span> {sys.build_time_utc ?? '—'}</p>
|
||||
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
<ServerQueryState {loading} {err} isEmpty={!sys} emptyDescription="Информация о бинарнике не получена.">
|
||||
{#snippet children()}
|
||||
{#if sys}
|
||||
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
|
||||
{#snippet icon()}
|
||||
<PackageIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<p><span class="text-muted-foreground">version</span> {sys.version}</p>
|
||||
<p><span class="text-muted-foreground">git</span> {sys.git_commit ?? '—'}</p>
|
||||
<p><span class="text-muted-foreground">build</span> {sys.build_time_utc ?? '—'}</p>
|
||||
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ServerQueryState>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ServerPageHeader from '$lib/components/server-page-header.svelte';
|
||||
import ServerQueryState from '$lib/components/server-query-state.svelte';
|
||||
import ServerJsonCard from '$lib/components/server-json-card.svelte';
|
||||
import Share2Icon from '@lucide/svelte/icons/share-2';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
|
||||
const alias = $derived(page.params.alias ?? '');
|
||||
|
||||
@@ -39,30 +38,22 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Upstreams & DCs</h1>
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
<ServerPageHeader title="Апстримы и ДЦ" {loading} onRefresh={load} />
|
||||
|
||||
{#if err}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if loading}
|
||||
<p class="text-muted-foreground">Загрузка…</p>
|
||||
{:else if data}
|
||||
<KpiPanelCard accent="info" title="Upstreams & DCs" description="stats/upstreams" contentClass="p-0">
|
||||
{#snippet icon()}
|
||||
<Share2Icon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<pre class="max-h-[70vh] overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
|
||||
data,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</KpiPanelCard>
|
||||
{/if}
|
||||
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Статистика upstream-ов пока не доступна.">
|
||||
{#snippet children()}
|
||||
{#if data}
|
||||
<ServerJsonCard
|
||||
accent="info"
|
||||
title="Апстримы и ДЦ"
|
||||
description="stats/upstreams"
|
||||
payload={data}
|
||||
preClass="max-h-[70vh]"
|
||||
>
|
||||
{#snippet iconSnippet()}
|
||||
<Share2Icon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
</ServerJsonCard>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ServerQueryState>
|
||||
@@ -199,7 +199,7 @@
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Users</h1>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Пользователи</h1>
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={() => (createOpen = true)} disabled={readOnly}>
|
||||
<PlusIcon class="mr-1 size-4" />
|
||||
@@ -232,11 +232,11 @@
|
||||
<UsersIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<Table.Head>Имя</Table.Head>
|
||||
<Table.Head>Ссылки</Table.Head>
|
||||
<Table.Head class="text-right">Conn</Table.Head>
|
||||
<Table.Head class="text-right">Соединения</Table.Head>
|
||||
<Table.Head class="text-right">IP</Table.Head>
|
||||
<Table.Head class="text-right">Трафик</Table.Head>
|
||||
<Table.Head class="w-[100px]"></Table.Head>
|
||||
@@ -248,9 +248,9 @@
|
||||
<Table.Cell class="font-medium">{u.username}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each u.links?.tls ?? [] as link (link)}
|
||||
{#each u.links?.tls ?? [] as link, idx (`${link}:${idx}`)}
|
||||
<Button variant="outline" size="xs" class="h-7" onclick={() => copy(link)}>
|
||||
TLS <CopyIcon class="size-3" />
|
||||
TLS {idx + 1} <CopyIcon class="size-3" />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -269,6 +269,8 @@
|
||||
size="icon-sm"
|
||||
disabled={readOnly}
|
||||
onclick={() => openEdit(u)}
|
||||
aria-label="Редактировать пользователя"
|
||||
title="Редактировать пользователя"
|
||||
>
|
||||
<PencilIcon class="size-4" />
|
||||
</Button>
|
||||
@@ -277,6 +279,8 @@
|
||||
size="icon-sm"
|
||||
disabled={readOnly}
|
||||
onclick={() => (deleteUserRow = u)}
|
||||
aria-label="Удалить пользователя"
|
||||
title="Удалить пользователя"
|
||||
>
|
||||
<TrashIcon class="size-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -325,15 +329,15 @@
|
||||
<Input id="eq" bind:value={editQuota} type="number" min="0" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="em">max_unique_ips</Label>
|
||||
<Label for="em">Макс. уникальных IP (max_unique_ips)</Label>
|
||||
<Input id="em" bind:value={editMaxIp} type="number" min="0" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="et">max_tcp_conns</Label>
|
||||
<Label for="et">Макс. TCP-соединений (max_tcp_conns)</Label>
|
||||
<Input id="et" bind:value={editMaxTcp} type="number" min="0" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="ex">expiry (RFC3339)</Label>
|
||||
<Label for="ex">Срок действия (RFC3339)</Label>
|
||||
<Input id="ex" bind:value={editExp} class="font-mono text-sm" />
|
||||
</div>
|
||||
{#if formErr}
|
||||
|
||||
@@ -6,10 +6,23 @@
|
||||
import type { components } from '$lib/api/aggregate.gen.js';
|
||||
import { formatMiB } from '$lib/format.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Columns3Icon from '@lucide/svelte/icons/columns-3';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
@@ -19,6 +32,11 @@
|
||||
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let loading = $state(true);
|
||||
@@ -99,9 +117,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function applyAliasIfChanged(nextAlias: string) {
|
||||
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||
if (nextAlias === currentAlias) return;
|
||||
async function handleAliasFilterChange(nextAlias: string) {
|
||||
aliasFilter = nextAlias;
|
||||
const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||
if (nextAlias === currentUrlAlias) return;
|
||||
const q = new URLSearchParams(page.url.searchParams);
|
||||
if (nextAlias === 'all') q.delete('aliases');
|
||||
else q.set('aliases', nextAlias);
|
||||
@@ -111,6 +130,7 @@
|
||||
noScroll: true,
|
||||
keepFocus: true
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
let queryKey = $derived(page.url.searchParams.toString());
|
||||
@@ -125,11 +145,6 @@
|
||||
void load();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void applyAliasIfChanged(aliasFilter);
|
||||
});
|
||||
|
||||
let staleSeconds = $derived.by(() =>
|
||||
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
|
||||
);
|
||||
@@ -146,7 +161,6 @@
|
||||
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
availableAliases = aliases;
|
||||
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
|
||||
} catch {
|
||||
availableAliases = [];
|
||||
}
|
||||
@@ -162,23 +176,108 @@
|
||||
|
||||
const usersPerPage = 25;
|
||||
let usersPage = $state(1);
|
||||
let userSearch = $state('');
|
||||
let sortKey = $state<'name' | 'traffic' | 'ips' | 'exp' | null>(null);
|
||||
let sortDir = $state<SortDir | null>(null);
|
||||
let usersViewTab = $state<'all' | 'traffic' | 'active'>('all');
|
||||
let showColLinks = $state(true);
|
||||
let showColExpires = $state(true);
|
||||
let selectedUsernames = $state<string[]>([]);
|
||||
|
||||
let hasStaleData = $derived(err != null && rows.length > 0);
|
||||
let showSkeleton = $derived(loading && rows.length === 0 && !err);
|
||||
let isDataEmpty = $derived(!loading && rows.length === 0 && !err);
|
||||
let pageCount = $derived(Math.max(1, Math.ceil(rows.length / usersPerPage)));
|
||||
|
||||
let countAll = $derived(rows.length);
|
||||
let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length);
|
||||
let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length);
|
||||
let sumTraffic = $derived.by(() =>
|
||||
rows.reduce((acc, row) => acc + (row.total_megabytes ?? 0), 0)
|
||||
);
|
||||
let sumActiveIps = $derived.by(() =>
|
||||
rows.reduce((acc, row) => acc + (row.active_unique_ips ?? 0), 0)
|
||||
);
|
||||
|
||||
let tabFilteredRows = $derived.by(() => {
|
||||
if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0);
|
||||
if (usersViewTab === 'active') return rows.filter((r) => (r.active_unique_ips ?? 0) > 0);
|
||||
return rows;
|
||||
});
|
||||
|
||||
let processedRows = $derived.by(() => {
|
||||
let r = [...tabFilteredRows];
|
||||
const q = userSearch.trim().toLowerCase();
|
||||
if (q) r = r.filter((row) => (row.username ?? '').toLowerCase().includes(q));
|
||||
const k = sortKey;
|
||||
const d = sortDir;
|
||||
if (k && d) {
|
||||
r.sort((a, b) => {
|
||||
if (k === 'name') return compareStrings(a.username ?? '', b.username ?? '', d);
|
||||
if (k === 'traffic')
|
||||
return compareNum(a.total_megabytes ?? 0, b.total_megabytes ?? 0, d);
|
||||
if (k === 'ips')
|
||||
return compareNum(a.active_unique_ips ?? 0, b.active_unique_ips ?? 0, d);
|
||||
if (k === 'exp') {
|
||||
const ta = a.expiration_rfc3339 ? new Date(a.expiration_rfc3339).getTime() : 0;
|
||||
const tb = b.expiration_rfc3339 ? new Date(b.expiration_rfc3339).getTime() : 0;
|
||||
return compareNum(ta, tb, d);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
let pageCount = $derived(Math.max(1, Math.ceil(processedRows.length / usersPerPage)));
|
||||
let pagedRows = $derived.by(() => {
|
||||
const start = (usersPage - 1) * usersPerPage;
|
||||
return rows.slice(start, start + usersPerPage);
|
||||
return processedRows.slice(start, start + usersPerPage);
|
||||
});
|
||||
|
||||
let pageUsernames = $derived(
|
||||
pagedRows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
|
||||
);
|
||||
let selectedOnPage = $derived(pageUsernames.filter((u) => selectedUsernames.includes(u)));
|
||||
let headerCheckboxChecked = $derived(
|
||||
pageUsernames.length > 0 && selectedOnPage.length === pageUsernames.length
|
||||
);
|
||||
let headerCheckboxIndeterminate = $derived(
|
||||
selectedOnPage.length > 0 && selectedOnPage.length < pageUsernames.length
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
aliasFilter;
|
||||
userSearch;
|
||||
usersViewTab;
|
||||
usersPage = 1;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
usersViewTab;
|
||||
selectedUsernames = [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
rows;
|
||||
const valid = new Set(
|
||||
rows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
|
||||
);
|
||||
const next = selectedUsernames.filter((u) => valid.has(u));
|
||||
const same =
|
||||
next.length === selectedUsernames.length &&
|
||||
next.every((u, i) => u === selectedUsernames[i]);
|
||||
if (!same) selectedUsernames = next;
|
||||
});
|
||||
$effect(() => {
|
||||
if (usersPage > pageCount) usersPage = pageCount;
|
||||
});
|
||||
|
||||
function toggleSort(key: 'name' | 'traffic' | 'ips' | 'exp') {
|
||||
const next = nextSortDir(sortDir, key, sortKey);
|
||||
sortKey = next === null ? null : key;
|
||||
sortDir = next;
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
const value = String(text ?? '');
|
||||
const done = () => toast.success('Скопировано в буфер');
|
||||
@@ -199,6 +298,31 @@
|
||||
void goto(`/users/${encodeURIComponent(u)}`);
|
||||
}
|
||||
|
||||
function toggleRowSelect(username: string, checked: boolean) {
|
||||
if (!username) return;
|
||||
if (checked) {
|
||||
if (!selectedUsernames.includes(username)) selectedUsernames = [...selectedUsernames, username];
|
||||
} else {
|
||||
selectedUsernames = selectedUsernames.filter((u) => u !== username);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllOnPage(checked: boolean) {
|
||||
if (checked) {
|
||||
selectedUsernames = [...new Set([...selectedUsernames, ...pageUsernames])];
|
||||
} else {
|
||||
selectedUsernames = selectedUsernames.filter((u) => !pageUsernames.includes(u));
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateUser() {
|
||||
if (!aliasFilter || aliasFilter === 'all') {
|
||||
toast.info('Для создания выберите конкретную ноду в фильтре "Алиас".');
|
||||
return;
|
||||
}
|
||||
void goto(`/servers/${encodeURIComponent(aliasFilter)}/users`);
|
||||
}
|
||||
|
||||
function fallbackCopy(value: string) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const ta = document.createElement('textarea');
|
||||
@@ -219,7 +343,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Пользователи (флот)</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
@@ -238,6 +362,10 @@
|
||||
>Ссылки tg://proxy</Label
|
||||
>
|
||||
</div>
|
||||
<Button variant="default" size="sm" onclick={openCreateUser}>
|
||||
<PlusIcon class="mr-1 size-4" />
|
||||
Создать
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||
Обновить
|
||||
@@ -246,7 +374,11 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||
<AliasFilterSelect
|
||||
availableAliases={availableAliases}
|
||||
bind:value={aliasFilter}
|
||||
onValueChange={(next) => void handleAliasFilterChange(next)}
|
||||
/>
|
||||
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||
</div>
|
||||
|
||||
@@ -281,32 +413,172 @@
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
<KpiPanelCard
|
||||
accent="info"
|
||||
title="Список пользователей"
|
||||
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
|
||||
contentClass="p-0"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<UsersIcon class="size-5" aria-hidden="true" />
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiStatCard accent="violet" label="Пользователей всего" footer="в текущем фильтре" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<UsersIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{countAll}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="success" label="С трафиком" footer="total_megabytes > 0" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{countTraffic}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="info" label="Активных IP (сумма)" footer="active_unique_ips" valueClass="text-xl">
|
||||
{#snippet icon()}
|
||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{sumActiveIps}
|
||||
</KpiStatCard>
|
||||
<KpiStatCard accent="warning" label="Трафик (MiB)" footer="агрегат по пользователям" valueClass="text-lg">
|
||||
{#snippet icon()}
|
||||
<CloudUploadIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
{formatMiB(sumTraffic)}
|
||||
</KpiStatCard>
|
||||
</div>
|
||||
|
||||
<DataTableCard title="Пользователи" description="Слияние по имени между серверами" accent="violet">
|
||||
{#snippet icon()}<UsersIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||
{#snippet toolbar()}
|
||||
<div class="border-b border-border">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
|
||||
<Tabs.Root bind:value={usersViewTab} class="w-full min-w-0 sm:w-auto">
|
||||
<Tabs.List class="h-auto min-h-8 w-full flex-wrap justify-start gap-1 p-1 sm:w-fit">
|
||||
<Tabs.Trigger value="all" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
|
||||
Все
|
||||
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countAll}</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="traffic" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
|
||||
С трафиком
|
||||
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countTraffic}</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="active" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
|
||||
Активные IP
|
||||
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countActive}</Badge>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
{#if selectedUsernames.length > 0}
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
Выбрано: {selectedUsernames.length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-3 py-2"
|
||||
>
|
||||
<div class="relative max-w-sm min-w-[200px] flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-9 ps-9" placeholder="Поиск по имени…" bind:value={userSearch} />
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-9 gap-1.5">
|
||||
<Columns3Icon class="size-4 opacity-70" />
|
||||
Столбцы
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-52">
|
||||
<DropdownMenu.Label class="text-xs">Видимость столбцов</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.CheckboxItem bind:checked={showColLinks}>
|
||||
Ссылки
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem bind:checked={showColExpires}>
|
||||
Истекает
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9"
|
||||
onclick={() => {
|
||||
userSearch = '';
|
||||
sortKey = null;
|
||||
sortDir = null;
|
||||
selectedUsernames = [];
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="flex w-full flex-col">
|
||||
<ScrollArea
|
||||
class="h-[min(70vh,560px)] w-full shrink-0"
|
||||
orientation="both"
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Имя</Table.Head>
|
||||
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head>
|
||||
<Table.Head class="text-right">Трафик</Table.Head>
|
||||
<Table.Head class="text-right">Активных IP</Table.Head>
|
||||
<Table.Head class="hidden sm:table-cell">Истекает</Table.Head>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<Table.Head class="w-10 px-2">
|
||||
{#if pageUsernames.length > 0}
|
||||
<Checkbox
|
||||
checked={headerCheckboxChecked}
|
||||
indeterminate={headerCheckboxIndeterminate}
|
||||
aria-label="Выбрать всех на странице"
|
||||
onCheckedChange={(v) => toggleSelectAllOnPage(v === true)}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
<SortableTh
|
||||
active={sortKey === 'name'}
|
||||
dir={sortDir}
|
||||
onToggle={() => toggleSort('name')}
|
||||
class="min-w-[8rem]"
|
||||
>Имя</SortableTh>
|
||||
<Table.Head
|
||||
class={cn(
|
||||
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
|
||||
!showColLinks && 'hidden',
|
||||
showColLinks && 'hidden sm:table-cell'
|
||||
)}
|
||||
>Ссылки</Table.Head>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={sortKey === 'traffic'}
|
||||
dir={sortDir}
|
||||
onToggle={() => toggleSort('traffic')}
|
||||
class="text-right"
|
||||
>Трафик</SortableTh>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={sortKey === 'ips'}
|
||||
dir={sortDir}
|
||||
onToggle={() => toggleSort('ips')}
|
||||
class="text-right"
|
||||
>Активных IP</SortableTh>
|
||||
<SortableTh
|
||||
active={sortKey === 'exp'}
|
||||
dir={sortDir}
|
||||
onToggle={() => toggleSort('exp')}
|
||||
class={cn(
|
||||
!showColExpires && 'hidden',
|
||||
showColExpires && 'hidden sm:table-cell'
|
||||
)}
|
||||
>Истекает</SortableTh>
|
||||
<Table.Head class="w-12 px-2 text-right">
|
||||
<span class="sr-only">Действия</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each pagedRows as row (row.username ?? '')}
|
||||
{@const uname = row.username ?? ''}
|
||||
<Table.Row
|
||||
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
class="group cursor-pointer border-b border-border transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
tabindex={0}
|
||||
role="button"
|
||||
aria-label={`Открыть пользователя ${row.username ?? ''}`}
|
||||
aria-label={`Открыть пользователя ${uname}`}
|
||||
onclick={() => openUser(row.username)}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -315,9 +587,25 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Table.Cell class="font-medium">{row.username}</Table.Cell>
|
||||
<Table.Cell class="hidden sm:table-cell">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Table.Cell class="w-10 px-2 align-middle" onclick={(e) => e.stopPropagation()}>
|
||||
{#if uname}
|
||||
<Checkbox
|
||||
checked={selectedUsernames.includes(uname)}
|
||||
aria-label={`Выбрать ${uname}`}
|
||||
onCheckedChange={(v) => toggleRowSelect(uname, v === true)}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="py-3 font-semibold text-foreground">{row.username}</Table.Cell>
|
||||
<Table.Cell
|
||||
class={cn(
|
||||
'py-3 align-middle',
|
||||
!showColLinks && 'hidden',
|
||||
showColLinks && 'hidden sm:table-cell'
|
||||
)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex max-w-[220px] flex-wrap gap-1">
|
||||
{#each row.links?.tls ?? [] as link (link)}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -346,30 +634,95 @@
|
||||
<CopyIcon class="size-3 opacity-60" />
|
||||
</Button>
|
||||
{/each}
|
||||
{#if !(row.links?.tls?.length) && !(row.links?.secure?.length)}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
|
||||
<Table.Cell class="text-right text-sm font-mono tabular-nums">
|
||||
{row.active_unique_ips ?? 0}
|
||||
{#if row.max_unique_ips != null}
|
||||
<span class="text-muted-foreground"> / {row.max_unique_ips}</span>
|
||||
<Table.Cell class="py-3 text-right align-middle tabular-nums">
|
||||
{#if (row.total_megabytes ?? 0) > 0}
|
||||
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
|
||||
{formatMiB(row.total_megabytes ?? 0)}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline" class="font-mono font-normal text-muted-foreground tabular-nums">
|
||||
{formatMiB(0)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="hidden sm:table-cell text-sm text-muted-foreground">
|
||||
<Table.Cell class="py-3 text-right align-middle">
|
||||
{#if (row.active_unique_ips ?? 0) > 0}
|
||||
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
|
||||
{row.active_unique_ips ?? 0}
|
||||
{#if row.max_unique_ips != null}
|
||||
<span class="text-muted-foreground">/{row.max_unique_ips}</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
{row.active_unique_ips ?? 0}
|
||||
{#if row.max_unique_ips != null}
|
||||
<span> / {row.max_unique_ips}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
class={cn(
|
||||
'py-3 text-sm text-muted-foreground',
|
||||
!showColExpires && 'hidden',
|
||||
showColExpires && 'hidden sm:table-cell'
|
||||
)}
|
||||
>
|
||||
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-14 px-1 py-3 align-middle" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-center justify-end gap-0.5 pe-0.5">
|
||||
<ChevronRightIcon
|
||||
class="pointer-events-none size-4 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 max-sm:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Действия"
|
||||
>
|
||||
<MoreVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-48">
|
||||
<DropdownMenu.Item onclick={() => openUser(row.username)}>
|
||||
Открыть карточку
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={!uname}
|
||||
onclick={() => uname && copy(uname)}
|
||||
>
|
||||
Копировать имя
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{#if rows.length > usersPerPage}
|
||||
</ScrollArea>
|
||||
{#if processedRows.length > usersPerPage}
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground"
|
||||
class="relative z-10 flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
{rows.length === 0
|
||||
{processedRows.length === 0
|
||||
? '0'
|
||||
: `${(usersPage - 1) * usersPerPage + 1}–${Math.min(usersPage * usersPerPage, rows.length)}`} из {rows.length}
|
||||
: `${(usersPage - 1) * usersPerPage + 1}–${Math.min(usersPage * usersPerPage, processedRows.length)}`} из {processedRows.length}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -387,6 +740,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</KpiPanelCard>
|
||||
</div>
|
||||
</DataTableCard>
|
||||
{/snippet}
|
||||
</DataQueryState>
|
||||
@@ -205,8 +205,8 @@
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||
<Table.Head class="text-xs sm:text-sm">IP</Table.Head>
|
||||
<Table.Head class="text-xs sm:text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
Reference in new issue
Block a user