Compare commits
52
Commits
c3a0a771a3
..
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 | ||
|
|
deb12e72c0 | ||
|
|
8da50bd254 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# telemt-api
|
# telemt-api
|
||||||
|
|
||||||
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`.
|
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md) (в т.ч. `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)
|
## Быстрый старт (Linux)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
listen: ":8080"
|
listen: ":8080"
|
||||||
|
|
||||||
|
# Диагностика: при 502 от прокси в JSON добавляется error.detail с текстом ошибки RoundTrip (TLS, DNS, таймаут).
|
||||||
|
# Включайте только во внутренней сети — строка может содержать хосты/порты upstream.
|
||||||
|
# expose_upstream_errors: true
|
||||||
|
|
||||||
# If true, IP whitelist is not enforced (development only).
|
# If true, IP whitelist is not enforced (development only).
|
||||||
allow_all: false
|
allow_all: false
|
||||||
|
|
||||||
@@ -39,6 +43,12 @@ trusted_proxies: []
|
|||||||
# - gt2
|
# - gt2
|
||||||
# cache_ttl_ms: 2000
|
# cache_ttl_ms: 2000
|
||||||
|
|
||||||
|
# Радар DC Telegram: прокси к radar.telemt.top и TCP-пинг DC с хоста шлюза (см. README).
|
||||||
|
# radar:
|
||||||
|
# statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||||
|
# http_timeout_ms: 30000
|
||||||
|
# ping_from: "stats.example.com"
|
||||||
|
|
||||||
# Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен)
|
# Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен)
|
||||||
# geoip:
|
# geoip:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
|
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
|
||||||
- **`GET /api/agg/fleet-status`** дополнительно вызывает на каждом upstream **`GET /v1/health`** и **`GET /v1/system/info`** (параллельно по серверам).
|
- **`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 для удобства).
|
**Единицы трафика в агрегатах:** поля `*_megabytes` — это **двоичные мегабайты (MiB)**, 1 MiB = 1024² октетов (как у Telemt в ответе считаются октеты, шлюз делит на MiB для удобства).
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
| GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). |
|
| 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/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/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 | `/api/agg/incidents` | Нормализованный snapshot инцидентов для triage-панели: `critical/warning/info`, `affected_aliases`, рекомендуемые `actions` (runbook/deep links), счётчики по severity. |
|
||||||
|
|
||||||
Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза.
|
Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза.
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ servers:
|
|||||||
| `mihomo_base_url_env` | Имя переменной окружения; если задано и значение **непустое**, URL контроллера берётся из `os.Getenv` при старте (удобно в Docker без хардкода IP). Если env пустой, используется `mihomo_base_url`. |
|
| `mihomo_base_url_env` | Имя переменной окружения; если задано и значение **непустое**, URL контроллера берётся из `os.Getenv` при старте (удобно в Docker без хардкода IP). Если env пустой, используется `mihomo_base_url`. |
|
||||||
| `mihomo_authorization_env` | Имя env: **полное** значение заголовка `Authorization` (например `Bearer <secret>`), как у `authorization_env` для Telemt. Должно совпадать с секретом на стороне Mihomo (`secret` / `CLASH_SECRET` в конфиге ядра). |
|
| `mihomo_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 при старте шлюза.
|
- Если указан `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.
|
- `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer <secret>`) задаётся в **environment** контейнера gateway, не в YAML.
|
||||||
- После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение.
|
- После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение.
|
||||||
|
|
||||||
|
<a id="radar-dc-telegram"></a>
|
||||||
|
|
||||||
|
### Радар DC Telegram
|
||||||
|
|
||||||
|
Два встроенных маршрута (аналог `radar_proxy.php` и `ping_proxy.php` из [telemt-proxy-panel-bot-firewall](https://github.com/upe4d/telemt-proxy-panel-bot-firewall)):
|
||||||
|
|
||||||
|
| Маршрут | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `GET /api/radar/statuses` | Прокси JSON с `https://radar.telemt.top/api/v1/endpoints/statuses` (без CORS в браузере к внешнему хосту). При ошибке исходящего запроса — HTTP 500 и `{"error":"failed"}`. |
|
||||||
|
| `GET /api/radar/ping-dc` | TCP-подключение с **хоста шлюза** к каждому из пяти DC Telegram на порт **443**, таймаут **2 с**; JSON с полями `ok`, `results`, `from` (как в PHP-панели). |
|
||||||
|
|
||||||
|
В **Web UI** раздел **«Радар DC»**: путь `/radar`.
|
||||||
|
|
||||||
|
Опциональный блок в `config.yaml` (см. [config.example.yaml](../config.example.yaml)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
radar:
|
||||||
|
statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||||
|
http_timeout_ms: 30000
|
||||||
|
ping_from: "stats.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле `ping_from` задаёт строку `from` в ответе `ping-dc`; если пусто — используется заголовок `Host` запроса (без порта) или значение `telemt-gateway`.
|
||||||
|
|
||||||
|
Зарезервированы **только** пути `/api/radar/statuses` и `/api/radar/ping-dc`. Остальные URL вида `/api/radar/…` по-прежнему относятся к серверу с **alias** `radar` в `servers[]`, если такой задан.
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
| Переменная | Описание |
|
| Переменная | Описание |
|
||||||
|
|||||||
@@ -8,3 +8,17 @@ require (
|
|||||||
github.com/prometheus/client_golang v1.20.5
|
github.com/prometheus/client_golang v1.20.5
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.55.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
|
||||||
|
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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
|
cacheMu sync.Mutex
|
||||||
cache map[string]cacheEntry
|
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).
|
// 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)
|
h.handleUsers(w, r)
|
||||||
case sub == "fleet-status":
|
case sub == "fleet-status":
|
||||||
h.handleFleetStatus(w, r)
|
h.handleFleetStatus(w, r)
|
||||||
|
case sub == "radar-telemt-dcs":
|
||||||
|
h.handleRadarTelemtDcs(w, r)
|
||||||
case sub == "incidents":
|
case sub == "incidents":
|
||||||
h.handleIncidents(w, r)
|
h.handleIncidents(w, r)
|
||||||
case strings.HasPrefix(sub, "user/"):
|
case strings.HasPrefix(sub, "user/"):
|
||||||
@@ -275,6 +282,25 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeAggOK(w, partial, data)
|
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) {
|
func (h *Handler) handleFleetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
aliases, err := h.resolveAliases(r)
|
aliases, err := h.resolveAliases(r)
|
||||||
if err != nil {
|
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) {
|
func TestHandlerUserOne(t *testing.T) {
|
||||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/stats/users" {
|
if r.URL.Path != "/v1/stats/users" {
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
|||||||
partial = true
|
partial = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if data.ConnectionsBadTotal >= 10000 {
|
badEff := h.effectiveConnectionsBad(alias, data.ConnectionsBadTotal)
|
||||||
|
if badEff >= 10000 {
|
||||||
out.Items = append(out.Items, IncidentItem{
|
out.Items = append(out.Items, IncidentItem{
|
||||||
ID: fmt.Sprintf("bad_connections_high:%s", alias),
|
ID: fmt.Sprintf("bad_connections_high:%s", alias),
|
||||||
Kind: "bad_connections_high",
|
Kind: "bad_connections_high",
|
||||||
@@ -75,13 +76,13 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
|||||||
Summary: "Резкий рост ошибок клиентских соединений",
|
Summary: "Резкий рост ошибок клиентских соединений",
|
||||||
AffectedAliases: []string{alias},
|
AffectedAliases: []string{alias},
|
||||||
MetricName: "connections_bad_total",
|
MetricName: "connections_bad_total",
|
||||||
MetricValue: float64(data.ConnectionsBadTotal),
|
MetricValue: float64(badEff),
|
||||||
MetricThreshold: 10000,
|
MetricThreshold: 10000,
|
||||||
Actions: []IncidentAction{
|
Actions: []IncidentAction{
|
||||||
{Label: "Node dashboard", Href: "/servers/" + alias},
|
{Label: "Node dashboard", Href: "/servers/" + alias},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else if data.ConnectionsBadTotal >= 1000 {
|
} else if badEff >= 1000 {
|
||||||
out.Items = append(out.Items, IncidentItem{
|
out.Items = append(out.Items, IncidentItem{
|
||||||
ID: fmt.Sprintf("bad_connections_warn:%s", alias),
|
ID: fmt.Sprintf("bad_connections_warn:%s", alias),
|
||||||
Kind: "bad_connections_warn",
|
Kind: "bad_connections_warn",
|
||||||
@@ -91,7 +92,7 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
|||||||
Summary: "Наблюдается рост ошибок клиентских соединений",
|
Summary: "Наблюдается рост ошибок клиентских соединений",
|
||||||
AffectedAliases: []string{alias},
|
AffectedAliases: []string{alias},
|
||||||
MetricName: "connections_bad_total",
|
MetricName: "connections_bad_total",
|
||||||
MetricValue: float64(data.ConnectionsBadTotal),
|
MetricValue: float64(badEff),
|
||||||
MetricThreshold: 1000,
|
MetricThreshold: 1000,
|
||||||
Actions: []IncidentAction{
|
Actions: []IncidentAction{
|
||||||
{Label: "Node dashboard", Href: "/servers/" + alias},
|
{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"`
|
Username string `json:"username"`
|
||||||
UniqueIPs uint64 `json:"unique_ips"`
|
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"`
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
|||||||
// Config is the gateway YAML configuration.
|
// Config is the gateway YAML configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Listen string `yaml:"listen"`
|
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"`
|
AllowAll bool `yaml:"allow_all"`
|
||||||
WhitelistCIDRs []string `yaml:"whitelist_cidrs"`
|
WhitelistCIDRs []string `yaml:"whitelist_cidrs"`
|
||||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||||
@@ -23,6 +25,17 @@ type Config struct {
|
|||||||
Servers []Server `yaml:"servers"`
|
Servers []Server `yaml:"servers"`
|
||||||
Aggregate *AggregateConfig `yaml:"aggregate"`
|
Aggregate *AggregateConfig `yaml:"aggregate"`
|
||||||
GeoIP *GeoIPConfig `yaml:"geoip"`
|
GeoIP *GeoIPConfig `yaml:"geoip"`
|
||||||
|
Radar *RadarConfig `yaml:"radar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadarConfig controls GET /api/radar/statuses and /api/radar/ping-dc (Telegram DC radar).
|
||||||
|
type RadarConfig struct {
|
||||||
|
// StatusesURL defaults to https://radar.telemt.top/api/v1/endpoints/statuses when empty.
|
||||||
|
StatusesURL string `yaml:"statuses_url"`
|
||||||
|
// HTTPTimeoutMs is outbound GET timeout for statuses; 0 means 30000.
|
||||||
|
HTTPTimeoutMs uint64 `yaml:"http_timeout_ms"`
|
||||||
|
// PingFrom is copied into JSON field "from" in ping-dc; empty uses request Host (no port) or "telemt-gateway".
|
||||||
|
PingFrom string `yaml:"ping_from"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional).
|
// GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional).
|
||||||
@@ -160,6 +173,17 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]")
|
return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.Radar != nil {
|
||||||
|
if c.Radar.HTTPTimeoutMs > 600000 {
|
||||||
|
return fmt.Errorf("radar.http_timeout_ms must be within [0, 600000]")
|
||||||
|
}
|
||||||
|
if u := strings.TrimSpace(c.Radar.StatusesURL); u != "" {
|
||||||
|
pu, err := url.Parse(u)
|
||||||
|
if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" {
|
||||||
|
return fmt.Errorf("radar.statuses_url: invalid URL %q", c.Radar.StatusesURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRadarStatusesURL(t *testing.T) {
|
||||||
|
c := &Config{
|
||||||
|
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
|
||||||
|
Radar: &RadarConfig{StatusesURL: "ftp://bad"},
|
||||||
|
}
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("expected error for invalid radar.statuses_url scheme")
|
||||||
|
}
|
||||||
|
c.Radar.StatusesURL = "https://radar.example/api"
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRadarHTTPTimeoutMs(t *testing.T) {
|
||||||
|
c := &Config{
|
||||||
|
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
|
||||||
|
Radar: &RadarConfig{HTTPTimeoutMs: 600001},
|
||||||
|
}
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("expected error for radar.http_timeout_ms > 600000")
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-12
@@ -34,6 +34,22 @@ type Gateway struct {
|
|||||||
promHandler http.Handler
|
promHandler http.Handler
|
||||||
corsAllowed []string
|
corsAllowed []string
|
||||||
webUI http.Handler
|
webUI http.Handler
|
||||||
|
radarStatusesURL string
|
||||||
|
radarPingFrom string
|
||||||
|
radarHTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) {
|
||||||
|
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.
|
// 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,
|
transport: t,
|
||||||
promHandler: promhttp.Handler(),
|
promHandler: promhttp.Handler(),
|
||||||
}
|
}
|
||||||
|
exposeErr := p.Config.ExposeUpstreamErrors
|
||||||
for i := range p.Config.Servers {
|
for i := range p.Config.Servers {
|
||||||
s := &p.Config.Servers[i]
|
s := &p.Config.Servers[i]
|
||||||
u, err := url.Parse(s.BaseURL)
|
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
|
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) {
|
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)
|
log.Error("upstream error", "alias", s.Alias, "err", err)
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "upstream unreachable", err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"ok": false,
|
|
||||||
"error": map[string]string{"code": "bad_gateway", "message": "upstream unreachable"},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for alias, m := range p.MihomoByAlias {
|
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"
|
strip := "/api/" + a + "/mihomo"
|
||||||
g.mihomo[a] = proxy.NewMihomoForward(m.Base, strip, m.Auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
|
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)
|
log.Error("mihomo upstream error", "alias", a, "err", err)
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "mihomo upstream unreachable", err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"ok": false,
|
|
||||||
"error": map[string]string{"code": "bad_gateway", "message": "mihomo upstream unreachable"},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
var aggCacheTTL time.Duration
|
var aggCacheTTL time.Duration
|
||||||
@@ -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.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
|
||||||
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
|
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
|
||||||
g.webUI = webui.Handler()
|
g.webUI = webui.Handler()
|
||||||
|
|
||||||
|
statusesURL := defaultRadarStatusesURL
|
||||||
|
radarTo := 30 * time.Second
|
||||||
|
if p.Config.Radar != nil {
|
||||||
|
if u := strings.TrimSpace(p.Config.Radar.StatusesURL); u != "" {
|
||||||
|
statusesURL = u
|
||||||
|
}
|
||||||
|
if p.Config.Radar.HTTPTimeoutMs > 0 {
|
||||||
|
radarTo = time.Duration(p.Config.Radar.HTTPTimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
g.radarPingFrom = strings.TrimSpace(p.Config.Radar.PingFrom)
|
||||||
|
}
|
||||||
|
g.radarStatusesURL = statusesURL
|
||||||
|
g.radarHTTPClient = &http.Client{Transport: t, Timeout: radarTo}
|
||||||
|
|
||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +260,13 @@ func routeEndpoint(path string) string {
|
|||||||
if path == "/api/agg" {
|
if path == "/api/agg" {
|
||||||
return "agg"
|
return "agg"
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(path, "/api/radar/") {
|
||||||
|
rest := strings.TrimPrefix(path, "/api/radar/")
|
||||||
|
if rest == "" {
|
||||||
|
return "radar"
|
||||||
|
}
|
||||||
|
return "radar_" + strings.ReplaceAll(rest, "/", "_")
|
||||||
|
}
|
||||||
if strings.HasPrefix(path, "/api/live/events") {
|
if strings.HasPrefix(path, "/api/live/events") {
|
||||||
return "live_events"
|
return "live_events"
|
||||||
}
|
}
|
||||||
@@ -279,6 +308,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxy.NormalizeRequestURLPath(r)
|
proxy.NormalizeRequestURLPath(r)
|
||||||
}
|
}
|
||||||
const prefix = "/api/"
|
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/") {
|
if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") {
|
||||||
g.agg.ServeHTTP(w, r)
|
g.agg.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
@@ -287,6 +320,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.serveLiveEvents(w, r)
|
g.serveLiveEvents(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.URL.Path == "/api/radar/statuses" || r.URL.Path == "/api/radar/ping-dc" {
|
||||||
|
g.serveRadar(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||||
g.webUI.ServeHTTP(w, r)
|
g.webUI.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultRadarStatusesURL = "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||||
|
|
||||||
|
// telegramDC443 — IPv4 DC Telegram для MTProto (как в ping_proxy.php исходной панели).
|
||||||
|
var telegramDC443 = map[int]string{
|
||||||
|
1: "149.154.175.50",
|
||||||
|
2: "149.154.167.51",
|
||||||
|
3: "149.154.175.100",
|
||||||
|
4: "149.154.167.91",
|
||||||
|
5: "149.154.171.5",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) serveRadar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/radar/statuses":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.serveRadarStatuses(w, r)
|
||||||
|
case "/api/radar/ping-dc":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.serveRadarPingDC(w, r)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRadarStatuses проксирует JSON radar.telemt.top (аналог radar_proxy.php).
|
||||||
|
func (g *Gateway) serveRadarStatuses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, g.radarStatusesURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
writeRadarFailed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := g.radarHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeRadarFailed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
writeRadarFailed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/json"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRadarFailed(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "failed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRadarPingDC — TCP :443 до каждого DC с таймаутом 2 с (аналог ping_proxy.php).
|
||||||
|
func (g *Gateway) serveRadarPingDC(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from := strings.TrimSpace(g.radarPingFrom)
|
||||||
|
if from == "" {
|
||||||
|
from = hostOnly(r.Host)
|
||||||
|
if from == "" {
|
||||||
|
from = "telemt-gateway"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results := make(map[string]map[string]any, len(telegramDC443))
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
ip := telegramDC443[i]
|
||||||
|
addr := net.JoinHostPort(ip, "443")
|
||||||
|
t0 := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
|
ms := int(time.Since(t0).Milliseconds())
|
||||||
|
key := strconv.Itoa(i)
|
||||||
|
if err != nil {
|
||||||
|
results[key] = map[string]any{"ok": false, "ms": nil}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
results[key] = map[string]any{"ok": true, "ms": ms}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"results": results,
|
||||||
|
"from": from,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOnly(hostPort string) string {
|
||||||
|
hostPort = strings.TrimSpace(hostPort)
|
||||||
|
if hostPort == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// IPv6 в квадратных скобках: [::1]:8080
|
||||||
|
if strings.HasPrefix(hostPort, "[") {
|
||||||
|
if i := strings.IndexByte(hostPort, ']'); i > 0 {
|
||||||
|
return hostPort[1:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h, _, err := net.SplitHostPort(hostPort)
|
||||||
|
if err != nil {
|
||||||
|
return hostPort
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHostOnly(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"example.com:8080", "example.com"},
|
||||||
|
{"[::1]:443", "::1"},
|
||||||
|
{"127.0.0.1", "127.0.0.1"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := hostOnly(tt.in); got != tt.want {
|
||||||
|
t.Errorf("hostOnly(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,6 +180,50 @@ export async function fetchAggFleetStatus(params?: { aliases?: string }): Promis
|
|||||||
return body as AggEnvelope<components['schemas']['FleetStatusData']>;
|
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?: {
|
export async function fetchAggUniqueIps(params?: {
|
||||||
aliases?: string;
|
aliases?: string;
|
||||||
geo?: boolean;
|
geo?: boolean;
|
||||||
@@ -238,6 +282,35 @@ export async function fetchAggIncidents(params?: { aliases?: string }): Promise<
|
|||||||
return body as AggEnvelope<IncidentsData>;
|
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 {
|
export function liveEventsUrl(params?: { aliases?: string }): string {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params?.aliases) q.set('aliases', params.aliases);
|
if (params?.aliases) q.set('aliases', params.aliases);
|
||||||
@@ -357,3 +430,52 @@ export async function deleteUser(
|
|||||||
}
|
}
|
||||||
return parsed as TelemtSuccess<string>;
|
return parsed as TelemtSuccess<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ответ GET /api/radar/ping-dc (как ping_proxy.php). */
|
||||||
|
export type RadarPingRow = { ok: boolean; ms: number | null };
|
||||||
|
export type RadarPingResponse = { ok: boolean; results: Record<string, RadarPingRow>; from: string };
|
||||||
|
|
||||||
|
/** Прокси к radar.telemt.top — массив эндпоинтов (как radar_proxy.php). */
|
||||||
|
export async function fetchRadarStatuses(): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${gatewayBase()}/api/radar/statuses`);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text) as { error?: string };
|
||||||
|
if (j?.error) msg = j.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new ApiError(msg, res.status, text);
|
||||||
|
}
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new ApiError('radar: ожидался JSON-массив', res.status, data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRadarPingDC(): Promise<RadarPingResponse> {
|
||||||
|
const res = await fetch(`${gatewayBase()}/api/radar/ping-dc`);
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(`ping-dc HTTP ${res.status}`, res.status, parsed);
|
||||||
|
}
|
||||||
|
const body = parsed as RadarPingResponse;
|
||||||
|
if (!body || body.ok !== true || !body.results) {
|
||||||
|
throw new ApiError('ping-dc: неверный ответ', res.status, parsed);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
@@ -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">
|
<script lang="ts">
|
||||||
import * as Select from "$lib/components/ui/select/index.js";
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
availableAliases = [],
|
availableAliases = [],
|
||||||
value = $bindable("all"),
|
value = $bindable("all"),
|
||||||
label = "Alias",
|
label = "Alias",
|
||||||
allLabel = "All nodes"
|
allLabel = "All nodes",
|
||||||
|
onValueChange
|
||||||
}: {
|
}: {
|
||||||
availableAliases: string[];
|
availableAliases: string[];
|
||||||
value?: string;
|
value?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
allLabel?: string;
|
allLabel?: string;
|
||||||
|
onValueChange?: (next: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let prev = value || "all";
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ((value ?? "all") !== "all" && !availableAliases.includes(value ?? "all")) {
|
|
||||||
value = "all";
|
|
||||||
}
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
value = "all";
|
value = "all";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value !== prev) {
|
||||||
|
prev = value;
|
||||||
|
untrack(() => onValueChange?.(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let selectedLabel = $derived((value ?? "all") === "all" ? allLabel : (value ?? "all"));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="text-xs text-muted-foreground">
|
<label class="text-xs text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
<Select.Root type="single" bind:value>
|
<select
|
||||||
<Select.Trigger class="mt-1 w-[220px]">
|
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"
|
||||||
{selectedLabel}
|
bind:value
|
||||||
</Select.Trigger>
|
>
|
||||||
<Select.Content class="z-[1200]">
|
<option value="all">{allLabel}</option>
|
||||||
<Select.Item value="all">{allLabel}</Select.Item>
|
|
||||||
{#each availableAliases as alias (alias)}
|
{#each availableAliases as alias (alias)}
|
||||||
<Select.Item value={alias}>{alias}</Select.Item>
|
<option value={alias}>{alias}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</select>
|
||||||
</Select.Root>
|
|
||||||
</label>
|
</label>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||||
import SirenIcon from '@lucide/svelte/icons/siren';
|
import SirenIcon from '@lucide/svelte/icons/siren';
|
||||||
import RadioIcon from '@lucide/svelte/icons/radio';
|
import RadioIcon from '@lucide/svelte/icons/radio';
|
||||||
|
import RadarIcon from '@lucide/svelte/icons/radar';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents">
|
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Инциденты">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="/incidents" {...props}>
|
<a href="/incidents" {...props}>
|
||||||
<SirenIcon />
|
<SirenIcon />
|
||||||
@@ -104,6 +105,26 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton isActive={active('/radar')} tooltipContent="Радар DC Telegram">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href="/radar" {...props}>
|
||||||
|
<RadarIcon />
|
||||||
|
<span>Радар DC</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.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.Menu>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
{#if serverAliases.length > 0}
|
{#if serverAliases.length > 0}
|
||||||
@@ -133,11 +154,11 @@
|
|||||||
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
|
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Dashboard">
|
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Обзор">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href={serverPath} {...props}>
|
<a href={serverPath} {...props}>
|
||||||
<LayoutDashboardIcon />
|
<LayoutDashboardIcon />
|
||||||
<span>Dashboard</span>
|
<span>Обзор</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -153,61 +174,61 @@
|
|||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
|
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Состояние">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{serverPath}/runtime" {...props}>
|
<a href="{serverPath}/runtime" {...props}>
|
||||||
<ActivityIcon />
|
<ActivityIcon />
|
||||||
<span>Runtime</span>
|
<span>Состояние</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Users">
|
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Пользователи">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{serverPath}/users" {...props}>
|
<a href="{serverPath}/users" {...props}>
|
||||||
<UsersIcon />
|
<UsersIcon />
|
||||||
<span>Users</span>
|
<span>Пользователи</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Security">
|
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Безопасность">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{serverPath}/security" {...props}>
|
<a href="{serverPath}/security" {...props}>
|
||||||
<ShieldIcon />
|
<ShieldIcon />
|
||||||
<span>Security</span>
|
<span>Безопасность</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Upstreams">
|
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Апстримы и ДЦ">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{serverPath}/upstreams" {...props}>
|
<a href="{serverPath}/upstreams" {...props}>
|
||||||
<NetworkIcon />
|
<NetworkIcon />
|
||||||
<span>Upstreams & DCs</span>
|
<span>Апстримы и ДЦ</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Configuration">
|
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Конфигурация">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{serverPath}/config" {...props}>
|
<a href="{serverPath}/config" {...props}>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
<span>Configuration</span>
|
<span>Конфигурация</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Update">
|
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Обновление">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{serverPath}/update" {...props}>
|
<a href="{serverPath}/update" {...props}>
|
||||||
<RefreshCwIcon />
|
<RefreshCwIcon />
|
||||||
<span>Update</span>
|
<span>Обновление</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</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,10 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import Chart from 'chart.js/auto';
|
import Chart from 'chart.js/auto';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||||
|
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
|
||||||
|
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
|
||||||
|
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
||||||
|
import {
|
||||||
|
chartColorWithAlpha,
|
||||||
|
chartGridColor,
|
||||||
|
chartLegendColor,
|
||||||
|
chartLineAreaFillCallback,
|
||||||
|
chartSeriesColors,
|
||||||
|
chartTickColor,
|
||||||
|
readCssVar
|
||||||
|
} from '$lib/chart-theme.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
histUp,
|
histUp,
|
||||||
@@ -42,72 +54,205 @@
|
|||||||
let chartNet: Chart | null = null;
|
let chartNet: Chart | null = null;
|
||||||
let chartTop: Chart | null = null;
|
let chartTop: Chart | null = null;
|
||||||
|
|
||||||
const grid = '#64748b';
|
|
||||||
|
|
||||||
function lineOpts() {
|
function lineOpts() {
|
||||||
|
const tick = chartTickColor();
|
||||||
|
const grid = chartGridColor();
|
||||||
|
const legend = chartLegendColor();
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
animation: false as const,
|
animation: false as const,
|
||||||
plugins: { legend: { labels: { color: grid } } },
|
plugins: { legend: { labels: { color: legend } } },
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
|
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
|
||||||
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
|
y: { ticks: { color: tick }, grid: { color: grid } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
function rebuildAll() {
|
||||||
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
|
const colors = chartSeriesColors();
|
||||||
|
const legend = chartLegendColor();
|
||||||
|
const grid = chartGridColor();
|
||||||
|
const tick = chartTickColor();
|
||||||
const lab = histDown.map((_, i) => String(i));
|
const lab = histDown.map((_, i) => String(i));
|
||||||
|
|
||||||
|
if (elTraffic) {
|
||||||
|
chartTraffic?.destroy();
|
||||||
|
const c0 = colors[0] ?? '#38bdf8';
|
||||||
|
const c4 = colors[4] ?? '#f472b6';
|
||||||
chartTraffic = new Chart(elTraffic, {
|
chartTraffic = new Chart(elTraffic, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: lab,
|
labels: lab,
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true },
|
{
|
||||||
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true }
|
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()
|
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, {
|
chartFlow = new Chart(elFlow, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: ['Скачано', 'Загружено'],
|
labels: ['Скачано', 'Загружено'],
|
||||||
datasets: [{ data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], backgroundColor: ['#38bdf8', '#f472b6'] }]
|
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, plugins: { legend: { labels: { color: grid } } } }
|
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, {
|
chartMem = new Chart(elMem, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: histMem.map((_, i) => String(i)),
|
labels: histMem.map((_, i) => String(i)),
|
||||||
datasets: [{ label: 'KB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }]
|
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()
|
options: lineOpts()
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elConn) {
|
||||||
|
chartConn?.destroy();
|
||||||
|
const c1 = colors[1] ?? '#34d399';
|
||||||
chartConn = new Chart(elConn, {
|
chartConn = new Chart(elConn, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: histConn.map((_, i) => String(i)),
|
labels: histConn.map((_, i) => String(i)),
|
||||||
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }]
|
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()
|
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, {
|
chartNet = new Chart(elNet, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: ['TCP', 'UDP'],
|
labels: ['TCP', 'UDP'],
|
||||||
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }]
|
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, plugins: { legend: { labels: { color: grid } } } }
|
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, {
|
chartTop = new Chart(elTop, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: topList.map((x) => x.name),
|
labels: topList.map((x) => x.name),
|
||||||
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }]
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Сессии',
|
||||||
|
data: topList.map((x) => x.n),
|
||||||
|
backgroundColor: topBarColor,
|
||||||
|
hoverBackgroundColor: topBarColor,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
indexAxis: 'y' as const,
|
indexAxis: 'y' as const,
|
||||||
@@ -116,11 +261,25 @@
|
|||||||
animation: false,
|
animation: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: grid }, grid: { color: '#33415555' } },
|
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||||
y: { ticks: { color: grid }, grid: { display: false } }
|
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(() => {
|
$effect(() => {
|
||||||
@@ -167,58 +326,76 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Header>
|
accent="neutral"
|
||||||
<Card.Title class="flex items-center gap-2 text-base">
|
title="Трафик"
|
||||||
<ActivityIcon class="size-4" /> Трафик
|
description="Скорость загрузки и скачивания (история из /traffic)"
|
||||||
</Card.Title>
|
headerClass="pb-2"
|
||||||
</Card.Header>
|
contentClass="h-56 px-4 pb-4 pt-0"
|
||||||
<Card.Content class="h-56">
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Root>
|
accent="neutral"
|
||||||
<Card.Header>
|
title="Поток (накоплено)"
|
||||||
<Card.Title class="text-base">Поток (накоплено)</Card.Title>
|
description="Соотношение накопленного down / up"
|
||||||
</Card.Header>
|
headerClass="pb-2"
|
||||||
<Card.Content class="h-56">
|
contentClass="h-56 px-4 pb-4 pt-0"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<ChartPieIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Root>
|
accent="neutral"
|
||||||
<Card.Header>
|
title="Память (KiB)"
|
||||||
<Card.Title class="text-base">Память (KB)</Card.Title>
|
description="Использование по снимкам (ось — KiB)"
|
||||||
</Card.Header>
|
headerClass="pb-2"
|
||||||
<Card.Content class="h-56">
|
contentClass="h-56 px-4 pb-4 pt-0"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<MemoryStickIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Root>
|
accent="neutral"
|
||||||
<Card.Header>
|
title="Подключения"
|
||||||
<Card.Title class="text-base">Подключения</Card.Title>
|
description="Число активных соединений во времени"
|
||||||
</Card.Header>
|
headerClass="pb-2"
|
||||||
<Card.Content class="h-56">
|
contentClass="h-56 px-4 pb-4 pt-0"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<PlugIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Root>
|
accent="neutral"
|
||||||
<Card.Header>
|
title="Типы сети"
|
||||||
<Card.Title class="flex items-center gap-2 text-base">
|
description="TCP и UDP по текущему снимку"
|
||||||
<NetworkIcon class="size-4" /> Типы сети
|
headerClass="pb-2"
|
||||||
</Card.Title>
|
contentClass="h-56 px-4 pb-4 pt-0"
|
||||||
</Card.Header>
|
>
|
||||||
<Card.Content class="h-56">
|
{#snippet icon()}
|
||||||
|
<NetworkIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Root>
|
accent="neutral"
|
||||||
<Card.Header>
|
title="Топ прокси (по сессиям)"
|
||||||
<Card.Title class="flex items-center gap-2 text-base">
|
description="Распределение по имени прокси"
|
||||||
<GaugeIcon class="size-4" /> Топ прокси (по сессиям)
|
headerClass="pb-2"
|
||||||
</Card.Title>
|
contentClass="h-56 px-4 pb-4 pt-0"
|
||||||
</Card.Header>
|
>
|
||||||
<Card.Content class="h-56">
|
{#snippet icon()}
|
||||||
|
<GaugeIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -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,
|
class: className,
|
||||||
children,
|
children,
|
||||||
size = "default",
|
size = "default",
|
||||||
|
density = "default",
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
size?: "default" | "sm";
|
||||||
|
density?: "default" | "compact";
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
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}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@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>
|
</script>
|
||||||
|
|
||||||
{#snippet Fallback()}
|
{#snippet Fallback()}
|
||||||
<span>Next</span>
|
<span>Следующая</span>
|
||||||
<ChevronRightIcon class="size-4" />
|
<ChevronRightIcon class="size-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<PaginationPrimitive.NextButton
|
<PaginationPrimitive.NextButton
|
||||||
bind:ref
|
bind:ref
|
||||||
aria-label="Go to next page"
|
aria-label="Перейти на следующую страницу"
|
||||||
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
|
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label="Go to next page"
|
aria-label="Перейти на следующую страницу"
|
||||||
size="default"
|
size="default"
|
||||||
class={cn("pr-1.5!", className)}
|
class={cn("pr-1.5!", className)}
|
||||||
{...restProps}
|
{...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" />
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
|
|
||||||
{#snippet Fallback()}
|
{#snippet Fallback()}
|
||||||
<ChevronLeftIcon class="size-4" />
|
<ChevronLeftIcon class="size-4" />
|
||||||
<span>Previous</span>
|
<span>Предыдущая</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<PaginationPrimitive.PrevButton
|
<PaginationPrimitive.PrevButton
|
||||||
bind:ref
|
bind:ref
|
||||||
aria-label="Go to previous page"
|
aria-label="Перейти на предыдущую страницу"
|
||||||
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
|
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label="Go to previous page"
|
aria-label="Перейти на предыдущую страницу"
|
||||||
size="default"
|
size="default"
|
||||||
class={cn("pl-1.5!", className)}
|
class={cn("pl-1.5!", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon data-icon="inline-start" />
|
<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>
|
</PaginationLink>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
bind:page
|
bind:page
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="pagination"
|
aria-label="Пагинация"
|
||||||
data-slot="pagination"
|
data-slot="pagination"
|
||||||
{count}
|
{count}
|
||||||
{perPage}
|
{perPage}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@
|
|||||||
{side}
|
{side}
|
||||||
>
|
>
|
||||||
<Sheet.Header class="sr-only">
|
<Sheet.Header class="sr-only">
|
||||||
<Sheet.Title>Sidebar</Sheet.Title>
|
<Sheet.Title>Боковая панель</Sheet.Title>
|
||||||
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
|
<Sheet.Description>Отображает мобильную боковую панель.</Sheet.Description>
|
||||||
</Sheet.Header>
|
</Sheet.Header>
|
||||||
<div class="flex h-full w-full flex-col">
|
<div class="flex h-full w-full flex-col">
|
||||||
{@render children?.()}
|
{@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">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import { page } from '$app/state';
|
import { page, navigating } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
@@ -9,9 +9,15 @@
|
|||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import { Toaster } from '$lib/components/ui/sonner/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();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
|
||||||
|
/** Нельзя использовать `!== null`: при `navigating === undefined` выражение истинно и полоска «зависает». */
|
||||||
|
const isNavigating = $derived(!!navigating);
|
||||||
|
|
||||||
let serverAliases = $state<string[]>([]);
|
let serverAliases = $state<string[]>([]);
|
||||||
|
|
||||||
const serverMatch = $derived.by(() => {
|
const serverMatch = $derived.by(() => {
|
||||||
@@ -42,7 +48,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<title>Telemt Panel</title>
|
<title>{docTitle}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
@@ -50,10 +56,13 @@
|
|||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<AppSidebar {serverAliases} {serverPath} />
|
<AppSidebar {serverAliases} {serverPath} />
|
||||||
<Sidebar.Inset>
|
<Sidebar.Inset>
|
||||||
<header
|
{#if isNavigating}
|
||||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"
|
<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>
|
||||||
<Sidebar.Trigger class="-ml-1" />
|
</div>
|
||||||
|
{/if}
|
||||||
|
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
|
<SiteHeader />
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-1 flex-col p-4">
|
<div class="flex flex-1 flex-col p-4">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
+481
-115
@@ -9,35 +9,44 @@
|
|||||||
fetchAggUniqueIps,
|
fetchAggUniqueIps,
|
||||||
fetchStatsSummary
|
fetchStatsSummary
|
||||||
} from '$lib/api/client.js';
|
} 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 type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.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 * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
|
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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 AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
||||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||||
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
||||||
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import { cn } from '$lib/utils.js';
|
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
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 InfoIcon from '@lucide/svelte/icons/info';
|
||||||
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
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 ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
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 {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
const value = raw?.trim() ?? '';
|
const value = raw?.trim() ?? '';
|
||||||
return !value || value.includes(',') ? 'all' : value;
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
@@ -90,10 +139,15 @@
|
|||||||
summary = s.data;
|
summary = s.data;
|
||||||
fleet = f.data ?? null;
|
fleet = f.data ?? null;
|
||||||
uniqueIps = u.data ?? null;
|
uniqueIps = u.data ?? null;
|
||||||
availableAliases = collectAliases(
|
const nextAliases = collectAliases(
|
||||||
(fleet?.servers ?? []).map((x) => x.alias),
|
(fleet?.servers ?? []).map((x) => x.alias),
|
||||||
(summary?.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();
|
lastSuccessAtMs = Date.now();
|
||||||
|
|
||||||
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
|
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
|
||||||
@@ -166,9 +220,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAliasIfChanged(nextAlias: string) {
|
async function handleAliasFilterChange(nextAlias: string) {
|
||||||
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
aliasFilter = nextAlias;
|
||||||
if (nextAlias === currentAlias) return;
|
const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||||
|
if (nextAlias === currentUrlAlias) return;
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
if (nextAlias === 'all') q.delete('aliases');
|
if (nextAlias === 'all') q.delete('aliases');
|
||||||
else q.set('aliases', nextAlias);
|
else q.set('aliases', nextAlias);
|
||||||
@@ -178,6 +233,7 @@
|
|||||||
noScroll: true,
|
noScroll: true,
|
||||||
keepFocus: true
|
keepFocus: true
|
||||||
});
|
});
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
let queryKey = $derived(page.url.searchParams.toString());
|
let queryKey = $derived(page.url.searchParams.toString());
|
||||||
@@ -191,11 +247,6 @@
|
|||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
void applyAliasIfChanged(aliasFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
let staleSeconds = $derived.by(() =>
|
let staleSeconds = $derived.by(() =>
|
||||||
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
|
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
|
||||||
);
|
);
|
||||||
@@ -266,26 +317,15 @@
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
|
|
||||||
let nodesHealthAccent = $derived.by((): KpiAccentKey => {
|
let nodesOkChart = $derived.by(() => {
|
||||||
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0;
|
const list = fleet?.servers ?? [];
|
||||||
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0;
|
return list.filter((s) => s.health_ok && s.system_info_ok).length;
|
||||||
if (t === 0) return 'neutral';
|
|
||||||
if (ok === t) return 'success';
|
|
||||||
if (ok === 0) return 'danger';
|
|
||||||
return 'warning';
|
|
||||||
});
|
});
|
||||||
|
let nodesDegradedChart = $derived.by(() => {
|
||||||
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */
|
const list = fleet?.servers ?? [];
|
||||||
let badConnectionsAccent = $derived.by((): KpiAccentKey => {
|
return list.filter((s) => !s.health_ok || !s.system_info_ok).length;
|
||||||
if (totalBad === 0) return 'success';
|
|
||||||
if (totalBad < 10_000) return 'warning';
|
|
||||||
return 'danger';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let readOnlyAccent = $derived.by((): KpiAccentKey =>
|
|
||||||
readOnlyNodes === 0 ? 'success' : 'warning'
|
|
||||||
);
|
|
||||||
|
|
||||||
function chipLabel(ip: components['schemas']['IPAssignments']): string {
|
function chipLabel(ip: components['schemas']['IPAssignments']): string {
|
||||||
const cc = ip.country_code?.trim();
|
const cc = ip.country_code?.trim();
|
||||||
const flag = flagEmoji(cc ?? undefined);
|
const flag = flagEmoji(cc ?? undefined);
|
||||||
@@ -373,19 +413,106 @@
|
|||||||
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
|
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
|
||||||
return out;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<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>
|
<h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1>
|
||||||
{#if generatedAt}
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
Снимок: {new Date(generatedAt).toLocaleString()}
|
Состояние нод и агрегированные метрики Telemt
|
||||||
</p>
|
{#if generatedAt}
|
||||||
|
<span class="hidden sm:inline"> · Снимок: {new Date(generatedAt).toLocaleString()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||||
@@ -397,7 +524,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<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()} />
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -420,80 +551,116 @@
|
|||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<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)}
|
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||||
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
<Skeleton class="h-28 rounded-xl" />
|
||||||
<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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{#if summary}
|
{#if summary}
|
||||||
|
{@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">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос">
|
<KpiStatCard accent="violet" label="Ноды OK / всего" footer="health + system/info" valueClass="text-xl">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<ServerIcon class="size-5" aria-hidden="true" />
|
<ServerIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
|
{okN}/{totN}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users">
|
<KpiStatCard
|
||||||
|
accent="info"
|
||||||
|
label="Соединения (флот)"
|
||||||
|
footer="агрегат stats/users"
|
||||||
|
valueClass="text-xl"
|
||||||
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
<PlugIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{summary.fleet_total_connections}
|
{summary.fleet_total_connections}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)">
|
<KpiStatCard accent="danger" label="Плохие соединения" footer="сумма по нодам" valueClass="text-xl">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<UnplugIcon class="size-5" aria-hidden="true" />
|
<ShieldAlertIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{totalBad}
|
{totalBad}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips">
|
<KpiStatCard accent="teal" label="Уникальные IP" footer="по снимку unique-ips" valueClass="text-xl">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{uniqueIpCount}
|
{uniqueIpCount}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB">
|
<KpiStatCard
|
||||||
|
accent="warning"
|
||||||
|
label="Трафик флота (MiB)"
|
||||||
|
footer="суммарный трафик summary"
|
||||||
|
valueClass="text-lg"
|
||||||
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
<CloudUploadIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{formatMiB(summary.fleet_total_megabytes)}
|
{formatMiB(summary.fleet_total_megabytes)}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only">
|
<KpiStatCard accent="neutral" label="Read-only API (нод)" footer="health.read_only" valueClass="text-xl">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<LockKeyholeIcon class="size-5" aria-hidden="true" />
|
<LockIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{readOnlyNodes}
|
{readOnlyNodes}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiPanelCard
|
<FleetOverviewCharts
|
||||||
|
nodesOk={nodesOkChart}
|
||||||
|
nodesDegraded={nodesDegradedChart}
|
||||||
|
topUsers={summary.top_users.slice(0, 8)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTableCard
|
||||||
class="mb-6"
|
class="mb-6"
|
||||||
accent="violet"
|
|
||||||
title="Активные IP (агрегат)"
|
title="Активные IP (агрегат)"
|
||||||
description="С GeoIP при включённой базе на шлюзе"
|
description="С GeoIP при включённой базе на шлюзе"
|
||||||
|
accent="teal"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
{#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}
|
{/snippet}
|
||||||
<div class="overflow-x-auto">
|
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head>
|
<SortableTh
|
||||||
<Table.Head class="text-xs sm:text-sm">Клиент</Table.Head>
|
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">
|
<Table.Head class="text-xs sm:text-sm">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Серверы
|
Серверы
|
||||||
@@ -506,27 +673,39 @@
|
|||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side="bottom" class="max-w-xs">
|
<Tooltip.Content side="bottom" class="max-w-xs">
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены
|
Активные и недавние — на момент снимка шлюза.
|
||||||
группы.
|
|
||||||
</p>
|
</p>
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
</Table.Head>
|
</Table.Head>
|
||||||
|
<Table.Head class="w-10"></Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#if (activeIpServerRows?.length ?? 0) === 0}
|
{#if activeIpServerRows.length === 0}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground">
|
<Table.Cell colspan={4} class="p-0">
|
||||||
Нет активных подключений
|
<TableEmpty
|
||||||
|
title="Нет активных подключений"
|
||||||
|
description="В снимке unique-ips нет IP с активными серверами."
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{:else if filteredSortedIpRows.length === 0}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell colspan={4} class="p-0">
|
||||||
|
<TableEmpty
|
||||||
|
title="Нет строк по фильтру"
|
||||||
|
description="Измените поиск или сбросьте фильтры."
|
||||||
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{:else}
|
||||||
{#each activeIpServerRows as r (r.username + '|' + r.ip)}
|
{#each filteredSortedIpRows as r (r.username + '|' + r.ip)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
|
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
|
||||||
{r.activeLabel}
|
<Badge variant="secondary" class="font-mono font-normal">{r.activeLabel}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-xs sm:text-sm">
|
<Table.Cell class="text-xs sm:text-sm">
|
||||||
<a
|
<a
|
||||||
@@ -543,49 +722,145 @@
|
|||||||
primary_server={r.primary_server}
|
primary_server={r.primary_server}
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</KpiPanelCard>
|
</DataTableCard>
|
||||||
|
|
||||||
<KpiPanelCard accent="info" title="По нодам" description="Health, версия, аптайм, трафик ноды" contentClass="p-0">
|
<DataTableCard
|
||||||
{#snippet icon()}
|
class="mb-6"
|
||||||
<ServerIcon class="size-5" aria-hidden="true" />
|
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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(70vh,480px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Сервер</Table.Head>
|
<SortableTh
|
||||||
|
active={nodeSortKey === 'alias'}
|
||||||
|
dir={nodeSortDir}
|
||||||
|
onToggle={() => toggleNodeSort('alias')}
|
||||||
|
>Сервер</SortableTh>
|
||||||
<Table.Head>Статус</Table.Head>
|
<Table.Head>Статус</Table.Head>
|
||||||
<Table.Head class="text-right">Latency</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>
|
||||||
<Table.Head class="text-right">Аптайм</Table.Head>
|
<Table.Head class="text-right">Аптайм</Table.Head>
|
||||||
<Table.Head class="text-right">Bad conn.</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.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each fleet?.servers ?? [] as srv (srv.alias)}
|
{#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.Row>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<a class="text-primary hover:underline" href="/servers/{encodeURIComponent(srv.alias ?? '')}">
|
<a
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
href="/servers/{encodeURIComponent(srv.alias ?? '')}"
|
||||||
|
>
|
||||||
{srv.alias}
|
{srv.alias}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if srv.health_ok && srv.system_info_ok}
|
{#if srv.health_ok && srv.system_info_ok}
|
||||||
<Badge class="bg-emerald-600/20 text-emerald-400">OK</Badge>
|
<StatusBadge variant="ok" />
|
||||||
{:else}
|
{:else}
|
||||||
<Badge variant="destructive">degraded</Badge>
|
<StatusBadge variant="degraded" />
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||||
{srv.health_latency_ms ?? '—'} ms
|
{srv.health_latency_ms ?? '—'} ms
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right text-muted-foreground">
|
<Table.Cell class="text-right text-muted-foreground">
|
||||||
{srv.system_info?.version ?? '—'}
|
<Badge variant="secondary">{srv.system_info?.version ?? '—'}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right text-muted-foreground">
|
<Table.Cell class="text-right text-muted-foreground">
|
||||||
{srv.system_info && srv.system_info.uptime_seconds != null
|
{srv.system_info && srv.system_info.uptime_seconds != null
|
||||||
@@ -596,7 +871,7 @@
|
|||||||
{#if srv.alias}
|
{#if srv.alias}
|
||||||
{@const st = nodeStats[srv.alias]}
|
{@const st = nodeStats[srv.alias]}
|
||||||
{#if !st}
|
{#if !st}
|
||||||
<span title="Нет ответа stats/summary для этой ноды">—</span>
|
<StatusBadge variant="pending" />
|
||||||
{:else if st && 'ok' in st && !st.ok && 'error' in st}
|
{:else if st && 'ok' in st && !st.ok && 'error' in st}
|
||||||
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
|
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
|
||||||
{:else if st && 'bad' in st && st.ok}
|
{:else if st && 'bad' in st && st.ok}
|
||||||
@@ -608,41 +883,132 @@
|
|||||||
—
|
—
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</KpiPanelCard>
|
</ScrollArea>
|
||||||
|
</DataTableCard>
|
||||||
|
|
||||||
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass="">
|
<DataTableCard
|
||||||
{#snippet icon()}
|
class="mt-6"
|
||||||
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
|
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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(60vh,400px)] w-full" orientation="vertical">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Пользователь</Table.Head>
|
<SortableTh
|
||||||
<Table.Head class="text-right">Трафик</Table.Head>
|
active={topSortKey === 'user'}
|
||||||
<Table.Head class="text-right">Замеченные IP</Table.Head>
|
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.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each summary.top_users.slice(0, 10) as t (t.username)}
|
{#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.Row>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
|
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
|
||||||
{t.username}
|
{t.username}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</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">
|
||||||
|
{formatMiB(t.total_megabytes)}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||||
{uniqueIpCountByUser[t.username] ?? 0}
|
{uniqueIpCountByUser[t.username] ?? 0}
|
||||||
</Table.Cell>
|
</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</KpiPanelCard>
|
</ScrollArea>
|
||||||
|
</DataTableCard>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
@@ -6,16 +6,19 @@
|
|||||||
ApiError,
|
ApiError,
|
||||||
fetchAggIncidents,
|
fetchAggIncidents,
|
||||||
fetchAggSummary,
|
fetchAggSummary,
|
||||||
|
postAggResetBadConnectionsBaseline,
|
||||||
type IncidentItem,
|
type IncidentItem,
|
||||||
type IncidentsData
|
type IncidentsData
|
||||||
} from '$lib/api/client.js';
|
} from '$lib/api/client.js';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
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 CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
|
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||||
import InfoSmallIcon from '@lucide/svelte/icons/info';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
|
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 * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/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 FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import InfoIcon from '@lucide/svelte/icons/info';
|
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 = {
|
type TriageState = {
|
||||||
ack?: boolean;
|
ack?: boolean;
|
||||||
@@ -54,6 +61,11 @@
|
|||||||
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let triageById = $state<Record<string, TriageState>>({});
|
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 {
|
function parseRefresh(raw: string | null): number {
|
||||||
if (raw == null || raw.trim() === '') return 30;
|
if (raw == null || raw.trim() === '') return 30;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -115,6 +127,12 @@
|
|||||||
return 'outline';
|
return 'outline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function severityLabel(sev: IncidentItem['severity']): string {
|
||||||
|
if (sev === 'critical') return 'критично';
|
||||||
|
if (sev === 'warning') return 'предупреждение';
|
||||||
|
return 'инфо';
|
||||||
|
}
|
||||||
|
|
||||||
function restartPolling() {
|
function restartPolling() {
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
clearInterval(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) {
|
async function load(aliasOverride?: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
@@ -225,18 +249,91 @@
|
|||||||
|
|
||||||
let hasStaleData = $derived(err != null && data != null);
|
let hasStaleData = $derived(err != null && data != null);
|
||||||
let showSkeleton = $derived(loading && data === null && !err);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1>
|
<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}
|
{#if generatedAt}
|
||||||
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
|
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
<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}>
|
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -277,35 +374,62 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
<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()}
|
{#snippet icon()}
|
||||||
<CircleAlertIcon class="size-5" aria-hidden="true" />
|
<SirenIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{data?.critical_total ?? 0}
|
{data?.critical_total ?? 0}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="warning" label="Warning" footer="открытые warning">
|
<KpiStatCard accent="warning" label="Предупреждения" footer="Открытые инциденты" valueClass="text-xl">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<TriangleAlertIcon class="size-5" aria-hidden="true" />
|
<TriangleAlertIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{data?.warning_total ?? 0}
|
{data?.warning_total ?? 0}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="info" label="Info" footer="открытые info">
|
<KpiStatCard accent="info" label="Инфо" footer="Открытые инциденты" valueClass="text-xl">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<InfoSmallIcon class="size-5" aria-hidden="true" />
|
<InfoIconBadge class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{data?.info_total ?? 0}
|
{data?.info_total ?? 0}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
|
<DataTableCard title="Инциденты" description="Список и разбор" accent="danger">
|
||||||
{#snippet icon()}
|
{#snippet icon()}<SirenIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||||
<ClipboardListIcon class="size-5" aria-hidden="true" />
|
{#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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Severity</Table.Head>
|
<SortableTh
|
||||||
<Table.Head>Инцидент</Table.Head>
|
active={incSortKey === 'sev'}
|
||||||
|
dir={incSortDir}
|
||||||
|
onToggle={() => toggleIncSort('sev')}
|
||||||
|
>Критичность</SortableTh>
|
||||||
|
<SortableTh
|
||||||
|
active={incSortKey === 'title'}
|
||||||
|
dir={incSortDir}
|
||||||
|
onToggle={() => toggleIncSort('title')}
|
||||||
|
>Инцидент</SortableTh>
|
||||||
<Table.Head>
|
<Table.Head>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Серверы
|
Серверы
|
||||||
@@ -322,8 +446,8 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
</Table.Head>
|
</Table.Head>
|
||||||
<Table.Head>Runbook</Table.Head>
|
<Table.Head>Инструкции</Table.Head>
|
||||||
<Table.Head>Triage</Table.Head>
|
<Table.Head>Разбор</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -333,11 +457,17 @@
|
|||||||
Инцидентов нет
|
Инцидентов нет
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</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}
|
{:else}
|
||||||
{#each data?.items ?? [] as item (item.id)}
|
{#each filteredIncidents as item (item.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="max-w-[360px]">
|
<Table.Cell class="max-w-[360px]">
|
||||||
<div class="font-medium">{item.title}</div>
|
<div class="font-medium">{item.title}</div>
|
||||||
@@ -360,29 +490,33 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="min-w-[280px]">
|
<Table.Cell class="min-w-[240px]">
|
||||||
{@const triage = triageFor(item.id)}
|
{@const triage = triageFor(item.id)}
|
||||||
<div class="mb-2 flex flex-wrap gap-1">
|
<div class="mb-2 flex flex-wrap gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
variant={triage.ack ? 'default' : 'outline'}
|
variant={triage.ack ? 'default' : 'outline'}
|
||||||
onclick={() => toggleAck(item.id)}
|
onclick={() => toggleAck(item.id)}
|
||||||
|
title={triage.ack ? 'Снять подтверждение' : 'Подтвердить'}
|
||||||
>
|
>
|
||||||
{triage.ack ? 'unack' : 'ack'}
|
{triage.ack ? 'Снять' : 'Подтв.'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
variant={triage.resolved ? 'default' : 'outline'}
|
variant={triage.resolved ? 'default' : 'outline'}
|
||||||
onclick={() => toggleResolved(item.id)}
|
onclick={() => toggleResolved(item.id)}
|
||||||
|
title={triage.resolved ? 'Вернуть в работу' : 'Решить'}
|
||||||
>
|
>
|
||||||
{triage.resolved ? 'unresolve' : 'resolve'}
|
{triage.resolved ? 'В работу' : 'Решить'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
class="h-8 w-full text-xs"
|
class="h-8 w-full text-xs"
|
||||||
placeholder="owner"
|
placeholder="Ответственный"
|
||||||
value={triage.owner ?? ''}
|
value={triage.owner ?? ''}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
patchTriage(item.id, {
|
patchTriage(item.id, {
|
||||||
@@ -394,7 +528,7 @@
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
class="h-8 w-full text-xs"
|
class="h-8 w-full text-xs"
|
||||||
placeholder="note"
|
placeholder="Примечание"
|
||||||
value={triage.note ?? ''}
|
value={triage.note ?? ''}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
patchTriage(item.id, {
|
patchTriage(item.id, {
|
||||||
@@ -408,6 +542,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</KpiPanelCard>
|
</ScrollArea>
|
||||||
|
</DataTableCard>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
+175
-16
@@ -9,6 +9,13 @@
|
|||||||
import { flagEmoji } from '$lib/format.js';
|
import { flagEmoji } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-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, type SortDir } from '$lib/table-sort.js';
|
||||||
import MapIcon from '@lucide/svelte/icons/map';
|
import MapIcon from '@lucide/svelte/icons/map';
|
||||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
@@ -49,6 +56,77 @@
|
|||||||
let mapStatus = $state<string | null>(null);
|
let mapStatus = $state<string | null>(null);
|
||||||
let renderAttempts = $state(0);
|
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 {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
const value = raw?.trim() ?? '';
|
const value = raw?.trim() ?? '';
|
||||||
return !value || value.includes(',') ? 'all' : value;
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
@@ -373,6 +451,14 @@
|
|||||||
noScroll: true,
|
noScroll: true,
|
||||||
keepFocus: 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());
|
let queryKey = $derived(page.url.searchParams.toString());
|
||||||
@@ -431,7 +517,7 @@
|
|||||||
let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
|
let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
|
||||||
</script>
|
</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>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
@@ -456,7 +542,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<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()} />
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -528,16 +618,52 @@
|
|||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
|
<DataTableCard title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" accent="teal">
|
||||||
{#snippet icon()}
|
{#snippet icon()}<GlobeIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
{#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}
|
{/snippet}
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<ScrollArea
|
||||||
|
class="h-[min(75vh,560px)] w-full shrink-0"
|
||||||
|
orientation="both"
|
||||||
|
>
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Пользователь</Table.Head>
|
<SortableTh
|
||||||
<Table.Head>IP</Table.Head>
|
active={ipSortKey === 'user'}
|
||||||
<Table.Head>Geo</Table.Head>
|
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>
|
<Table.Head>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Серверы
|
Серверы
|
||||||
@@ -559,12 +685,18 @@
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as ur (ur.username)}
|
{#if displayedIpRows.length === 0}
|
||||||
{#each ur.ips ?? [] as ip (ur.username + (ip.ip ?? ''))}
|
<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.Row>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<a href="/users/{encodeURIComponent(ur.username ?? '')}" class="text-primary hover:underline">
|
<a href="/users/{encodeURIComponent(username)}" class="text-primary hover:underline">
|
||||||
{ur.username}
|
{username}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
|
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
|
||||||
@@ -587,10 +719,37 @@
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</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}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,24 @@
|
|||||||
import {
|
import {
|
||||||
liveEventsUrl,
|
liveEventsUrl,
|
||||||
fetchAggSummary,
|
fetchAggSummary,
|
||||||
|
postAggResetBadConnectionsBaseline,
|
||||||
|
ApiError,
|
||||||
type IncidentItem,
|
type IncidentItem,
|
||||||
type IncidentSeverity,
|
type IncidentSeverity,
|
||||||
type IncidentStatus
|
type IncidentStatus
|
||||||
} from '$lib/api/client.js';
|
} from '$lib/api/client.js';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
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 ActivityIcon from '@lucide/svelte/icons/activity';
|
||||||
import LayersIcon from '@lucide/svelte/icons/layers';
|
import LayersIcon from '@lucide/svelte/icons/layers';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/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 FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import InfoIcon from '@lucide/svelte/icons/info';
|
import InfoIcon from '@lucide/svelte/icons/info';
|
||||||
|
import EraserIcon from '@lucide/svelte/icons/eraser';
|
||||||
|
|
||||||
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
||||||
|
|
||||||
@@ -56,18 +65,95 @@
|
|||||||
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let es: EventSource | 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' {
|
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
if (sev === 'critical') return 'destructive';
|
if (sev === 'critical') return 'destructive';
|
||||||
if (sev === 'warning') return 'secondary';
|
if (sev === 'warning') return 'secondary';
|
||||||
return 'outline';
|
return 'outline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function severityLabel(sev: IncidentSeverity): string {
|
||||||
|
if (sev === 'critical') return 'критично';
|
||||||
|
if (sev === 'warning') return 'предупреждение';
|
||||||
|
return 'инфо';
|
||||||
|
}
|
||||||
|
|
||||||
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
|
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
|
||||||
if (st === 'critical') return 'destructive';
|
if (st === 'critical') return 'destructive';
|
||||||
if (st === 'degraded') return 'secondary';
|
if (st === 'degraded') return 'secondary';
|
||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(st: LiveStatus | undefined): string {
|
||||||
|
if (st === 'critical') return 'критично';
|
||||||
|
if (st === 'degraded') return 'деградация';
|
||||||
|
return 'норма';
|
||||||
|
}
|
||||||
|
|
||||||
function parseAliasFilter(raw: string | null): string {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
const value = raw?.trim() ?? '';
|
const value = raw?.trim() ?? '';
|
||||||
return !value || value.includes(',') ? 'all' : value;
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
@@ -268,12 +354,12 @@
|
|||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<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>
|
<p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant={connected ? 'default' : 'secondary'}>
|
<Badge variant={connected ? 'default' : 'secondary'}>
|
||||||
{connected ? 'connected' : 'disconnected'}
|
{connected ? 'подключено' : 'отключено'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<FleetLiveStaleBadge
|
<FleetLiveStaleBadge
|
||||||
{isStale}
|
{isStale}
|
||||||
@@ -283,9 +369,28 @@
|
|||||||
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
|
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
|
||||||
: `Последнее событие потока: ${staleSeconds ?? 0} с назад.`}
|
: `Последнее событие потока: ${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()}>
|
<Button variant="outline" size="sm" onclick={() => connectStream()}>
|
||||||
<RefreshCwIcon class="mr-1 size-4" />
|
<RefreshCwIcon class="mr-1 size-4" />
|
||||||
Reconnect
|
Переподключить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,11 +401,11 @@
|
|||||||
|
|
||||||
{#if err}
|
{#if err}
|
||||||
<Alert class="mb-4">
|
<Alert class="mb-4">
|
||||||
<AlertTitle>Live stream</AlertTitle>
|
<AlertTitle>Поток событий</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{err}
|
{err}
|
||||||
{#if reconnectInSec != null}
|
{#if reconnectInSec != null}
|
||||||
Переподключение через {reconnectInSec}s.
|
Переподключение через {reconnectInSec} с.
|
||||||
{/if}
|
{/if}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -311,19 +416,17 @@
|
|||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Badge variant={statusVariant(snapshot?.status)}>
|
<Badge variant={statusVariant(snapshot?.status)}>{statusLabel(snapshot?.status)}</Badge>
|
||||||
{snapshot?.status ?? 'healthy'}
|
|
||||||
</Badge>
|
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок">
|
<KpiStatCard accent="warning" label="Частичность" footer="неполный снимок">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<LayersIcon class="size-5" aria-hidden="true" />
|
<LayersIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{snapshot?.partial ? 'yes' : 'no'}
|
{snapshot?.partial ? 'да' : 'нет'}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard
|
<KpiStatCard
|
||||||
accent="info"
|
accent="info"
|
||||||
label="Timestamp"
|
label="Временная метка"
|
||||||
footer="время snapshot"
|
footer="время snapshot"
|
||||||
valueClass="text-base font-normal tabular-nums leading-tight"
|
valueClass="text-base font-normal tabular-nums leading-tight"
|
||||||
>
|
>
|
||||||
@@ -334,21 +437,47 @@
|
|||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiPanelCard
|
<DataTableCard
|
||||||
accent="danger"
|
title="Последние инциденты из снимка"
|
||||||
title="Последние incidents из snapshot"
|
|
||||||
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
|
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
|
||||||
contentClass="p-0"
|
accent="warning"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}<ActivityIcon class="size-5" aria-hidden="true" />{/snippet}
|
||||||
<BellIcon class="size-5" aria-hidden="true" />
|
{#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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Severity</Table.Head>
|
<SortableTh
|
||||||
<Table.Head>Title</Table.Head>
|
active={liveSortKey === 'sev'}
|
||||||
<Table.Head>Summary</Table.Head>
|
dir={liveSortDir}
|
||||||
|
onToggle={() => toggleLiveSort('sev')}
|
||||||
|
>Критичность</SortableTh>
|
||||||
|
<SortableTh
|
||||||
|
active={liveSortKey === 'title'}
|
||||||
|
dir={liveSortDir}
|
||||||
|
onToggle={() => toggleLiveSort('title')}
|
||||||
|
>Заголовок</SortableTh>
|
||||||
|
<Table.Head>Описание</Table.Head>
|
||||||
<Table.Head>
|
<Table.Head>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Серверы
|
Серверы
|
||||||
@@ -374,11 +503,17 @@
|
|||||||
Инцидентов нет
|
Инцидентов нет
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</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}
|
{:else}
|
||||||
{#each snapshot?.incidents ?? [] as item (item.id)}
|
{#each filteredLiveIncidents as item (item.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">{item.title}</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>
|
<Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell>
|
||||||
@@ -390,4 +525,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</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">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import {
|
import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
|
||||||
ApiError,
|
|
||||||
fetchStatsSummary,
|
|
||||||
fetchTelemt
|
|
||||||
} from '$lib/api/client.js';
|
|
||||||
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||||
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
|
import { formatDurationSeconds } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
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 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 { 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 { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||||
@@ -30,6 +25,15 @@
|
|||||||
let summary = $state<StatsSummaryData | null>(null);
|
let summary = $state<StatsSummaryData | null>(null);
|
||||||
let sys = $state<SystemInfoData | 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) {
|
async function loadFor(a: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
@@ -64,17 +68,19 @@
|
|||||||
let hasStaleData = $derived(err != null && (health != null || summary != null));
|
let hasStaleData = $derived(err != null && (health != null || summary != null));
|
||||||
let showSkeleton = $derived(loading && !err && health == null && summary == null);
|
let showSkeleton = $derived(loading && !err && health == null && summary == null);
|
||||||
|
|
||||||
let badConnAccent = $derived.by((): 'danger' | 'success' => {
|
let uptimeSec = $derived(num(sys?.uptime_seconds));
|
||||||
const n = summary?.connections_bad_total;
|
let connTotal = $derived(num(summary?.connections_total));
|
||||||
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) return 'success';
|
let badTotal = $derived(num(summary?.connections_bad_total));
|
||||||
return 'danger';
|
let cfgUsers = $derived(num(summary?.configured_users));
|
||||||
});
|
let handshakeT = $derived(num(summary?.handshake_timeouts_total));
|
||||||
</script>
|
</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>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Сервер: обзор</h1>
|
||||||
<p class="font-mono text-sm text-muted-foreground">{alias}</p>
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Ключевые метрики и состояние Telemt для <span class="font-mono">{alias}</span>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
@@ -90,25 +96,12 @@
|
|||||||
partial={false}
|
partial={false}
|
||||||
>
|
>
|
||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
{#each Array.from({ length: 4 }) as _, i (i)}
|
{#each Array.from({ length: 5 }) as _, i (i)}
|
||||||
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
<Skeleton class="h-28 rounded-xl" />
|
||||||
<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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<Skeleton class="h-40 w-full rounded-lg" />
|
<Skeleton class="h-64 w-full rounded-lg" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{#if health?.status === 'ok'}
|
{#if health?.status === 'ok'}
|
||||||
@@ -120,35 +113,66 @@
|
|||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl">
|
<KpiStatCard
|
||||||
|
accent="violet"
|
||||||
|
label="Аптайм"
|
||||||
|
valueClass="text-xl"
|
||||||
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<ClockIcon class="size-5" aria-hidden="true" />
|
<ClockIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl">
|
<KpiStatCard
|
||||||
|
accent="info"
|
||||||
|
label="Соединения (всего)"
|
||||||
|
valueClass="text-xl"
|
||||||
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<PlugIcon class="size-5" aria-hidden="true" />
|
<PlugIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{summary?.connections_total ?? '—'}
|
{summary ? String(connTotal) : '—'}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl">
|
<KpiStatCard
|
||||||
|
accent="danger"
|
||||||
|
label="Плохие соединения"
|
||||||
|
valueClass="text-xl"
|
||||||
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<UnplugIcon class="size-5" aria-hidden="true" />
|
<ShieldAlertIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{summary?.connections_bad_total ?? '—'}
|
{summary ? String(badTotal) : '—'}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl">
|
<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()}
|
{#snippet icon()}
|
||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{summary?.configured_users ?? '—'}
|
{summary ? String(cfgUsers) : '—'}
|
||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if sys}
|
{#if sys}
|
||||||
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
<KpiPanelCard
|
||||||
|
accent="neutral"
|
||||||
|
title="Система"
|
||||||
|
description="Информация о версии, платформе и конфигурации ноды"
|
||||||
|
contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<CpuIcon class="size-5" aria-hidden="true" />
|
<CpuIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
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';
|
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||||
|
|
||||||
const alias = $derived(page.params.alias ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6">
|
<ServerPageHeader
|
||||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight">Configuration</h1>
|
title="Конфигурация"
|
||||||
<p class="text-sm text-muted-foreground">
|
subtitle="Конфигурация этой ноды не отдается через Control API"
|
||||||
Конфигурация Telemt на ноде <span class="font-mono text-foreground">{alias}</span> не доступна через API
|
showRefresh={false}
|
||||||
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
|
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<SettingsIcon class="size-5" aria-hidden="true" />
|
<SettingsIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/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
|
См. <a
|
||||||
class="text-primary underline"
|
class="text-primary underline"
|
||||||
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
|
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
|
||||||
|
|||||||
@@ -2,27 +2,21 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import {
|
import { ApiError, fetchMihomoJson, fetchMihomoMeta, mihomoUrl, mihomoWsUrl } from '$lib/api/client.js';
|
||||||
ApiError,
|
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
|
||||||
fetchMihomoJson,
|
|
||||||
fetchMihomoMeta,
|
|
||||||
mihomoPut,
|
|
||||||
mihomoUrl,
|
|
||||||
mihomoWsUrl
|
|
||||||
} from '$lib/api/client.js';
|
|
||||||
import type {
|
|
||||||
MihomoConnectionsResponse,
|
|
||||||
MihomoMetaResponse,
|
|
||||||
MihomoProxiesResponse,
|
|
||||||
MihomoProxyEntry
|
|
||||||
} from '$lib/api/mihomo-types.js';
|
|
||||||
import { formatBytes, formatRatePerSec } from '$lib/format.js';
|
import { formatBytes, formatRatePerSec } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge/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 * as Tabs from '$lib/components/ui/tabs/index.js';
|
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||||
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.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';
|
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
|
|
||||||
const alias = $derived(page.params.alias ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
@@ -36,7 +30,8 @@
|
|||||||
let downBps = $state(0);
|
let downBps = $state(0);
|
||||||
let totalUp = $state(0);
|
let totalUp = $state(0);
|
||||||
let totalDown = $state(0);
|
let totalDown = $state(0);
|
||||||
let memKB = $state(0);
|
/** Память в KiB (кибибайты): из WS Mihomo приходит в байтах — нормализуем в одном месте. */
|
||||||
|
let memKiB = $state(0);
|
||||||
let connTotal = $state(0);
|
let connTotal = $state(0);
|
||||||
let tcpN = $state(0);
|
let tcpN = $state(0);
|
||||||
let udpN = $state(0);
|
let udpN = $state(0);
|
||||||
@@ -48,19 +43,15 @@
|
|||||||
let topList = $state<{ name: string; n: number }[]>([]);
|
let topList = $state<{ name: string; n: number }[]>([]);
|
||||||
|
|
||||||
const MAX_POINTS = 72;
|
const MAX_POINTS = 72;
|
||||||
/** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */
|
|
||||||
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
|
|
||||||
|
|
||||||
|
/** Mihomo WS: `inuse` (/memory) и `memory` (/connections) — в байтах → KiB для карточки и графика. */
|
||||||
|
function mihomoWsMemoryToKiB(bytes: number): number {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||||
|
return bytes / 1024;
|
||||||
|
}
|
||||||
let lastTick = $state<number | null>(null);
|
let lastTick = $state<number | null>(null);
|
||||||
let pollTimer: ReturnType<typeof setInterval> | 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. */
|
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
|
||||||
function recordRates(
|
function recordRates(
|
||||||
now: number,
|
now: number,
|
||||||
@@ -102,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> {
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
@@ -203,8 +188,8 @@
|
|||||||
if (line) {
|
if (line) {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(line) as { inuse?: number };
|
const o = JSON.parse(line) as { inuse?: number };
|
||||||
memKB = Number(o.inuse) || 0;
|
memKiB = mihomoWsMemoryToKiB(Number(o.inuse) || 0);
|
||||||
histMem = [...histMem, memKB];
|
histMem = [...histMem, memKiB];
|
||||||
while (histMem.length > MAX_POINTS) histMem.shift();
|
while (histMem.length > MAX_POINTS) histMem.shift();
|
||||||
seen++;
|
seen++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -343,8 +328,8 @@
|
|||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(String(ev.data)) as { inuse?: number };
|
const o = JSON.parse(String(ev.data)) as { inuse?: number };
|
||||||
memKB = Number(o.inuse) || 0;
|
memKiB = mihomoWsMemoryToKiB(Number(o.inuse) || 0);
|
||||||
histMem = [...histMem, memKB];
|
histMem = [...histMem, memKiB];
|
||||||
while (histMem.length > MAX_POINTS) histMem.shift();
|
while (histMem.length > MAX_POINTS) histMem.shift();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -397,8 +382,8 @@
|
|||||||
totalDown = j.downloadTotal;
|
totalDown = j.downloadTotal;
|
||||||
}
|
}
|
||||||
if (typeof j.memory === 'number') {
|
if (typeof j.memory === 'number') {
|
||||||
memKB = j.memory;
|
memKiB = mihomoWsMemoryToKiB(j.memory);
|
||||||
histMem = [...histMem, memKB];
|
histMem = [...histMem, memKiB];
|
||||||
while (histMem.length > MAX_POINTS) histMem.shift();
|
while (histMem.length > MAX_POINTS) histMem.shift();
|
||||||
}
|
}
|
||||||
let t = 0,
|
let t = 0,
|
||||||
@@ -486,7 +471,7 @@
|
|||||||
totalDown = 0;
|
totalDown = 0;
|
||||||
upBps = 0;
|
upBps = 0;
|
||||||
downBps = 0;
|
downBps = 0;
|
||||||
memKB = 0;
|
memKiB = 0;
|
||||||
histUp = [];
|
histUp = [];
|
||||||
histDown = [];
|
histDown = [];
|
||||||
histMem = [];
|
histMem = [];
|
||||||
@@ -506,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(() => {
|
onDestroy(() => {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1>
|
<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>
|
</div>
|
||||||
<Tabs.Root bind:value={tab} class="w-fit shrink-0">
|
<Tabs.Root bind:value={tab} class="w-fit shrink-0">
|
||||||
<Tabs.List class="min-w-[220px] gap-0.5 p-1">
|
<Tabs.List class="min-w-[220px] gap-0.5 p-1">
|
||||||
@@ -622,14 +512,40 @@
|
|||||||
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
|
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
|
||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
<Skeleton class="mb-4 h-6 w-96 max-w-full" />
|
<Skeleton class="mb-4 h-6 w-96 max-w-full" />
|
||||||
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<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)}
|
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||||
<Skeleton class="h-24 rounded-lg" />
|
<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>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
{#each Array.from({ length: 6 }) as _, i (i)}
|
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||||
<Skeleton class="h-56 rounded-lg" />
|
<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-4 w-40" />
|
||||||
|
<Skeleton class="h-3 w-full max-w-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="h-56 px-4 pb-4 pt-0">
|
||||||
|
<Skeleton class="h-full w-full rounded-md" />
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -640,45 +556,43 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tab === 'overview' && !metaErr}
|
{#if tab === 'overview' && !metaErr}
|
||||||
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<Card.Root class="border-l-primary border-l-4 py-3">
|
<KpiStatCard accent="violet" label="Загрузка" footer="мгновенная скорость, /traffic" valueClass="text-xl">
|
||||||
<Card.Header class="pb-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Загрузка</Card.Description>
|
<ArrowBigUpIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="text-lg font-semibold">{formatRatePerSec(upBps)}</Card.Content>
|
{formatRatePerSec(upBps)}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root class="border-l-sky-500 border-l-4 py-3">
|
<KpiStatCard accent="info" label="Скачивание" footer="мгновенная скорость, /traffic" valueClass="text-xl">
|
||||||
<Card.Header class="pb-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Скачивание</Card.Description>
|
<ArrowBigDownIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="text-lg font-semibold">{formatRatePerSec(downBps)}</Card.Content>
|
{formatRatePerSec(downBps)}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root class="border-l-amber-500 border-l-4 py-3">
|
<KpiStatCard accent="warning" label="Всего загружено" footer="uploadTotal (снимок)" valueClass="text-xl">
|
||||||
<Card.Header class="pb-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Всего загружено</Card.Description>
|
<CloudUploadIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="text-lg font-semibold">{formatBytes(totalUp)}</Card.Content>
|
{formatBytes(totalUp)}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root class="border-l-cyan-500 border-l-4 py-3">
|
<KpiStatCard accent="teal" label="Всего скачано" footer="downloadTotal (снимок)" valueClass="text-xl">
|
||||||
<Card.Header class="pb-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Всего скачано</Card.Description>
|
<CloudDownloadIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="text-lg font-semibold">{formatBytes(totalDown)}</Card.Content>
|
{formatBytes(totalDown)}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root class="border-l-emerald-500 border-l-4 py-3">
|
<KpiStatCard accent="info" label="Активные соединения" footer="список /connections" valueClass="text-xl">
|
||||||
<Card.Header class="pb-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Активные соединения</Card.Description>
|
<PlugIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="text-lg font-semibold">{connTotal}</Card.Content>
|
{connTotal}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root class="border-l-violet-500 border-l-4 py-3">
|
<KpiStatCard accent="neutral" label="Память" footer="/memory или поле memory в WS" valueClass="text-xl">
|
||||||
<Card.Header class="pb-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Память</Card.Description>
|
<MemoryStickIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="text-lg font-semibold">
|
{memKiB > 0 ? `${(memKiB / 1024).toFixed(1)} MiB` : '—'}
|
||||||
{memKB > 0 ? `${(memKB / 1024).toFixed(1)} MiB` : '—'}
|
</KpiStatCard>
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
</div>
|
||||||
<MihomoOverviewCharts
|
<MihomoOverviewCharts
|
||||||
histUp={histUp}
|
histUp={histUp}
|
||||||
@@ -696,84 +610,5 @@
|
|||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|
||||||
{#if tab === 'proxies'}
|
{#if tab === 'proxies'}
|
||||||
<div class="mb-4 flex justify-end">
|
<MihomoProxyGroups alias={alias} />
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
|
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
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 BuildingIcon from '@lucide/svelte/icons/building-2';
|
||||||
import BracesIcon from '@lucide/svelte/icons/braces';
|
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 { 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 * as Table from '$lib/components/ui/table/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
||||||
|
|
||||||
const alias = $derived(page.params.alias ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
|
|
||||||
@@ -56,44 +57,32 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<ServerPageHeader title="Состояние" {loading} onRefresh={load} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if err}
|
<ServerQueryState {loading} {err} isEmpty={false}>
|
||||||
<Alert variant="destructive">
|
{#snippet children()}
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
|
||||||
<AlertDescription>{err}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
{:else if loading}
|
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
|
||||||
{:else}
|
|
||||||
<div class="mb-4 flex flex-wrap gap-2">
|
<div class="mb-4 flex flex-wrap gap-2">
|
||||||
{#if gates}
|
{#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">Приём новых: {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">Условный 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">Резерв me2dc: {onOff(gates.me2dc_fallback_enabled)}</Badge>
|
||||||
<Badge variant="secondary" class="px-3 py-1">ME ready: {onOff(gates.me_runtime_ready)}</Badge>
|
<Badge variant="secondary" class="px-3 py-1">ME готов: {onOff(gates.me_runtime_ready)}</Badge>
|
||||||
<Badge variant="outline" class="px-3 py-1">middle proxy: {onOff(gates.use_middle_proxy)}</Badge>
|
<Badge variant="outline" class="px-3 py-1">Промежуточный прокси: {onOff(gates.use_middle_proxy)}</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
|
{#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()}
|
{#snippet icon()}
|
||||||
<BuildingIcon class="size-5" aria-hidden="true" />
|
<BuildingIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>DC</Table.Head>
|
<Table.Head>DC</Table.Head>
|
||||||
<Table.Head class="text-right">RTT</Table.Head>
|
<Table.Head class="text-right">RTT</Table.Head>
|
||||||
<Table.Head class="text-right">Writers</Table.Head>
|
<Table.Head class="text-right">Писатели</Table.Head>
|
||||||
<Table.Head class="text-right">Coverage</Table.Head>
|
<Table.Head class="text-right">Покрытие</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -114,18 +103,14 @@
|
|||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
{:else if dcs?.reason}
|
{:else if dcs?.reason}
|
||||||
<Alert class="mb-4">
|
<Alert class="mb-4">
|
||||||
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
|
<AlertDescription>Статус ДЦ: {dcs.reason}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<KpiPanelCard accent="violet" title="ME pool state" description="Сырой JSON" contentClass="">
|
<ServerJsonCard accent="violet" title="ME pool state" description="Сырой JSON" payload={pool} preClass="max-h-[480px]">
|
||||||
{#snippet icon()}
|
{#snippet iconSnippet()}
|
||||||
<BracesIcon class="size-5" aria-hidden="true" />
|
<BracesIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify(
|
</ServerJsonCard>
|
||||||
pool,
|
{/snippet}
|
||||||
null,
|
</ServerQueryState>
|
||||||
2
|
|
||||||
)}</pre>
|
|
||||||
</KpiPanelCard>
|
|
||||||
{/if}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
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 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 ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
|
|
||||||
@@ -39,30 +38,16 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<ServerPageHeader title="Безопасность" {loading} onRefresh={load} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if err}
|
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Endpoint вернул пустой ответ.">
|
||||||
<Alert variant="destructive">
|
{#snippet children()}
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
{#if data}
|
||||||
<AlertDescription>{err}</AlertDescription>
|
<ServerJsonCard accent="danger" title="Профиль безопасности" description="POST /security/posture" payload={data}>
|
||||||
</Alert>
|
{#snippet iconSnippet()}
|
||||||
{: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" />
|
<ShieldIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<pre class="overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
|
</ServerJsonCard>
|
||||||
data,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}</pre>
|
|
||||||
</KpiPanelCard>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ServerQueryState>
|
||||||
@@ -3,10 +3,9 @@
|
|||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
|
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
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 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 ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
|
|
||||||
@@ -40,22 +39,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<ServerPageHeader title="Обновление" {loading} onRefresh={load} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if err}
|
<ServerQueryState {loading} {err} isEmpty={!sys} emptyDescription="Информация о бинарнике не получена.">
|
||||||
<Alert variant="destructive">
|
{#snippet children()}
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
{#if sys}
|
||||||
<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">
|
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<PackageIcon class="size-5" aria-hidden="true" />
|
<PackageIcon class="size-5" aria-hidden="true" />
|
||||||
@@ -66,3 +54,5 @@
|
|||||||
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
|
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
|
||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ServerQueryState>
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
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 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 ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
|
|
||||||
@@ -39,30 +38,22 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<ServerPageHeader title="Апстримы и ДЦ" {loading} onRefresh={load} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if err}
|
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Статистика upstream-ов пока не доступна.">
|
||||||
<Alert variant="destructive">
|
{#snippet children()}
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
{#if data}
|
||||||
<AlertDescription>{err}</AlertDescription>
|
<ServerJsonCard
|
||||||
</Alert>
|
accent="info"
|
||||||
{:else if loading}
|
title="Апстримы и ДЦ"
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
description="stats/upstreams"
|
||||||
{:else if data}
|
payload={data}
|
||||||
<KpiPanelCard accent="info" title="Upstreams & DCs" description="stats/upstreams" contentClass="p-0">
|
preClass="max-h-[70vh]"
|
||||||
{#snippet icon()}
|
>
|
||||||
|
{#snippet iconSnippet()}
|
||||||
<Share2Icon class="size-5" aria-hidden="true" />
|
<Share2Icon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<pre class="max-h-[70vh] overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
|
</ServerJsonCard>
|
||||||
data,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}</pre>
|
|
||||||
</KpiPanelCard>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ServerQueryState>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<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">
|
<div class="flex gap-2">
|
||||||
<Button onclick={() => (createOpen = true)} disabled={readOnly}>
|
<Button onclick={() => (createOpen = true)} disabled={readOnly}>
|
||||||
<PlusIcon class="mr-1 size-4" />
|
<PlusIcon class="mr-1 size-4" />
|
||||||
@@ -232,11 +232,11 @@
|
|||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Имя</Table.Head>
|
<Table.Head>Имя</Table.Head>
|
||||||
<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">IP</Table.Head>
|
||||||
<Table.Head class="text-right">Трафик</Table.Head>
|
<Table.Head class="text-right">Трафик</Table.Head>
|
||||||
<Table.Head class="w-[100px]"></Table.Head>
|
<Table.Head class="w-[100px]"></Table.Head>
|
||||||
@@ -248,9 +248,9 @@
|
|||||||
<Table.Cell class="font-medium">{u.username}</Table.Cell>
|
<Table.Cell class="font-medium">{u.username}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div class="flex flex-wrap gap-1">
|
<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)}>
|
<Button variant="outline" size="xs" class="h-7" onclick={() => copy(link)}>
|
||||||
TLS <CopyIcon class="size-3" />
|
TLS {idx + 1} <CopyIcon class="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +269,8 @@
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onclick={() => openEdit(u)}
|
onclick={() => openEdit(u)}
|
||||||
|
aria-label="Редактировать пользователя"
|
||||||
|
title="Редактировать пользователя"
|
||||||
>
|
>
|
||||||
<PencilIcon class="size-4" />
|
<PencilIcon class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,6 +279,8 @@
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onclick={() => (deleteUserRow = u)}
|
onclick={() => (deleteUserRow = u)}
|
||||||
|
aria-label="Удалить пользователя"
|
||||||
|
title="Удалить пользователя"
|
||||||
>
|
>
|
||||||
<TrashIcon class="size-4 text-destructive" />
|
<TrashIcon class="size-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -325,15 +329,15 @@
|
|||||||
<Input id="eq" bind:value={editQuota} type="number" min="0" />
|
<Input id="eq" bind:value={editQuota} type="number" min="0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<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" />
|
<Input id="em" bind:value={editMaxIp} type="number" min="0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<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" />
|
<Input id="et" bind:value={editMaxTcp} type="number" min="0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<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" />
|
<Input id="ex" bind:value={editExp} class="font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
{#if formErr}
|
{#if formErr}
|
||||||
|
|||||||
@@ -6,10 +6,23 @@
|
|||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatMiB } from '$lib/format.js';
|
import { formatMiB } from '$lib/format.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.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 Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.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 UsersIcon from '@lucide/svelte/icons/users';
|
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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/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 FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
|
||||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
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';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -99,9 +117,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAliasIfChanged(nextAlias: string) {
|
async function handleAliasFilterChange(nextAlias: string) {
|
||||||
const currentAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
aliasFilter = nextAlias;
|
||||||
if (nextAlias === currentAlias) return;
|
const currentUrlAlias = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||||
|
if (nextAlias === currentUrlAlias) return;
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
if (nextAlias === 'all') q.delete('aliases');
|
if (nextAlias === 'all') q.delete('aliases');
|
||||||
else q.set('aliases', nextAlias);
|
else q.set('aliases', nextAlias);
|
||||||
@@ -111,6 +130,7 @@
|
|||||||
noScroll: true,
|
noScroll: true,
|
||||||
keepFocus: true
|
keepFocus: true
|
||||||
});
|
});
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
let queryKey = $derived(page.url.searchParams.toString());
|
let queryKey = $derived(page.url.searchParams.toString());
|
||||||
@@ -125,11 +145,6 @@
|
|||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
void applyAliasIfChanged(aliasFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
let staleSeconds = $derived.by(() =>
|
let staleSeconds = $derived.by(() =>
|
||||||
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
|
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)
|
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.sort((a, b) => a.localeCompare(b));
|
||||||
availableAliases = aliases;
|
availableAliases = aliases;
|
||||||
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
|
|
||||||
} catch {
|
} catch {
|
||||||
availableAliases = [];
|
availableAliases = [];
|
||||||
}
|
}
|
||||||
@@ -162,23 +176,108 @@
|
|||||||
|
|
||||||
const usersPerPage = 25;
|
const usersPerPage = 25;
|
||||||
let usersPage = $state(1);
|
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 hasStaleData = $derived(err != null && rows.length > 0);
|
||||||
let showSkeleton = $derived(loading && rows.length === 0 && !err);
|
let showSkeleton = $derived(loading && rows.length === 0 && !err);
|
||||||
let isDataEmpty = $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(() => {
|
let pagedRows = $derived.by(() => {
|
||||||
const start = (usersPage - 1) * usersPerPage;
|
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(() => {
|
$effect(() => {
|
||||||
aliasFilter;
|
aliasFilter;
|
||||||
|
userSearch;
|
||||||
|
usersViewTab;
|
||||||
usersPage = 1;
|
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(() => {
|
$effect(() => {
|
||||||
if (usersPage > pageCount) usersPage = pageCount;
|
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) {
|
function copy(text: string) {
|
||||||
const value = String(text ?? '');
|
const value = String(text ?? '');
|
||||||
const done = () => toast.success('Скопировано в буфер');
|
const done = () => toast.success('Скопировано в буфер');
|
||||||
@@ -199,6 +298,31 @@
|
|||||||
void goto(`/users/${encodeURIComponent(u)}`);
|
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) {
|
function fallbackCopy(value: string) {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
@@ -219,7 +343,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Пользователи (флот)</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Пользователи (флот)</h1>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
@@ -238,6 +362,10 @@
|
|||||||
>Ссылки tg://proxy</Label
|
>Ссылки tg://proxy</Label
|
||||||
>
|
>
|
||||||
</div>
|
</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}>
|
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -246,7 +374,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<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()} />
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,32 +413,172 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<KpiPanelCard
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
accent="info"
|
<KpiStatCard accent="violet" label="Пользователей всего" footer="в текущем фильтре" valueClass="text-xl">
|
||||||
title="Список пользователей"
|
|
||||||
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
|
|
||||||
contentClass="p-0"
|
|
||||||
>
|
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
{/snippet}
|
{/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.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<Table.Row class="border-b border-border/80 hover:bg-transparent">
|
||||||
<Table.Head>Имя</Table.Head>
|
<Table.Head class="w-10 px-2">
|
||||||
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head>
|
{#if pageUsernames.length > 0}
|
||||||
<Table.Head class="text-right">Трафик</Table.Head>
|
<Checkbox
|
||||||
<Table.Head class="text-right">Активных IP</Table.Head>
|
checked={headerCheckboxChecked}
|
||||||
<Table.Head class="hidden sm:table-cell">Истекает</Table.Head>
|
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.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each pagedRows as row (row.username ?? '')}
|
{#each pagedRows as row (row.username ?? '')}
|
||||||
|
{@const uname = row.username ?? ''}
|
||||||
<Table.Row
|
<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}
|
tabindex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`Открыть пользователя ${row.username ?? ''}`}
|
aria-label={`Открыть пользователя ${uname}`}
|
||||||
onclick={() => openUser(row.username)}
|
onclick={() => openUser(row.username)}
|
||||||
onkeydown={(e: KeyboardEvent) => {
|
onkeydown={(e: KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -315,9 +587,25 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Cell class="font-medium">{row.username}</Table.Cell>
|
<Table.Cell class="w-10 px-2 align-middle" onclick={(e) => e.stopPropagation()}>
|
||||||
<Table.Cell class="hidden sm:table-cell">
|
{#if uname}
|
||||||
<div class="flex flex-wrap gap-1">
|
<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)}
|
{#each row.links?.tls ?? [] as link (link)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -346,30 +634,95 @@
|
|||||||
<CopyIcon class="size-3 opacity-60" />
|
<CopyIcon class="size-3 opacity-60" />
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if !(row.links?.tls?.length) && !(row.links?.secure?.length)}
|
||||||
|
<span class="text-xs text-muted-foreground">—</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
|
<Table.Cell class="py-3 text-right align-middle tabular-nums">
|
||||||
<Table.Cell class="text-right text-sm font-mono 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="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}
|
{row.active_unique_ips ?? 0}
|
||||||
{#if row.max_unique_ips != null}
|
{#if row.max_unique_ips != null}
|
||||||
<span class="text-muted-foreground">/{row.max_unique_ips}</span>
|
<span class="text-muted-foreground">/{row.max_unique_ips}</span>
|
||||||
{/if}
|
{/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>
|
||||||
<Table.Cell class="hidden sm:table-cell text-sm text-muted-foreground">
|
<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() : '—'}
|
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
|
||||||
</Table.Cell>
|
</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
{#if rows.length > usersPerPage}
|
</ScrollArea>
|
||||||
|
{#if processedRows.length > usersPerPage}
|
||||||
<div
|
<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>
|
<span>
|
||||||
{rows.length === 0
|
{processedRows.length === 0
|
||||||
? '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>
|
</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -387,6 +740,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</KpiPanelCard>
|
</div>
|
||||||
|
</DataTableCard>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
@@ -205,8 +205,8 @@
|
|||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted">
|
||||||
<Table.Row>
|
<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">IP</Table.Head>
|
||||||
<Table.Head class="text-xs sm:text-sm">
|
<Table.Head class="text-xs sm:text-sm">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
Reference in new issue
Block a user