Compare commits
80
Commits
23f71378d3
...
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 | ||
|
|
c3a0a771a3 | ||
|
|
49f36d2b8b | ||
|
|
7e88cfcb3e | ||
|
|
65d153df99 | ||
|
|
77a6c1e31e | ||
|
|
1686840b0e | ||
|
|
6c1f8a8a37 | ||
|
|
309b4a0e64 | ||
|
|
cb6ecd46b1 | ||
|
|
8b7233159a | ||
|
|
82dd7dd1bf | ||
|
|
06141dbad0 | ||
|
|
f951e7ef8d | ||
|
|
50fe3df7e6 | ||
|
|
b1839c5ebe | ||
|
|
0aa440477e | ||
|
|
b8bb5bede8 | ||
|
|
ed785cb8f3 | ||
|
|
a24fc90b39 | ||
|
|
2d7b06260e | ||
|
|
bbd9619290 | ||
|
|
992eebd842 | ||
|
|
46d080e70a | ||
|
|
141d900594 | ||
|
|
ce2a7ed43e | ||
|
|
4be07fc242 | ||
|
|
aaacb80f9a | ||
|
|
309750fd25 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# telemt-api
|
||||
|
||||
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`.
|
||||
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md) (в т.ч. `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)
|
||||
|
||||
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
# Пример для docker compose: без whitelist (только для локальной проверки).
|
||||
# На Linux в docker-compose.yml задано extra_hosts host.docker.internal:host-gateway.
|
||||
# Без Mihomo: как ниже. С Mihomo в той же сети — см. config.example.yaml и docs/GATEWAY_RUN.md.
|
||||
listen: ":8080"
|
||||
allow_all: true
|
||||
whitelist_cidrs: []
|
||||
trusted_proxies: []
|
||||
# UI отдаётся с того же порта, что и API (образ шлюза) — отдельный CORS для панели не нужен.
|
||||
# UI отдаётся с тем же портом, что и API (образ шлюза) — отдельный CORS для панели не нужен.
|
||||
servers:
|
||||
- alias: main_srv
|
||||
base_url: http://host.docker.internal:9091
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
listen: ":8080"
|
||||
|
||||
# Диагностика: при 502 от прокси в JSON добавляется error.detail с текстом ошибки RoundTrip (TLS, DNS, таймаут).
|
||||
# Включайте только во внутренней сети — строка может содержать хосты/порты upstream.
|
||||
# expose_upstream_errors: true
|
||||
|
||||
# If true, IP whitelist is not enforced (development only).
|
||||
allow_all: false
|
||||
|
||||
@@ -39,6 +43,12 @@ trusted_proxies: []
|
||||
# - gt2
|
||||
# cache_ttl_ms: 2000
|
||||
|
||||
# Радар DC Telegram: прокси к radar.telemt.top и TCP-пинг DC с хоста шлюза (см. README).
|
||||
# radar:
|
||||
# statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses"
|
||||
# http_timeout_ms: 30000
|
||||
# ping_from: "stats.example.com"
|
||||
|
||||
# Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен)
|
||||
# geoip:
|
||||
# enabled: true
|
||||
@@ -52,6 +62,10 @@ servers:
|
||||
base_url: http://127.0.0.1:9091
|
||||
path_prefix: /v1
|
||||
# authorization_env: TELEMT_API_AUTH
|
||||
# Mihomo: тот же compose-сервис (container_name: mihomo, сеть proxy-net, external-controller :9090).
|
||||
# У процесса/контейнера gateway в environment: TELEMT_MIHOMO_AUTH=Bearer ${CLASH_SECRET} (как у mihomo).
|
||||
mihomo_base_url: http://172.20.0.2:9090
|
||||
mihomo_authorization_env: Bearer Ifwrbqifwrbq1995
|
||||
|
||||
# ivx: HTTPS + nginx location /api/ → Telemt; base_url должен заканчиваться на /api/
|
||||
- alias: gt1
|
||||
|
||||
@@ -8,6 +8,13 @@ services:
|
||||
- ./config.compose.yaml:/etc/telemt-gateway/config.yaml:ro
|
||||
environment:
|
||||
CONFIG_PATH: /etc/telemt-gateway/config.yaml
|
||||
# Вместе с Mihomo в config.yaml: TELEMT_MIHOMO_AUTH: Bearer ${CLASH_SECRET}
|
||||
# Доступ к Telemt на хосте Linux (host.docker.internal в config.compose.yaml):
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# С контейнером mihomo в сети proxy-net:
|
||||
# networks:
|
||||
# - proxy-net
|
||||
# networks:
|
||||
# proxy-net:
|
||||
# external: true
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- Большинство маршрутов агрегации используют **`GET /v1/stats/users`** на каждом сервере из конфигурации.
|
||||
- **`GET /api/agg/fleet-status`** дополнительно вызывает на каждом upstream **`GET /v1/health`** и **`GET /v1/system/info`** (параллельно по серверам).
|
||||
- **`GET /api/agg/radar-telemt-dcs`** — на каждом upstream параллельно **`GET /v1/stats/dcs`** (снимок ME / DC для панели «Радар DC»); см. [API.md](API.md) про `minimal_runtime_enabled` и поля `DcStatusData`.
|
||||
|
||||
**Единицы трафика в агрегатах:** поля `*_megabytes` — это **двоичные мегабайты (MiB)**, 1 MiB = 1024² октетов (как у Telemt в ответе считаются октеты, шлюз делит на MiB для удобства).
|
||||
|
||||
@@ -33,6 +34,7 @@
|
||||
| GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). |
|
||||
| GET | `/api/agg/user/{username}` | Один пользователь в том же формате, что элементы `/api/agg/users` (без списка всех). Имя в пути: `[A-Za-z0-9_.-]+`. Ответ **`404`**, если пользователь не найден ни на одном успешном upstream. |
|
||||
| GET | `/api/agg/fleet-status` | По каждому алиасу: параллельно health + system/info; в `data.servers[]` — статусы подзапросов и тела `health` / `system_info` при успехе. См. [AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml). |
|
||||
| GET | `/api/agg/radar-telemt-dcs` | По каждому алиасу: параллельно `GET /v1/stats/dcs`; в `data.servers[]` — `alias`, `ok`, при успехе объект `data` (поля `middle_proxy_enabled`, `reason`, `dcs[]` с `dc`, `coverage_pct`, `rtt_ms` и т.д.). |
|
||||
| GET | `/api/agg/incidents` | Нормализованный snapshot инцидентов для triage-панели: `critical/warning/info`, `affected_aliases`, рекомендуемые `actions` (runbook/deep links), счётчики по severity. |
|
||||
|
||||
Все методы — **GET**; действует тот же whitelist, что и для остального API шлюза.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
9. [Проверка](#проверка)
|
||||
10. [Обновление и CI/CD](#обновление-и-cicd)
|
||||
11. [Устранение неполадок](#устранение-неполадок)
|
||||
12. [Mihomo (external-controller)](#mihomo-external-controller) — при **400** на REST: [отладка](#mihomo-debug-400)
|
||||
|
||||
## Назначение
|
||||
|
||||
@@ -77,12 +78,108 @@ servers:
|
||||
|
||||
Значение должно **точно** совпадать с настроенным в Telemt `auth_header` (см. [API.md](API.md)).
|
||||
|
||||
### Mihomo (external-controller)
|
||||
|
||||
Опционально для каждого `servers[]` можно включить проксирование **Mihomo** (Clash Meta) API: в Web UI появится раздел **«Mihomo»** для выбранного alias.
|
||||
|
||||
- Клиент (браузер) обращается к шлюзу: `GET /api/{alias}/mihomo/proxies`, WebSocket `…/mihomo/traffic` и т.д.
|
||||
- Шлюз проксирует на `{mihomo_base_url}/proxies`, `…/traffic` и т.д. с заголовком `Authorization` из переменной окружения (секрет **не** попадает в фронтенд).
|
||||
- Служебный ответ: `GET /api/{alias}/mihomo/meta` — JSON с полем `controller_base` (без учётных данных) для строки «Подключено к: …» в панели.
|
||||
|
||||
В **config.yaml** задаются только параметры **шлюза** (`mihomo_base_url` / `mihomo_base_url_env`, `mihomo_authorization_env`). Сервис контейнера Mihomo (`build`, `CLASH_SECRET`, сеть `proxy-net` и т.д.) описывается в вашем Docker Compose отдельно; связка URL и переменных для gateway — ниже и в [docker-compose.yml](../docker-compose.yml).
|
||||
|
||||
Поля в конфиге:
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| `mihomo_base_url` | Базовый URL контроллера, например `http://mihomo:9090` (порт контроллера по умолчанию в контейнере Mihomo — **9090**). |
|
||||
| `mihomo_base_url_env` | Имя переменной окружения; если задано и значение **непустое**, URL контроллера берётся из `os.Getenv` при старте (удобно в Docker без хардкода IP). Если env пустой, используется `mihomo_base_url`. |
|
||||
| `mihomo_authorization_env` | Имя env: **полное** значение заголовка `Authorization` (например `Bearer <secret>`), как у `authorization_env` для Telemt. Должно совпадать с секретом на стороне Mihomo (`secret` / `CLASH_SECRET` в конфиге ядра). |
|
||||
|
||||
У **нескольких** записей `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 при старте шлюза.
|
||||
- Контейнер **gateway** должен иметь **сетевую связность** с контроллером Mihomo (лучше одна пользовательская Docker-сеть; имя сервиса `http://mihomo:9090` предпочтительнее статического IP). Публиковать порт **9090** на хост не обязательно: браузер ходит в шлюз, шлюз — в контейнер Mihomo по overlay-сети.
|
||||
- Пример `environment` для compose (секреты не в git — через `.env`):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gateway:
|
||||
environment:
|
||||
MIHOMO_CONTROLLER_URL: http://mihomo:9090
|
||||
TELEMT_MIHOMO_AUTH: Bearer ${CLASH_SECRET}
|
||||
```
|
||||
|
||||
и в `config.yaml` для нужного сервера: `mihomo_base_url_env: MIHOMO_CONTROLLER_URL`, `mihomo_authorization_env: TELEMT_MIHOMO_AUTH`.
|
||||
|
||||
За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`. Панель Mihomo тянет трафик и память **потоковым HTTP** (не WS); при задержке или «залипании» графиков за nginx включите для upstream шлюза `proxy_buffering off` (или эквивалент), чтобы не буферизовать длинный ответ.
|
||||
|
||||
<a id="mihomo-debug-400"></a>
|
||||
|
||||
#### Отладка 400 на маршрутах Mihomo (`/api/{alias}/mihomo/…`)
|
||||
|
||||
Если **напрямую** к контроллеру всё ок (`curl -H "Authorization: Bearer …" http://mihomo:9090/version` → JSON), а **через шлюз** те же пути дают **400** и в логах шлюза `duration_ms` около **1** — проблема в **сборке исходящего HTTP на шлюзе**, а не в секрете Mihomo.
|
||||
|
||||
Что зафиксировано в коде и не стоит «упрощать» назад без причины:
|
||||
|
||||
| Что | Зачем |
|
||||
|-----|--------|
|
||||
| **REST** (GET `/version`, `/proxies`, `/connections`, POST к API контроллера и т.д.) | Тот же путь, что и прокси к Telemt: `http.NewRequest` + `RoundTrip` (`internal/proxy/forward.go`, общая логика `newAliasForward`). Так совпадает с рабочим `curl` к upstream. |
|
||||
| **Не** единый `httputil.ReverseProxy` на весь Mihomo | Для обычного HTTP `ReverseProxy` нередко даёт **400** у строгих upstream при том, что прямой запрос работает. |
|
||||
| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** использует нативный **WebSocket** к шлюзу для метрик и параллельно короткие **HTTP GET** (резерв); шлюз подставляет `Authorization` к Mihomo, токен в браузер не передаётся. |
|
||||
| Заголовок **Host** | Не пересылается с клиента; на upstream уходит authority из `mihomo_base_url` (как для Telemt и `base_url`). |
|
||||
|
||||
Проверки конфигурации:
|
||||
|
||||
- `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[]`, если такой задан.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание |
|
||||
|----------------|----------|
|
||||
| `CONFIG_PATH` | Путь к YAML внутри контейнера. По умолчанию: `/etc/telemt-gateway/config.yaml`. |
|
||||
| `TELEMT_API_AUTH` | Пример: секрет для `authorization_env` в конфиге (имя может быть любым). |
|
||||
| `MIHOMO_CONTROLLER_URL` | Пример: URL для `mihomo_base_url_env` (если используете в конфиге). |
|
||||
| `TELEMT_MIHOMO_AUTH` | Пример: `Bearer …` для `mihomo_authorization_env` (если используете). |
|
||||
|
||||
## Готовый образ из registry
|
||||
|
||||
@@ -187,6 +284,7 @@ docker compose down
|
||||
- **Nginx с `location /api/` и `proxy_pass http://…:9091/;` (со слэшем в конце)** на бэкенд уходит путь **без** префикса `/api/` (например запрос к nginx `GET /api/v1/users` превращается в `GET /v1/users` на Telemt). Шлюз при `base_url: https://gt2.example/api/` должен запрашивать именно **`/api/v1/…`** на стороне nginx. Если в `base_url` нет пути `/api/` (только `https://gt2.example`), шлюз обратится к `https://gt2.example/v1/…` — часто это **не** попадает в `location /api/`, и nginx отдаёт **чужой vhost / заглушку**. Задавайте `base_url` с завершающим слэшем: `https://gt2.example/api/`.
|
||||
- **Заголовок `Host`**: шлюз выставляет `Host` равным хосту из `base_url` (как у обычного клиента к этому имени). Если после обновления образа проблема остаётся, с хоста шлюза проверьте: `curl -sv -o /dev/null https://gt2…/api/v1/health` и сравните с запросом через шлюз.
|
||||
- **`400` на `/api/{alias}/…` при локальном `base_url` (например `http://172.20.0.3:9091`), хотя `curl` к `:9091/v1/…` даёт `200`**: частая причина — **несовпадение заголовка `Host`**: браузер шлёт `Host: публичное_имя:8888`, а при прямом `curl` к IP в `Host` попадает `172.20.0.3:9091`. Строгий upstream (часто hyper/Rust) отвечает `400`, если `Host` не совпадает с ожидаемым authority. Шлюз при проксировании **не пересылает** клиентский `Host` и выставляет authority из `base_url` (как серверные запросы агрегатора). Убедитесь также, что в YAML **нет пробела/переноса** в конце `base_url` (поля обрезаются `TrimSpace`), и в URL нет лишнего `/api//…` (путь под `/api` нормализуется).
|
||||
- **`400` только на `/api/{alias}/mihomo/…`, при этом прямой `curl` к контроллеру Mihomo ок**: см. [Отладка 400 (Mihomo)](#mihomo-debug-400) в разделе [Mihomo](#mihomo-external-controller) — REST к Mihomo идёт через тот же механизм, что и к Telemt; не путать с отдельным WebSocket-прокси.
|
||||
- **Список пользователей через шлюз**: запрос **`GET` или `HEAD`** на **`/api/{alias}/users`** шлюз перенаправляет на upstream **`GET/HEAD /v1/stats/users`** (как и агрегатор). Так совместимы сборки Telemt, где прямой **`GET /v1/users`** даёт ошибку (например `400`), а **`/v1/stats/users`** работает. **`POST /api/{alias}/users`** (создание) и **`GET /api/{alias}/users/{username}`** по-прежнему идут на **`/v1/users`** и **`/v1/users/{username}`**. Явный путь **`/api/{alias}/stats/users`** не меняется. См. [API.md](API.md).
|
||||
- **`docker pull`: `unauthorized` / `denied`**: выполните `docker login git.shts.su` с учётной записью Gitea и PAT с **`read:package`**.
|
||||
- **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`.
|
||||
|
||||
@@ -3,7 +3,22 @@ module github.com/telemt/telemt-api
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/oschwald/geoip2-golang v1.11.0
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
|
||||
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,69 @@
|
||||
package aggregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// effectiveConnectionsBad возвращает значение для порогов инцидентов: прирост от последнего сброса.
|
||||
func (h *Handler) effectiveConnectionsBad(alias string, current uint64) uint64 {
|
||||
h.badConnBaselineMu.Lock()
|
||||
defer h.badConnBaselineMu.Unlock()
|
||||
b, ok := h.badConnBaseline[alias]
|
||||
if !ok {
|
||||
return current
|
||||
}
|
||||
if current < b {
|
||||
// Счётчик на ноде уменьшился (перезапуск и т.п.) — старая отметка неприменима.
|
||||
delete(h.badConnBaseline, alias)
|
||||
return current
|
||||
}
|
||||
return current - b
|
||||
}
|
||||
|
||||
func (h *Handler) setBadConnectionBaseline(alias string, snapshot uint64) {
|
||||
h.badConnBaselineMu.Lock()
|
||||
defer h.badConnBaselineMu.Unlock()
|
||||
if h.badConnBaseline == nil {
|
||||
h.badConnBaseline = make(map[string]uint64)
|
||||
}
|
||||
h.badConnBaseline[alias] = snapshot
|
||||
}
|
||||
|
||||
// HandleResetBadConnectionsBaseline — POST /api/agg/bad-connections/reset
|
||||
// Сохраняет текущий connections_bad_total по каждой выбранной ноде как нулевую отметку для алертов.
|
||||
func (h *Handler) HandleResetBadConnectionsBaseline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
writeBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type row struct {
|
||||
Alias string `json:"alias"`
|
||||
OK bool `json:"ok"`
|
||||
Baseline uint64 `json:"baseline,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
rows := make([]row, 0, len(aliases))
|
||||
partial := false
|
||||
for _, alias := range aliases {
|
||||
data, meta := FetchTelemtGET[statsSummaryLite](ctx, h.Client, h.Parsed, alias, "stats/summary")
|
||||
if !meta.OK {
|
||||
partial = true
|
||||
rows = append(rows, row{Alias: alias, OK: false, Error: meta.Error})
|
||||
continue
|
||||
}
|
||||
h.setBadConnectionBaseline(alias, data.ConnectionsBadTotal)
|
||||
rows = append(rows, row{Alias: alias, OK: true, Baseline: data.ConnectionsBadTotal})
|
||||
}
|
||||
writeAggOK(w, partial, map[string]any{"servers": rows})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package aggregate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEffectiveConnectionsBad(t *testing.T) {
|
||||
h := &Handler{}
|
||||
|
||||
if got := h.effectiveConnectionsBad("a", 500); got != 500 {
|
||||
t.Fatalf("without baseline want 500, got %d", got)
|
||||
}
|
||||
|
||||
h.setBadConnectionBaseline("a", 400)
|
||||
if got := h.effectiveConnectionsBad("a", 900); got != 500 {
|
||||
t.Fatalf("with baseline 400 want delta 500, got %d", got)
|
||||
}
|
||||
|
||||
h.setBadConnectionBaseline("b", 1000)
|
||||
if got := h.effectiveConnectionsBad("b", 500); got != 500 {
|
||||
t.Fatalf("after counter drop want raw value 500, got %d", got)
|
||||
}
|
||||
if _, ok := h.badConnBaseline["b"]; ok {
|
||||
t.Fatal("baseline for b should be cleared when current < baseline")
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ type Handler struct {
|
||||
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
|
||||
// badConnBaseline хранит снимок connections_bad_total на момент сброса в UI;
|
||||
// инциденты bad_connections_* считаются по приросту от этой отметки (в памяти процесса шлюза).
|
||||
badConnBaselineMu sync.Mutex
|
||||
badConnBaseline map[string]uint64
|
||||
}
|
||||
|
||||
// ResolveAliasesForLive resolves aliases for external endpoints (SSE/live).
|
||||
@@ -113,6 +118,8 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, sub string) {
|
||||
h.handleUsers(w, r)
|
||||
case sub == "fleet-status":
|
||||
h.handleFleetStatus(w, r)
|
||||
case sub == "radar-telemt-dcs":
|
||||
h.handleRadarTelemtDcs(w, r)
|
||||
case sub == "incidents":
|
||||
h.handleIncidents(w, r)
|
||||
case strings.HasPrefix(sub, "user/"):
|
||||
@@ -275,6 +282,25 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRadarTelemtDcs(w http.ResponseWriter, r *http.Request) {
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
writeBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
data := FetchRadarTelemtDcs(ctx, h.Client, h.Parsed, aliases)
|
||||
partial := false
|
||||
for _, s := range data.Servers {
|
||||
if !s.OK {
|
||||
partial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleFleetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -113,6 +113,65 @@ func TestHandlerFleetStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRadarTelemtDcs(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/stats/dcs" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"data": map[string]any{
|
||||
"middle_proxy_enabled": true,
|
||||
"generated_at_epoch_secs": 1,
|
||||
"dcs": []map[string]any{{
|
||||
"dc": 1, "rtt_ms": 42.5, "coverage_pct": 100.0,
|
||||
"alive_writers": 3, "required_writers": 3, "load": 0,
|
||||
}},
|
||||
},
|
||||
"revision": "rd",
|
||||
})
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Servers: []config.Server{
|
||||
{Alias: "test", BaseURL: up.URL, PathPrefix: "/v1"},
|
||||
},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := cfg.Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewHandler(parsed, up.Client(), nil, 0)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agg/radar-telemt-dcs?aliases=test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data RadarTelemtDcsData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !env.OK || len(env.Data.Servers) != 1 {
|
||||
t.Fatalf("envelope: %+v", env)
|
||||
}
|
||||
row := env.Data.Servers[0]
|
||||
if row.Alias != "test" || !row.OK || row.Data == nil || !row.Data.MiddleProxyEnabled {
|
||||
t.Fatalf("row: %+v", row)
|
||||
}
|
||||
if len(row.Data.Dcs) != 1 || row.Data.Dcs[0].DC != 1 {
|
||||
t.Fatalf("dcs: %+v", row.Data.Dcs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerUserOne(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/stats/users" {
|
||||
|
||||
@@ -65,7 +65,8 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
||||
partial = true
|
||||
continue
|
||||
}
|
||||
if data.ConnectionsBadTotal >= 10000 {
|
||||
badEff := h.effectiveConnectionsBad(alias, data.ConnectionsBadTotal)
|
||||
if badEff >= 10000 {
|
||||
out.Items = append(out.Items, IncidentItem{
|
||||
ID: fmt.Sprintf("bad_connections_high:%s", alias),
|
||||
Kind: "bad_connections_high",
|
||||
@@ -75,13 +76,13 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
||||
Summary: "Резкий рост ошибок клиентских соединений",
|
||||
AffectedAliases: []string{alias},
|
||||
MetricName: "connections_bad_total",
|
||||
MetricValue: float64(data.ConnectionsBadTotal),
|
||||
MetricValue: float64(badEff),
|
||||
MetricThreshold: 10000,
|
||||
Actions: []IncidentAction{
|
||||
{Label: "Node dashboard", Href: "/servers/" + alias},
|
||||
},
|
||||
})
|
||||
} else if data.ConnectionsBadTotal >= 1000 {
|
||||
} else if badEff >= 1000 {
|
||||
out.Items = append(out.Items, IncidentItem{
|
||||
ID: fmt.Sprintf("bad_connections_warn:%s", alias),
|
||||
Kind: "bad_connections_warn",
|
||||
@@ -91,7 +92,7 @@ func BuildIncidents(ctx context.Context, h *Handler, aliases []string) (Incident
|
||||
Summary: "Наблюдается рост ошибок клиентских соединений",
|
||||
AffectedAliases: []string{alias},
|
||||
MetricName: "connections_bad_total",
|
||||
MetricValue: float64(data.ConnectionsBadTotal),
|
||||
MetricValue: float64(badEff),
|
||||
MetricThreshold: 1000,
|
||||
Actions: []IncidentAction{
|
||||
{Label: "Node dashboard", Href: "/servers/" + alias},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package aggregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/telemt/telemt-api/internal/config"
|
||||
)
|
||||
|
||||
const statsDcsPath = "stats/dcs"
|
||||
|
||||
// FetchRadarTelemtDcs calls GET /v1/stats/dcs on each Telemt in parallel.
|
||||
func FetchRadarTelemtDcs(ctx context.Context, client *http.Client, parsed *config.Parsed, aliases []string) RadarTelemtDcsData {
|
||||
if len(aliases) == 0 {
|
||||
return RadarTelemtDcsData{}
|
||||
}
|
||||
rows := make([]RadarTelemtDcsServer, len(aliases))
|
||||
var wg sync.WaitGroup
|
||||
for i, alias := range aliases {
|
||||
i, alias := i, alias
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
data, meta := FetchTelemtGET[DcStatusPayload](ctx, client, parsed, alias, statsDcsPath)
|
||||
row := RadarTelemtDcsServer{
|
||||
Alias: alias,
|
||||
OK: meta.OK,
|
||||
HTTPStatus: meta.HTTPStatus,
|
||||
LatencyMs: meta.LatencyMs,
|
||||
Error: meta.Error,
|
||||
Revision: meta.Revision,
|
||||
}
|
||||
if meta.OK {
|
||||
row.Data = &data
|
||||
}
|
||||
rows[i] = row
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Alias < rows[j].Alias })
|
||||
return RadarTelemtDcsData{Servers: rows}
|
||||
}
|
||||
@@ -206,3 +206,37 @@ type TopUserByUniqueIPs struct {
|
||||
Username string `json:"username"`
|
||||
UniqueIPs uint64 `json:"unique_ips"`
|
||||
}
|
||||
|
||||
// DcStatusRow mirrors Telemt GET /v1/stats/dcs data.dcs[] (subset for radar UI).
|
||||
type DcStatusRow struct {
|
||||
DC int `json:"dc"`
|
||||
RttMs *float64 `json:"rtt_ms"`
|
||||
CoveragePct float64 `json:"coverage_pct"`
|
||||
AliveWriters int `json:"alive_writers"`
|
||||
RequiredWriters int `json:"required_writers"`
|
||||
Load int `json:"load"`
|
||||
}
|
||||
|
||||
// DcStatusPayload mirrors Telemt GET /v1/stats/dcs data object.
|
||||
type DcStatusPayload struct {
|
||||
MiddleProxyEnabled bool `json:"middle_proxy_enabled"`
|
||||
Reason *string `json:"reason"`
|
||||
GeneratedAtEpochSecs uint64 `json:"generated_at_epoch_secs"`
|
||||
Dcs []DcStatusRow `json:"dcs"`
|
||||
}
|
||||
|
||||
// RadarTelemtDcsServer is one upstream stats/dcs outcome for /api/agg/radar-telemt-dcs.
|
||||
type RadarTelemtDcsServer struct {
|
||||
Alias string `json:"alias"`
|
||||
OK bool `json:"ok"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Data *DcStatusPayload `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// RadarTelemtDcsData is aggregate payload for /api/agg/radar-telemt-dcs.
|
||||
type RadarTelemtDcsData struct {
|
||||
Servers []RadarTelemtDcsServer `json:"servers"`
|
||||
}
|
||||
+100
-7
@@ -15,14 +15,27 @@ var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// Config is the gateway YAML configuration.
|
||||
type Config struct {
|
||||
Listen string `yaml:"listen"`
|
||||
AllowAll bool `yaml:"allow_all"`
|
||||
Listen string `yaml:"listen"`
|
||||
// ExposeUpstreamErrors puts RoundTrip error text in JSON 502 bodies (error.detail). For private ops only.
|
||||
ExposeUpstreamErrors bool `yaml:"expose_upstream_errors"`
|
||||
AllowAll bool `yaml:"allow_all"`
|
||||
WhitelistCIDRs []string `yaml:"whitelist_cidrs"`
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
CorsAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||
Servers []Server `yaml:"servers"`
|
||||
Aggregate *AggregateConfig `yaml:"aggregate"`
|
||||
GeoIP *GeoIPConfig `yaml:"geoip"`
|
||||
Radar *RadarConfig `yaml:"radar"`
|
||||
}
|
||||
|
||||
// RadarConfig controls GET /api/radar/statuses and /api/radar/ping-dc (Telegram DC radar).
|
||||
type RadarConfig struct {
|
||||
// StatusesURL defaults to https://radar.telemt.top/api/v1/endpoints/statuses when empty.
|
||||
StatusesURL string `yaml:"statuses_url"`
|
||||
// HTTPTimeoutMs is outbound GET timeout for statuses; 0 means 30000.
|
||||
HTTPTimeoutMs uint64 `yaml:"http_timeout_ms"`
|
||||
// PingFrom is copied into JSON field "from" in ping-dc; empty uses request Host (no port) or "telemt-gateway".
|
||||
PingFrom string `yaml:"ping_from"`
|
||||
}
|
||||
|
||||
// GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional).
|
||||
@@ -49,6 +62,16 @@ type Server struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
PathPrefix string `yaml:"path_prefix"`
|
||||
AuthorizationEnv string `yaml:"authorization_env"`
|
||||
// Mihomo external-controller (optional): REST + WebSocket at controller root.
|
||||
MihomoBaseURL string `yaml:"mihomo_base_url"`
|
||||
MihomoBaseURLEnv string `yaml:"mihomo_base_url_env"`
|
||||
MihomoAuthorizationEnv string `yaml:"mihomo_authorization_env"`
|
||||
}
|
||||
|
||||
// Mihomo holds resolved external-controller upstream for a server alias.
|
||||
type Mihomo struct {
|
||||
Base *url.URL
|
||||
Auth string // full Authorization header value (e.g. Bearer <secret>)
|
||||
}
|
||||
|
||||
// Load reads and validates configuration from path.
|
||||
@@ -79,6 +102,9 @@ func (c *Config) Validate() error {
|
||||
s.BaseURL = strings.TrimSpace(s.BaseURL)
|
||||
s.PathPrefix = strings.TrimSpace(s.PathPrefix)
|
||||
s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv)
|
||||
s.MihomoBaseURL = strings.TrimSpace(s.MihomoBaseURL)
|
||||
s.MihomoBaseURLEnv = strings.TrimSpace(s.MihomoBaseURLEnv)
|
||||
s.MihomoAuthorizationEnv = strings.TrimSpace(s.MihomoAuthorizationEnv)
|
||||
if s.Alias == "" {
|
||||
return fmt.Errorf("servers[%d]: alias is required", i)
|
||||
}
|
||||
@@ -106,6 +132,19 @@ func (c *Config) Validate() error {
|
||||
if !strings.HasPrefix(s.PathPrefix, "/") {
|
||||
s.PathPrefix = "/" + s.PathPrefix
|
||||
}
|
||||
hasMihomoURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != ""
|
||||
if hasMihomoURL && s.MihomoAuthorizationEnv == "" {
|
||||
return fmt.Errorf("servers[%d]: mihomo_authorization_env is required when mihomo url is set", i)
|
||||
}
|
||||
if !hasMihomoURL && s.MihomoAuthorizationEnv != "" {
|
||||
return fmt.Errorf("servers[%d]: mihomo_base_url or mihomo_base_url_env is required when mihomo_authorization_env is set", i)
|
||||
}
|
||||
if s.MihomoBaseURL != "" {
|
||||
mu, err := url.Parse(s.MihomoBaseURL)
|
||||
if err != nil || mu.Scheme == "" || mu.Host == "" {
|
||||
return fmt.Errorf("servers[%d]: invalid mihomo_base_url %q", i, s.MihomoBaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.Servers) == 0 {
|
||||
return fmt.Errorf("at least one server entry is required")
|
||||
@@ -134,6 +173,17 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]")
|
||||
}
|
||||
}
|
||||
if c.Radar != nil {
|
||||
if c.Radar.HTTPTimeoutMs > 600000 {
|
||||
return fmt.Errorf("radar.http_timeout_ms must be within [0, 600000]")
|
||||
}
|
||||
if u := strings.TrimSpace(c.Radar.StatusesURL); u != "" {
|
||||
pu, err := url.Parse(u)
|
||||
if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" {
|
||||
return fmt.Errorf("radar.statuses_url: invalid URL %q", c.Radar.StatusesURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -160,6 +210,7 @@ type Parsed struct {
|
||||
Trusted []netip.Prefix
|
||||
ByAlias map[string]*Server
|
||||
AuthByAlias map[string]string // non-empty Authorization value per alias
|
||||
MihomoByAlias map[string]*Mihomo
|
||||
}
|
||||
|
||||
// Parse compiles CIDRs and resolves authorization from environment.
|
||||
@@ -182,6 +233,7 @@ func (c *Config) Parse() (*Parsed, error) {
|
||||
}
|
||||
by := make(map[string]*Server, len(c.Servers))
|
||||
auth := make(map[string]string)
|
||||
mihomo := make(map[string]*Mihomo)
|
||||
for i := range c.Servers {
|
||||
s := &c.Servers[i]
|
||||
by[s.Alias] = s
|
||||
@@ -192,12 +244,53 @@ func (c *Config) Parse() (*Parsed, error) {
|
||||
}
|
||||
auth[s.Alias] = v
|
||||
}
|
||||
mu, err := resolveMihomo(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mu != nil {
|
||||
mihomo[s.Alias] = mu
|
||||
}
|
||||
}
|
||||
return &Parsed{
|
||||
Config: c,
|
||||
Whitelist: wl,
|
||||
Trusted: tr,
|
||||
ByAlias: by,
|
||||
AuthByAlias: auth,
|
||||
Config: c,
|
||||
Whitelist: wl,
|
||||
Trusted: tr,
|
||||
ByAlias: by,
|
||||
AuthByAlias: auth,
|
||||
MihomoByAlias: mihomo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveMihomo returns non-nil only when Mihomo is configured for this server.
|
||||
func resolveMihomo(s *Server) (*Mihomo, error) {
|
||||
hasURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != ""
|
||||
if !hasURL {
|
||||
return nil, nil
|
||||
}
|
||||
if s.MihomoAuthorizationEnv == "" {
|
||||
return nil, fmt.Errorf("server %q: mihomo_authorization_env is required when mihomo url is set", s.Alias)
|
||||
}
|
||||
var urlStr string
|
||||
if s.MihomoBaseURLEnv != "" {
|
||||
v := strings.TrimSpace(os.Getenv(s.MihomoBaseURLEnv))
|
||||
if v != "" {
|
||||
urlStr = v
|
||||
}
|
||||
}
|
||||
if urlStr == "" && s.MihomoBaseURL != "" {
|
||||
urlStr = s.MihomoBaseURL
|
||||
}
|
||||
if urlStr == "" {
|
||||
return nil, fmt.Errorf("server %q: mihomo controller url is empty (set mihomo_base_url or env %q)", s.Alias, s.MihomoBaseURLEnv)
|
||||
}
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("server %q: invalid mihomo controller url %q", s.Alias, urlStr)
|
||||
}
|
||||
authVal := os.Getenv(s.MihomoAuthorizationEnv)
|
||||
if strings.TrimSpace(authVal) == "" {
|
||||
return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.MihomoAuthorizationEnv)
|
||||
}
|
||||
return &Mihomo{Base: u, Auth: authVal}, nil
|
||||
}
|
||||
@@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRadarStatusesURL(t *testing.T) {
|
||||
c := &Config{
|
||||
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
|
||||
Radar: &RadarConfig{StatusesURL: "ftp://bad"},
|
||||
}
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Fatal("expected error for invalid radar.statuses_url scheme")
|
||||
}
|
||||
c.Radar.StatusesURL = "https://radar.example/api"
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRadarHTTPTimeoutMs(t *testing.T) {
|
||||
c := &Config{
|
||||
Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}},
|
||||
Radar: &RadarConfig{HTTPTimeoutMs: 600001},
|
||||
}
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Fatal("expected error for radar.http_timeout_ms > 600000")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,18 @@ func NewAliasForward(
|
||||
stripPrefix, pathPrefix, auth string,
|
||||
rt http.RoundTripper,
|
||||
errHandler func(http.ResponseWriter, *http.Request, error),
|
||||
) http.Handler {
|
||||
return newAliasForward(target, stripPrefix, pathPrefix, auth, rt, errHandler, true)
|
||||
}
|
||||
|
||||
// newAliasForward is the shared HTTP forwarder. remapUsers maps top-level "users" → "stats/users"
|
||||
// for Telemt; Mihomo must pass false so /users is not rewritten.
|
||||
func newAliasForward(
|
||||
target *url.URL,
|
||||
stripPrefix, pathPrefix, auth string,
|
||||
rt http.RoundTripper,
|
||||
errHandler func(http.ResponseWriter, *http.Request, error),
|
||||
remapUsers bool,
|
||||
) http.Handler {
|
||||
if errHandler == nil {
|
||||
errHandler = defaultForwardErrorHandler
|
||||
@@ -35,7 +47,7 @@ func NewAliasForward(
|
||||
}
|
||||
rest := strings.TrimPrefix(p, stripPrefix)
|
||||
rest = strings.TrimPrefix(rest, "/")
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && rest == "users" {
|
||||
if remapUsers && (r.Method == http.MethodGet || r.Method == http.MethodHead) && rest == "users" {
|
||||
rest = "stats/users"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller.
|
||||
//
|
||||
// REST: same path as NewAliasForward (http.NewRequest + RoundTrip).
|
||||
// WebSocket (traffic, memory, …): gorilla/websocket Dial to upstream, then Upgrader on client, then frame relay.
|
||||
func NewMihomoForward(
|
||||
target *url.URL,
|
||||
stripPrefix string,
|
||||
auth string,
|
||||
rt http.RoundTripper,
|
||||
errHandler func(http.ResponseWriter, *http.Request, error),
|
||||
) http.Handler {
|
||||
httpH := newAliasForward(target, stripPrefix, "", auth, rt, errHandler, false)
|
||||
wsH := newMihomoWSTunnel(target, stripPrefix, auth, errHandler)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NormalizeRequestURLPath(r)
|
||||
p := r.URL.Path
|
||||
if !strings.HasPrefix(p, stripPrefix) {
|
||||
httpH.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(strings.TrimPrefix(p, stripPrefix), "/")
|
||||
if useMihomoWebSocketTunnel(rest, r) {
|
||||
wsH.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
httpH.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// useMihomoWebSocketTunnel is true when the client is doing a WS handshake.
|
||||
// For /traffic, /memory, /connections we also accept Sec-WebSocket-Key alone: some hops strip
|
||||
// Connection/Upgrade but leave Sec-WebSocket-Key; plain streaming GET has no key.
|
||||
func useMihomoWebSocketTunnel(rest string, r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
return true
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
switch rest {
|
||||
case "traffic", "memory", "connections":
|
||||
return strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key")) != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// applyMihomoWSDialAuth prepares upstream WS URL and Dial headers.
|
||||
// Mihomo: /connections WebSocket expects ?token=<secret> (raw secret, no "Bearer " prefix);
|
||||
// /traffic, /memory use Authorization: Bearer … like REST.
|
||||
func applyMihomoWSDialAuth(rest string, dest *url.URL, auth string) http.Header {
|
||||
hdr := make(http.Header)
|
||||
a := strings.TrimSpace(auth)
|
||||
if a == "" || dest == nil {
|
||||
return hdr
|
||||
}
|
||||
if rest == "connections" {
|
||||
token := a
|
||||
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||
token = strings.TrimSpace(token[7:])
|
||||
}
|
||||
q := dest.Query()
|
||||
q.Set("token", token)
|
||||
dest.RawQuery = q.Encode()
|
||||
return hdr
|
||||
}
|
||||
hdr.Set("Authorization", a)
|
||||
return hdr
|
||||
}
|
||||
|
||||
func mihomoWebSocketURL(dest *url.URL) (string, error) {
|
||||
if dest == nil {
|
||||
return "", fmt.Errorf("nil destination URL")
|
||||
}
|
||||
u := *dest
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http":
|
||||
u.Scheme = "ws"
|
||||
case "https":
|
||||
u.Scheme = "wss"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported mihomo scheme %q", dest.Scheme)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func tlsConfigForMihomoWS(dest *url.URL) *tls.Config {
|
||||
if dest == nil || strings.ToLower(dest.Scheme) != "https" {
|
||||
return nil
|
||||
}
|
||||
return &tls.Config{
|
||||
ServerName: dest.Hostname(),
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
// newMihomoWSTunnel: dial upstream WebSocket first (with Authorization), then upgrade the client, then relay frames.
|
||||
func newMihomoWSTunnel(
|
||||
target *url.URL,
|
||||
stripPrefix string,
|
||||
auth string,
|
||||
errHandler func(http.ResponseWriter, *http.Request, error),
|
||||
) http.Handler {
|
||||
if errHandler == nil {
|
||||
errHandler = defaultForwardErrorHandler
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NormalizeRequestURLPath(r)
|
||||
p := r.URL.Path
|
||||
if !strings.HasPrefix(p, stripPrefix) {
|
||||
errHandler(w, r, fmt.Errorf("path %q: missing strip prefix %q", p, stripPrefix))
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(strings.TrimPrefix(p, stripPrefix), "/")
|
||||
dest := JoinPathPrefix(target, "/", rest)
|
||||
du := *dest
|
||||
du.RawQuery = r.URL.RawQuery
|
||||
dest = &du
|
||||
|
||||
dialHdr := applyMihomoWSDialAuth(rest, dest, auth)
|
||||
wsURL, err := mihomoWebSocketURL(dest)
|
||||
if err != nil {
|
||||
errHandler(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
d := websocket.Dialer{
|
||||
HandshakeTimeout: 15 * time.Second,
|
||||
Proxy: func(*http.Request) (*url.URL, error) { return nil, nil },
|
||||
TLSClientConfig: tlsConfigForMihomoWS(dest),
|
||||
}
|
||||
upConn, _, err := d.Dial(wsURL, dialHdr)
|
||||
if err != nil {
|
||||
errHandler(w, r, fmt.Errorf("mihomo ws dial: %w", err))
|
||||
return
|
||||
}
|
||||
defer upConn.Close()
|
||||
|
||||
// Upgrader requires Connection+Upgrade; some proxies strip them but leave Sec-WebSocket-Key.
|
||||
if !websocket.IsWebSocketUpgrade(r) {
|
||||
r.Header.Set("Connection", "Upgrade")
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
if strings.TrimSpace(r.Header.Get("Sec-WebSocket-Version")) == "" {
|
||||
r.Header.Set("Sec-WebSocket-Version", "13")
|
||||
}
|
||||
}
|
||||
|
||||
upgrader := websocket.Upgrader{
|
||||
HandshakeTimeout: 15 * time.Second,
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
clientConn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go relayWSFrames(upConn, clientConn, errCh)
|
||||
go relayWSFrames(clientConn, upConn, errCh)
|
||||
<-errCh
|
||||
})
|
||||
}
|
||||
|
||||
func relayWSFrames(dst, src *websocket.Conn, errCh chan<- error) {
|
||||
for {
|
||||
mt, data, err := src.ReadMessage()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if err := dst.WriteMessage(mt, data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MihomoMetaJSON returns a JSON body for GET .../mihomo/meta (display URL without credentials).
|
||||
func MihomoMetaJSON(target *url.URL) []byte {
|
||||
display := strings.TrimSuffix(target.String(), "/")
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"controller_base": display,
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// MihomoJSONError writes a JSON error for Mihomo routes when proxy is not configured.
|
||||
func MihomoJSONError(w http.ResponseWriter, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": false,
|
||||
"error": map[string]string{"code": code, "message": msg},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMihomoForwardRewritesPathAndAuth(t *testing.T) {
|
||||
target, err := url.Parse("http://127.0.0.1:9090")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := &captureTransport{}
|
||||
h := NewMihomoForward(target, "/api/mtg/mihomo", "Bearer testsecret", cap, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://gw/api/mtg/mihomo/proxies", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer client-should-not-forward")
|
||||
req.Header.Set("Host", "public-gateway.example:8888")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
}
|
||||
if got, want := cap.got.Host, "127.0.0.1:9090"; got != want {
|
||||
t.Fatalf("Host: got %q want %q (upstream must not see client Host)", got, want)
|
||||
}
|
||||
if got := cap.got.Header.Get("Authorization"); got != "Bearer testsecret" {
|
||||
t.Fatalf("Authorization: got %q want Bearer testsecret", got)
|
||||
}
|
||||
want, err := url.Parse("http://127.0.0.1:9090/proxies")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestUseMihomoWebSocketTunnel(t *testing.T) {
|
||||
t.Run("gorilla IsWebSocketUpgrade", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/traffic", nil)
|
||||
r.Header.Set("Connection", "Upgrade")
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
r.Header.Set("Sec-WebSocket-Version", "13")
|
||||
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
if !useMihomoWebSocketTunnel("traffic", r) {
|
||||
t.Fatal("expected tunnel for full WS handshake")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traffic with key only (no Connection token)", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/traffic", nil)
|
||||
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
if !useMihomoWebSocketTunnel("traffic", r) {
|
||||
t.Fatal("expected tunnel for traffic+Sec-WebSocket-Key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("memory with key only", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/memory", nil)
|
||||
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
if !useMihomoWebSocketTunnel("memory", r) {
|
||||
t.Fatal("expected tunnel for memory+Sec-WebSocket-Key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traffic streaming GET without key", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/traffic", nil)
|
||||
if useMihomoWebSocketTunnel("traffic", r) {
|
||||
t.Fatal("plain GET /traffic must use HTTP forwarder")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proxies path never tunnel", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/proxies", nil)
|
||||
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
if useMihomoWebSocketTunnel("proxies", r) {
|
||||
t.Fatal("proxies must not use WS tunnel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connections with key only", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/connections", nil)
|
||||
r.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
if !useMihomoWebSocketTunnel("connections", r) {
|
||||
t.Fatal("expected tunnel for connections+Sec-WebSocket-Key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connections REST GET without key", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/x/mihomo/connections", nil)
|
||||
if useMihomoWebSocketTunnel("connections", r) {
|
||||
t.Fatal("plain GET /connections must use HTTP forwarder")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyMihomoWSDialAuth(t *testing.T) {
|
||||
t.Run("connections strips Bearer for token query", func(t *testing.T) {
|
||||
u, _ := url.Parse("http://127.0.0.1:9090/connections")
|
||||
hdr := applyMihomoWSDialAuth("connections", u, "Bearer mysecret")
|
||||
if hdr.Get("Authorization") != "" {
|
||||
t.Fatal("connections WS must not set Authorization")
|
||||
}
|
||||
if u.RawQuery != "token=mysecret" {
|
||||
t.Fatalf("query: %q", u.RawQuery)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connections raw token", func(t *testing.T) {
|
||||
u, _ := url.Parse("http://127.0.0.1:9090/connections")
|
||||
_ = applyMihomoWSDialAuth("connections", u, "rawonly")
|
||||
if u.RawQuery != "token=rawonly" {
|
||||
t.Fatalf("query: %q", u.RawQuery)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traffic uses Authorization", func(t *testing.T) {
|
||||
u, _ := url.Parse("http://127.0.0.1:9090/traffic")
|
||||
hdr := applyMihomoWSDialAuth("traffic", u, "Bearer t")
|
||||
if hdr.Get("Authorization") != "Bearer t" {
|
||||
t.Fatalf("Authorization: %q", hdr.Get("Authorization"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMihomoWebSocketURL(t *testing.T) {
|
||||
u, err := url.Parse("http://172.20.0.2:9090/traffic?q=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := mihomoWebSocketURL(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "ws://172.20.0.2:9090/traffic?q=1"; s != want {
|
||||
t.Fatalf("got %q want %q", s, want)
|
||||
}
|
||||
u2, _ := url.Parse("https://example.com/mem")
|
||||
s2, err := mihomoWebSocketURL(u2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "wss://example.com/mem"; s2 != want {
|
||||
t.Fatalf("got %q want %q", s2, want)
|
||||
}
|
||||
}
|
||||
+109
-9
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
type Gateway struct {
|
||||
parsed *config.Parsed
|
||||
proxies map[string]http.Handler
|
||||
mihomo map[string]http.Handler
|
||||
agg *aggregate.Handler
|
||||
geo *geoip.Service
|
||||
log *slog.Logger
|
||||
@@ -31,6 +34,22 @@ type Gateway struct {
|
||||
promHandler http.Handler
|
||||
corsAllowed []string
|
||||
webUI http.Handler
|
||||
radarStatusesURL string
|
||||
radarPingFrom string
|
||||
radarHTTPClient *http.Client
|
||||
}
|
||||
|
||||
func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
errObj := map[string]any{"code": code, "message": message}
|
||||
if expose && upstreamErr != nil {
|
||||
errObj["detail"] = upstreamErr.Error()
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": false,
|
||||
"error": errObj,
|
||||
})
|
||||
}
|
||||
|
||||
// NewGateway builds handlers and reverse proxies from parsed config.
|
||||
@@ -45,11 +64,13 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
g := &Gateway{
|
||||
parsed: p,
|
||||
proxies: make(map[string]http.Handler),
|
||||
mihomo: make(map[string]http.Handler),
|
||||
geo: geo,
|
||||
log: log,
|
||||
transport: t,
|
||||
promHandler: promhttp.Handler(),
|
||||
}
|
||||
exposeErr := p.Config.ExposeUpstreamErrors
|
||||
for i := range p.Config.Servers {
|
||||
s := &p.Config.Servers[i]
|
||||
u, err := url.Parse(s.BaseURL)
|
||||
@@ -60,12 +81,15 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
strip := "/api/" + s.Alias
|
||||
g.proxies[s.Alias] = proxy.NewAliasForward(u, strip, s.PathPrefix, auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Error("upstream error", "alias", s.Alias, "err", err)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": false,
|
||||
"error": map[string]string{"code": "bad_gateway", "message": "upstream unreachable"},
|
||||
})
|
||||
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "upstream unreachable", err)
|
||||
})
|
||||
}
|
||||
for alias, m := range p.MihomoByAlias {
|
||||
a := alias
|
||||
strip := "/api/" + a + "/mihomo"
|
||||
g.mihomo[a] = proxy.NewMihomoForward(m.Base, strip, m.Auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Error("mihomo upstream error", "alias", a, "err", err)
|
||||
writeBadGatewayJSON(w, exposeErr, "bad_gateway", "mihomo upstream unreachable", err)
|
||||
})
|
||||
}
|
||||
var aggCacheTTL time.Duration
|
||||
@@ -75,6 +99,21 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
|
||||
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
|
||||
g.webUI = webui.Handler()
|
||||
|
||||
statusesURL := defaultRadarStatusesURL
|
||||
radarTo := 30 * time.Second
|
||||
if p.Config.Radar != nil {
|
||||
if u := strings.TrimSpace(p.Config.Radar.StatusesURL); u != "" {
|
||||
statusesURL = u
|
||||
}
|
||||
if p.Config.Radar.HTTPTimeoutMs > 0 {
|
||||
radarTo = time.Duration(p.Config.Radar.HTTPTimeoutMs) * time.Millisecond
|
||||
}
|
||||
g.radarPingFrom = strings.TrimSpace(p.Config.Radar.PingFrom)
|
||||
}
|
||||
g.radarStatusesURL = statusesURL
|
||||
g.radarHTTPClient = &http.Client{Transport: t, Timeout: radarTo}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
@@ -221,6 +260,13 @@ func routeEndpoint(path string) string {
|
||||
if path == "/api/agg" {
|
||||
return "agg"
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/radar/") {
|
||||
rest := strings.TrimPrefix(path, "/api/radar/")
|
||||
if rest == "" {
|
||||
return "radar"
|
||||
}
|
||||
return "radar_" + strings.ReplaceAll(rest, "/", "_")
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/live/events") {
|
||||
return "live_events"
|
||||
}
|
||||
@@ -230,7 +276,11 @@ func routeEndpoint(path string) string {
|
||||
if i < 0 {
|
||||
return "proxy_root"
|
||||
}
|
||||
return "proxy_" + rest[i+1:]
|
||||
sub := rest[i+1:]
|
||||
if strings.HasPrefix(sub, "mihomo/") {
|
||||
return "mihomo_" + sub[len("mihomo/"):]
|
||||
}
|
||||
return "proxy_" + sub
|
||||
}
|
||||
return "ui"
|
||||
}
|
||||
@@ -258,6 +308,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
proxy.NormalizeRequestURLPath(r)
|
||||
}
|
||||
const prefix = "/api/"
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/agg/bad-connections/reset" {
|
||||
g.agg.HandleResetBadConnectionsBaseline(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") {
|
||||
g.agg.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -266,6 +320,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
g.serveLiveEvents(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/radar/statuses" || r.URL.Path == "/api/radar/ping-dc" {
|
||||
g.serveRadar(w, r)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
g.webUI.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -285,7 +343,7 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rp, ok := g.proxies[alias]
|
||||
_, ok := g.proxies[alias]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -295,7 +353,28 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
rp.ServeHTTP(w, r)
|
||||
mihomoPfx := "/api/" + alias + "/mihomo"
|
||||
if strings.HasPrefix(r.URL.Path, mihomoPfx) {
|
||||
mh, have := g.mihomo[alias]
|
||||
if !have {
|
||||
proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server")
|
||||
return
|
||||
}
|
||||
if r.URL.Path == mihomoPfx+"/meta" && r.Method == http.MethodGet {
|
||||
mu := g.parsed.MihomoByAlias[alias]
|
||||
if mu == nil {
|
||||
proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(proxy.MihomoMetaJSON(mu.Base))
|
||||
return
|
||||
}
|
||||
mh.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
g.proxies[alias].ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (g *Gateway) serveLiveEvents(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -363,6 +442,27 @@ func (s *statusWriter) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hijack preserves WebSocket upgrade support through middleware wrappers.
|
||||
func (s *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if h, ok := s.ResponseWriter.(http.Hijacker); ok {
|
||||
return h.Hijack()
|
||||
}
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
|
||||
// ReadFrom keeps io.Copy fast-path behavior when the underlying writer supports it.
|
||||
func (s *statusWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
if rf, ok := s.ResponseWriter.(io.ReaderFrom); ok {
|
||||
return rf.ReadFrom(r)
|
||||
}
|
||||
return io.Copy(s.ResponseWriter, r)
|
||||
}
|
||||
|
||||
// Unwrap lets net/http.ResponseController reach the original writer.
|
||||
func (s *statusWriter) Unwrap() http.ResponseWriter {
|
||||
return s.ResponseWriter
|
||||
}
|
||||
|
||||
// Shutdown idle connections on the shared transport.
|
||||
func (g *Gateway) Shutdown(ctx context.Context) error {
|
||||
g.transport.CloseIdleConnections()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+118
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"bits-ui": "^2.16.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -22,9 +23,11 @@
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -551,6 +554,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz",
|
||||
@@ -1479,6 +1488,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2084,6 +2105,73 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mode-watcher": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
|
||||
"integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"runed": "^0.25.0",
|
||||
"svelte-toolbelt": "^0.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mode-watcher/node_modules/runed": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
|
||||
"integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mode-watcher/node_modules/svelte-toolbelt": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
|
||||
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte"
|
||||
],
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"runed": "^0.23.2",
|
||||
"style-to-object": "^1.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"pnpm": ">=8.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
|
||||
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -2450,6 +2538,36 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-sonner": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz",
|
||||
"integrity": "sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"runed": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-sonner/node_modules/runed": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz",
|
||||
"integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-toolbelt": {
|
||||
"version": "0.10.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -30,6 +32,7 @@
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"bits-ui": "^2.16.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -7,12 +7,88 @@ import type {
|
||||
TelemtSuccess,
|
||||
UserInfo
|
||||
} from './telemt-v1.js';
|
||||
import type { MihomoMetaResponse } from './mihomo-types.js';
|
||||
|
||||
export function gatewayBase(): string {
|
||||
const u = PUBLIC_TELEMT_GATEWAY_URL || '';
|
||||
return u.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** Путь к Mihomo external-controller через шлюз: без ведущего слэша. */
|
||||
export function mihomoUrl(alias: string, path: string): string {
|
||||
const p = path.replace(/^\/+/, '');
|
||||
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket к шлюзу: `ws(s)://…/api/{alias}/mihomo/{path}`.
|
||||
* Authorization к Mihomo подставляет шлюз; браузеру токен не нужен.
|
||||
* Обзор Mihomo: WS для /traffic, /memory, /connections (токен на upstream подставляет шлюз; для /connections — ?token=…).
|
||||
*/
|
||||
export function mihomoWsUrl(alias: string, path: string): string {
|
||||
const p = path.replace(/^\/+/, '');
|
||||
const base =
|
||||
gatewayBase() || (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '');
|
||||
let wsBase = base;
|
||||
if (wsBase.startsWith('https://')) wsBase = `wss://${wsBase.slice('https://'.length)}`;
|
||||
else if (wsBase.startsWith('http://')) wsBase = `ws://${wsBase.slice('http://'.length)}`;
|
||||
return `${wsBase}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
|
||||
}
|
||||
|
||||
async function parseMihomoBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const snip = text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
throw new ApiError(`Ответ не JSON (HTTP ${res.status}): ${snip}`, res.status, text);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMihomoMeta(alias: string): Promise<MihomoMetaResponse> {
|
||||
const res = await fetch(mihomoUrl(alias, 'meta'));
|
||||
const body = (await parseMihomoBody(res)) as Record<string, unknown> | null;
|
||||
if (res.status === 404) {
|
||||
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`Mihomo meta HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
if (!body || body.ok !== true) {
|
||||
throw new ApiError('Mihomo meta: неверный ответ', res.status, body);
|
||||
}
|
||||
return body as unknown as MihomoMetaResponse;
|
||||
}
|
||||
|
||||
export async function fetchMihomoJson<T>(alias: string, path: string): Promise<T> {
|
||||
const res = await fetch(mihomoUrl(alias, path));
|
||||
const body = await parseMihomoBody(res);
|
||||
if (res.status === 404) {
|
||||
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`Mihomo HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function mihomoPut(alias: string, path: string, jsonBody: unknown): Promise<void> {
|
||||
const res = await fetch(mihomoUrl(alias, path), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(jsonBody)
|
||||
});
|
||||
if (res.status === 404) {
|
||||
const body = await parseMihomoBody(res);
|
||||
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await parseMihomoBody(res);
|
||||
throw new ApiError(`Mihomo PUT HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
}
|
||||
|
||||
export type AggEnvelope<T> = {
|
||||
ok: true;
|
||||
generated_at: string;
|
||||
@@ -104,6 +180,50 @@ export async function fetchAggFleetStatus(params?: { aliases?: string }): Promis
|
||||
return body as AggEnvelope<components['schemas']['FleetStatusData']>;
|
||||
}
|
||||
|
||||
/** Payload GET /api/agg/radar-telemt-dcs (снимок GET /v1/stats/dcs с каждой ноды). */
|
||||
export type AggRadarTelemtDcsRow = {
|
||||
alias: string;
|
||||
ok: boolean;
|
||||
http_status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
revision?: string;
|
||||
data?: {
|
||||
middle_proxy_enabled: boolean;
|
||||
reason?: string | null;
|
||||
generated_at_epoch_secs?: number;
|
||||
dcs?: {
|
||||
dc: number;
|
||||
rtt_ms?: number | null;
|
||||
coverage_pct: number;
|
||||
alive_writers: number;
|
||||
required_writers: number;
|
||||
load: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type AggRadarTelemtDcsData = {
|
||||
servers: AggRadarTelemtDcsRow[];
|
||||
};
|
||||
|
||||
export async function fetchAggRadarTelemtDcs(params?: {
|
||||
aliases?: string;
|
||||
}): Promise<AggEnvelope<AggRadarTelemtDcsData>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.aliases) q.set('aliases', params.aliases);
|
||||
const url = `${gatewayBase()}/api/agg/radar-telemt-dcs${q.toString() ? `?${q}` : ''}`;
|
||||
const res = await fetch(url);
|
||||
const body = (await parseJson(res)) as Record<string, unknown> | null;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`radar-telemt-dcs HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
if (!body || body.ok !== true) {
|
||||
throw new ApiError('radar-telemt-dcs: ok !== true', res.status, body);
|
||||
}
|
||||
return body as AggEnvelope<AggRadarTelemtDcsData>;
|
||||
}
|
||||
|
||||
export async function fetchAggUniqueIps(params?: {
|
||||
aliases?: string;
|
||||
geo?: boolean;
|
||||
@@ -162,6 +282,35 @@ export async function fetchAggIncidents(params?: { aliases?: string }): Promise<
|
||||
return body as AggEnvelope<IncidentsData>;
|
||||
}
|
||||
|
||||
export type ResetBadConnectionsRow = {
|
||||
alias: string;
|
||||
ok: boolean;
|
||||
baseline?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ResetBadConnectionsData = {
|
||||
servers: ResetBadConnectionsRow[];
|
||||
};
|
||||
|
||||
/** POST: зафиксировать текущий connections_bad_total как «ноль» для алертов bad connections на шлюзе. */
|
||||
export async function postAggResetBadConnectionsBaseline(params?: {
|
||||
aliases?: string;
|
||||
}): Promise<AggEnvelope<ResetBadConnectionsData>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.aliases) q.set('aliases', params.aliases);
|
||||
const url = `${gatewayBase()}/api/agg/bad-connections/reset${q.toString() ? `?${q}` : ''}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
const body = (await parseJson(res)) as Record<string, unknown> | null;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`bad-connections/reset HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
if (!body || body.ok !== true) {
|
||||
throw new ApiError('bad-connections/reset: ok !== true', res.status, body);
|
||||
}
|
||||
return body as AggEnvelope<ResetBadConnectionsData>;
|
||||
}
|
||||
|
||||
export function liveEventsUrl(params?: { aliases?: string }): string {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.aliases) q.set('aliases', params.aliases);
|
||||
@@ -281,3 +430,52 @@ export async function deleteUser(
|
||||
}
|
||||
return parsed as TelemtSuccess<string>;
|
||||
}
|
||||
|
||||
/** Ответ GET /api/radar/ping-dc (как ping_proxy.php). */
|
||||
export type RadarPingRow = { ok: boolean; ms: number | null };
|
||||
export type RadarPingResponse = { ok: boolean; results: Record<string, RadarPingRow>; from: string };
|
||||
|
||||
/** Прокси к radar.telemt.top — массив эндпоинтов (как radar_proxy.php). */
|
||||
export async function fetchRadarStatuses(): Promise<unknown[]> {
|
||||
const res = await fetch(`${gatewayBase()}/api/radar/statuses`);
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string };
|
||||
if (j?.error) msg = j.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(msg, res.status, text);
|
||||
}
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
|
||||
}
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ApiError('radar: ожидался JSON-массив', res.status, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRadarPingDC(): Promise<RadarPingResponse> {
|
||||
const res = await fetch(`${gatewayBase()}/api/radar/ping-dc`);
|
||||
const text = await res.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`ping-dc HTTP ${res.status}`, res.status, parsed);
|
||||
}
|
||||
const body = parsed as RadarPingResponse;
|
||||
if (!body || body.ok !== true || !body.results) {
|
||||
throw new ApiError('ping-dc: неверный ответ', res.status, parsed);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** GET /proxies — фрагмент ответа Mihomo external-controller. */
|
||||
export type MihomoProxiesResponse = {
|
||||
proxies?: Record<string, MihomoProxyEntry>;
|
||||
};
|
||||
|
||||
export type MihomoProxyEntry = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
now?: string;
|
||||
all?: string[];
|
||||
/** URL для healthcheck / GET …/delay (если задан в конфиге группы). */
|
||||
testUrl?: string;
|
||||
history?: { time: string; delay: number }[];
|
||||
udp?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MihomoConnectionsResponse = {
|
||||
total?: number;
|
||||
connections?: MihomoConnection[];
|
||||
/** Снимок по WebSocket /connections (Mihomo). */
|
||||
downloadTotal?: number;
|
||||
uploadTotal?: number;
|
||||
memory?: number;
|
||||
};
|
||||
|
||||
export type MihomoConnection = {
|
||||
metadata?: { network?: string; [k: string]: unknown };
|
||||
chains?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MihomoMetaResponse = {
|
||||
ok: true;
|
||||
controller_base: string;
|
||||
};
|
||||
|
||||
export type MihomoDelayResponse = {
|
||||
delay?: number;
|
||||
message?: string;
|
||||
};
|
||||
@@ -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,32 +1,43 @@
|
||||
<script lang="ts">
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
availableAliases = [],
|
||||
value = $bindable("all"),
|
||||
label = "Alias",
|
||||
allLabel = "All nodes"
|
||||
allLabel = "All nodes",
|
||||
onValueChange
|
||||
}: {
|
||||
availableAliases: string[];
|
||||
value?: string;
|
||||
label?: string;
|
||||
allLabel?: string;
|
||||
onValueChange?: (next: string) => void;
|
||||
} = $props();
|
||||
|
||||
let selectedLabel = $derived(value === "all" ? allLabel : value);
|
||||
let prev = value || "all";
|
||||
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
value = "all";
|
||||
return;
|
||||
}
|
||||
if (value !== prev) {
|
||||
prev = value;
|
||||
untrack(() => onValueChange?.(value));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="text-xs text-muted-foreground">
|
||||
{label}
|
||||
<Select.Root type="single" bind:value>
|
||||
<Select.Trigger class="mt-1 w-[220px]">
|
||||
{selectedLabel}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="all">{allLabel}</Select.Item>
|
||||
{#each availableAliases as alias (alias)}
|
||||
<Select.Item value={alias}>{alias}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<select
|
||||
class="border-input bg-background mt-1 h-8 w-[220px] rounded-lg border px-2.5 text-sm outline-none focus-visible:border-ring"
|
||||
bind:value
|
||||
>
|
||||
<option value="all">{allLabel}</option>
|
||||
{#each availableAliases as alias (alias)}
|
||||
<option value={alias}>{alias}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
@@ -9,8 +9,10 @@
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import SirenIcon from '@lucide/svelte/icons/siren';
|
||||
import RadioIcon from '@lucide/svelte/icons/radio';
|
||||
import RadarIcon from '@lucide/svelte/icons/radar';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
|
||||
let {
|
||||
@@ -22,10 +24,15 @@
|
||||
} = $props();
|
||||
|
||||
function active(href: string): boolean {
|
||||
const p = $page.url.pathname;
|
||||
const p = page.url.pathname;
|
||||
if (href === '/') return p === '/';
|
||||
return p === href || p.startsWith(href + '/');
|
||||
}
|
||||
|
||||
/** Только точное совпадение (для корня ноды `/servers/{alias}` — иначе вложенные страницы вроде …/mihomo подсвечивали бы и Dashboard). */
|
||||
function activeExact(href: string): boolean {
|
||||
return page.url.pathname === href;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Root collapsible="icon">
|
||||
@@ -79,7 +86,7 @@
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents">
|
||||
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Инциденты">
|
||||
{#snippet child({ props })}
|
||||
<a href="/incidents" {...props}>
|
||||
<SirenIcon />
|
||||
@@ -98,6 +105,26 @@
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active('/radar')} tooltipContent="Радар DC Telegram">
|
||||
{#snippet child({ props })}
|
||||
<a href="/radar" {...props}>
|
||||
<RadarIcon />
|
||||
<span>Радар DC</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active('/mihomo')} tooltipContent="Mihomo (флот)">
|
||||
{#snippet child({ props })}
|
||||
<a href="/mihomo" {...props}>
|
||||
<ZapIcon />
|
||||
<span>Mihomo</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
{#if serverAliases.length > 0}
|
||||
@@ -127,71 +154,81 @@
|
||||
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath)} tooltipContent="Dashboard">
|
||||
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Обзор">
|
||||
{#snippet child({ props })}
|
||||
<a href={serverPath} {...props}>
|
||||
<LayoutDashboardIcon />
|
||||
<span>Dashboard</span>
|
||||
<span>Обзор</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/mihomo')} tooltipContent="Mihomo">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/mihomo" {...props}>
|
||||
<ZapIcon />
|
||||
<span>Mihomo</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Состояние">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/runtime" {...props}>
|
||||
<ActivityIcon />
|
||||
<span>Runtime</span>
|
||||
<span>Состояние</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Users">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Пользователи">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/users" {...props}>
|
||||
<UsersIcon />
|
||||
<span>Users</span>
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Security">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Безопасность">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/security" {...props}>
|
||||
<ShieldIcon />
|
||||
<span>Security</span>
|
||||
<span>Безопасность</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Upstreams">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Апстримы и ДЦ">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/upstreams" {...props}>
|
||||
<NetworkIcon />
|
||||
<span>Upstreams & DCs</span>
|
||||
<span>Апстримы и ДЦ</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Configuration">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Конфигурация">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/config" {...props}>
|
||||
<SettingsIcon />
|
||||
<span>Configuration</span>
|
||||
<span>Конфигурация</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Update">
|
||||
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Обновление">
|
||||
{#snippet child({ props })}
|
||||
<a href="{serverPath}/update" {...props}>
|
||||
<RefreshCwIcon />
|
||||
<span>Update</span>
|
||||
<span>Обновление</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { kpiAccent, type KpiAccentKey } from '$lib/kpi-accents.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
accent,
|
||||
icon,
|
||||
class: className,
|
||||
toolbar,
|
||||
children
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
accent?: KpiAccentKey;
|
||||
icon?: Snippet;
|
||||
toolbar?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
density="compact"
|
||||
class={cn(
|
||||
'overflow-hidden rounded-lg border border-border shadow-sm',
|
||||
accent && 'border-l-4',
|
||||
accent && kpiAccent[accent].border,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#if title}
|
||||
<Card.Header class="border-b border-border bg-muted/30 px-4 py-3.5">
|
||||
<div class="flex gap-3">
|
||||
{#if icon && accent}
|
||||
<div
|
||||
class={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
||||
kpiAccent[accent].iconWrap
|
||||
)}
|
||||
>
|
||||
{@render icon()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<Card.Title class="text-base font-semibold">{title}</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
{/if}
|
||||
{#if toolbar}
|
||||
{@render toolbar()}
|
||||
{/if}
|
||||
<Card.Content class="p-0">
|
||||
{@render children()}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { SortDir } from '$lib/table-sort.js';
|
||||
|
||||
let {
|
||||
active,
|
||||
dir,
|
||||
onToggle,
|
||||
class: className,
|
||||
children,
|
||||
numeric
|
||||
}: {
|
||||
active: boolean;
|
||||
dir: SortDir | null;
|
||||
onToggle: () => void;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
numeric?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Table.Head
|
||||
class={cn(
|
||||
'px-3 py-2 text-left align-middle text-sm font-medium text-muted-foreground',
|
||||
numeric && 'text-right',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-md',
|
||||
'text-muted-foreground transition-colors',
|
||||
'hover:bg-accent/60 hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45',
|
||||
active && 'text-foreground',
|
||||
numeric && 'ms-auto'
|
||||
)}
|
||||
onclick={onToggle}
|
||||
>
|
||||
{@render children()}
|
||||
{#if active && dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
{:else if active && dir === 'desc'}
|
||||
<ArrowDownIcon class="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||
import InboxIcon from '@lucide/svelte/icons/inbox';
|
||||
|
||||
let {
|
||||
title,
|
||||
description
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-10">
|
||||
<Empty.Root class="max-w-sm border-0">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<InboxIcon />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{title}</Empty.Title>
|
||||
{#if description}
|
||||
<Empty.Description>{description}</Empty.Description>
|
||||
{/if}
|
||||
</Empty.Header>
|
||||
</Empty.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type PartialVariant = 'default' | 'destructive';
|
||||
|
||||
let {
|
||||
partial = false,
|
||||
partialVariant = 'default' as PartialVariant,
|
||||
partialTitle = 'Частичные данные',
|
||||
partialDescription = 'Не все upstream ответили успешно.',
|
||||
partialSnippet,
|
||||
err = null as string | null,
|
||||
hasStaleData = false,
|
||||
showSkeleton = false,
|
||||
isEmpty = false,
|
||||
children,
|
||||
skeleton,
|
||||
empty
|
||||
}: {
|
||||
partial?: boolean;
|
||||
partialVariant?: PartialVariant;
|
||||
partialTitle?: string;
|
||||
partialDescription?: string;
|
||||
partialSnippet?: Snippet;
|
||||
err?: string | null;
|
||||
hasStaleData?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
isEmpty?: boolean;
|
||||
children?: Snippet;
|
||||
skeleton?: Snippet;
|
||||
empty?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if partial}
|
||||
{#if partialSnippet}
|
||||
{@render partialSnippet()}
|
||||
{:else}
|
||||
<Alert variant={partialVariant} class="mb-4">
|
||||
<AlertTitle>{partialTitle}</AlertTitle>
|
||||
<AlertDescription>{partialDescription}</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if err}
|
||||
<Alert variant="destructive" class="mb-4">
|
||||
<AlertTitle>{hasStaleData ? 'Ошибка обновления' : 'Ошибка'}</AlertTitle>
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
{#if hasStaleData}
|
||||
<p class="mt-2 text-xs text-muted-foreground">Показаны последние успешные данные.</p>
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if showSkeleton}
|
||||
{@render skeleton?.()}
|
||||
{:else if err && !hasStaleData}
|
||||
<!-- блокируем контент при первичной ошибке -->
|
||||
{:else if isEmpty}
|
||||
{@render empty?.()}
|
||||
{:else}
|
||||
<div class={cn(err && hasStaleData && 'opacity-90')}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
let {
|
||||
isStale,
|
||||
staleSeconds,
|
||||
refreshSeconds,
|
||||
staleMultiplier = 2,
|
||||
tooltipOverride
|
||||
}: {
|
||||
isStale: boolean;
|
||||
staleSeconds: number | null;
|
||||
refreshSeconds: number;
|
||||
staleMultiplier?: number;
|
||||
/** Полный текст тултипа (например для Live/SSE с фиксированным порогом). */
|
||||
tooltipOverride?: string;
|
||||
} = $props();
|
||||
|
||||
const thresholdSec = $derived(refreshSeconds > 0 ? refreshSeconds * staleMultiplier : 60);
|
||||
const tip = $derived(
|
||||
tooltipOverride ??
|
||||
(isStale
|
||||
? `Статус stale: нет успешного обновления дольше ${thresholdSec} с (порог ≈ ${staleMultiplier}× интервал автообновления).`
|
||||
: `Свежие данные: последний успешный снимок ${staleSeconds ?? 0} с назад.`)
|
||||
);
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="inline-flex">
|
||||
<Badge variant={isStale ? 'secondary' : 'default'}>
|
||||
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" class="max-w-xs">
|
||||
<p class="text-xs">{tip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -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,27 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
|
||||
let {
|
||||
id = 'fleet-refresh-sec',
|
||||
value = $bindable('30'),
|
||||
onValueChange
|
||||
}: {
|
||||
id?: string;
|
||||
value?: string;
|
||||
onValueChange?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid w-fit gap-1.5">
|
||||
<Label for={id} class="text-xs text-muted-foreground">Refresh (sec)</Label>
|
||||
<Input
|
||||
{id}
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
class="h-9 w-28"
|
||||
bind:value
|
||||
onchange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import InfoIcon from '@lucide/svelte/icons/info';
|
||||
import StarIcon from '@lucide/svelte/icons/star';
|
||||
|
||||
let {
|
||||
active_on_servers,
|
||||
recent_on_servers,
|
||||
primary_server,
|
||||
linkToServer = true,
|
||||
class: className
|
||||
}: {
|
||||
active_on_servers?: string[] | null;
|
||||
recent_on_servers?: string[] | null;
|
||||
primary_server?: string | null;
|
||||
/** Ссылка на `/servers/{alias}` у бейджей */
|
||||
linkToServer?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const activeServers = $derived(((active_on_servers ?? []) as string[]).filter(Boolean));
|
||||
const recentServers = $derived(((recent_on_servers ?? []) as string[]).filter(Boolean));
|
||||
const recentOnly = $derived(recentServers.filter((s) => !activeServers.includes(s)));
|
||||
|
||||
const soleActiveTip =
|
||||
'Единственный сервер в активных на этом снимке (не приоритет, а однозначность).';
|
||||
|
||||
function serverHref(alias: string): string | undefined {
|
||||
if (!linkToServer) return undefined;
|
||||
return `/servers/${encodeURIComponent(alias)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('space-y-2 text-xs', className)}>
|
||||
{#if activeServers.length > 0}
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-muted-foreground">Активные</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Пояснение: активные"
|
||||
>
|
||||
<InfoIcon class="size-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" class="max-w-xs">
|
||||
<p class="text-xs">
|
||||
Серверы, где этот IP есть в списке активных уникальных адресов на момент снимка
|
||||
шлюза.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#each activeServers as srv (srv)}
|
||||
{#if primary_server && srv === primary_server && activeServers.length === 1}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="inline-flex">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="font-normal px-2 py-0.5 gap-1"
|
||||
href={serverHref(srv)}
|
||||
>
|
||||
<StarIcon
|
||||
class="size-3 shrink-0 fill-amber-400 stroke-amber-400 text-amber-400 drop-shadow-[0_0_5px_rgba(251,191,36,0.55)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{srv}
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" class="max-w-xs">
|
||||
<p class="text-xs">{soleActiveTip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="font-normal px-2 py-0.5" href={serverHref(srv)}>
|
||||
{srv}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">
|
||||
{recentOnly.length > 0 ? 'Нет в активных' : '—'}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if recentOnly.length > 0}
|
||||
{#if activeServers.length > 0}
|
||||
<Separator class="my-1" />
|
||||
{/if}
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-muted-foreground">Только недавние</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Пояснение: только недавние"
|
||||
>
|
||||
<InfoIcon class="size-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" class="max-w-xs">
|
||||
<p class="text-xs">
|
||||
Серверы, где IP есть в недавнем списке, но на этом снимке не в активных — например
|
||||
недавнее подключение с другого шлюза.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#each recentOnly as srv (srv)}
|
||||
<Badge variant="outline" class="font-normal px-2 py-0.5" href={serverHref(srv)}>
|
||||
{srv}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
||||
import { kpiAccent } from '$lib/kpi-accents.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
accent,
|
||||
title,
|
||||
description,
|
||||
class: className,
|
||||
contentClass,
|
||||
headerClass,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
accent: KpiAccentKey;
|
||||
title: string;
|
||||
description?: string;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
headerClass?: string;
|
||||
icon: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent[accent].border, className)}>
|
||||
<Card.Header class={cn(headerClass)}>
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
||||
kpiAccent[accent].iconWrap
|
||||
)}
|
||||
>
|
||||
{@render icon()}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description>{description}</Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class={contentClass}>
|
||||
{@render children()}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
||||
import { kpiAccent } from '$lib/kpi-accents.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
accent,
|
||||
label,
|
||||
footer,
|
||||
class: className,
|
||||
valueClass,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
accent: KpiAccentKey;
|
||||
label: string;
|
||||
footer?: string;
|
||||
class?: string;
|
||||
/** Дополнительные классы для значения (например `text-xl`). */
|
||||
valueClass?: string;
|
||||
icon: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[accent].border, className)}>
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
||||
kpiAccent[accent].iconWrap
|
||||
)}
|
||||
>
|
||||
{@render icon()}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Card.Description>{label}</Card.Description>
|
||||
<Card.Title class={cn('text-2xl tabular-nums', kpiAccent[accent].value, valueClass)}>
|
||||
{@render children()}
|
||||
</Card.Title>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if footer}
|
||||
<Card.Content class="text-xs text-muted-foreground">{footer}</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,401 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import Chart from 'chart.js/auto';
|
||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
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 GaugeIcon from '@lucide/svelte/icons/gauge';
|
||||
import {
|
||||
chartColorWithAlpha,
|
||||
chartGridColor,
|
||||
chartLegendColor,
|
||||
chartLineAreaFillCallback,
|
||||
chartSeriesColors,
|
||||
chartTickColor,
|
||||
readCssVar
|
||||
} from '$lib/chart-theme.js';
|
||||
|
||||
let {
|
||||
histUp,
|
||||
histDown,
|
||||
histMem,
|
||||
histConn,
|
||||
totalUp,
|
||||
totalDown,
|
||||
tcpN,
|
||||
udpN,
|
||||
topList
|
||||
}: {
|
||||
histUp: number[];
|
||||
histDown: number[];
|
||||
histMem: number[];
|
||||
histConn: number[];
|
||||
totalUp: number;
|
||||
totalDown: number;
|
||||
tcpN: number;
|
||||
udpN: number;
|
||||
topList: { name: string; n: number }[];
|
||||
} = $props();
|
||||
|
||||
let elTraffic = $state<HTMLCanvasElement | null>(null);
|
||||
let elFlow = $state<HTMLCanvasElement | null>(null);
|
||||
let elMem = $state<HTMLCanvasElement | null>(null);
|
||||
let elConn = $state<HTMLCanvasElement | null>(null);
|
||||
let elNet = $state<HTMLCanvasElement | null>(null);
|
||||
let elTop = $state<HTMLCanvasElement | null>(null);
|
||||
|
||||
let chartTraffic: Chart | null = null;
|
||||
let chartFlow: Chart | null = null;
|
||||
let chartMem: Chart | null = null;
|
||||
let chartConn: Chart | null = null;
|
||||
let chartNet: Chart | null = null;
|
||||
let chartTop: Chart | null = null;
|
||||
|
||||
function lineOpts() {
|
||||
const tick = chartTickColor();
|
||||
const grid = chartGridColor();
|
||||
const legend = chartLegendColor();
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
plugins: { legend: { labels: { color: legend } } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildAll() {
|
||||
const colors = chartSeriesColors();
|
||||
const legend = chartLegendColor();
|
||||
const grid = chartGridColor();
|
||||
const tick = chartTickColor();
|
||||
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, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: lab,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Скачивание',
|
||||
data: [...histDown],
|
||||
borderColor: c0,
|
||||
backgroundColor: chartLineAreaFillCallback(c0),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c0,
|
||||
pointHoverBorderColor: c0,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Загрузка',
|
||||
data: [...histUp],
|
||||
borderColor: c4,
|
||||
backgroundColor: chartLineAreaFillCallback(c4),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c4,
|
||||
pointHoverBorderColor: c4,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
}
|
||||
|
||||
if (elFlow) {
|
||||
chartFlow?.destroy();
|
||||
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
const downColor = chartColorWithAlpha(colors[0] ?? '#38bdf8', 0.88);
|
||||
const upColor = chartColorWithAlpha(colors[4] ?? '#f472b6', 0.88);
|
||||
chartFlow = new Chart(elFlow, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Скачано', 'Загружено'],
|
||||
datasets: [
|
||||
{
|
||||
data: [Math.max(totalDown, 0), Math.max(totalUp, 0)],
|
||||
backgroundColor: [downColor, upColor],
|
||||
hoverBackgroundColor: [downColor, upColor],
|
||||
borderWidth: 2,
|
||||
borderColor: hole,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: hole,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (elMem) {
|
||||
chartMem?.destroy();
|
||||
const c2 = colors[2] ?? '#a78bfa';
|
||||
chartMem = new Chart(elMem, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: histMem.map((_, i) => String(i)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'KiB',
|
||||
data: [...histMem],
|
||||
borderColor: c2,
|
||||
backgroundColor: chartLineAreaFillCallback(c2),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c2,
|
||||
pointHoverBorderColor: c2,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
}
|
||||
|
||||
if (elConn) {
|
||||
chartConn?.destroy();
|
||||
const c1 = colors[1] ?? '#34d399';
|
||||
chartConn = new Chart(elConn, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: histConn.map((_, i) => String(i)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Соединения',
|
||||
data: [...histConn],
|
||||
borderColor: c1,
|
||||
backgroundColor: chartLineAreaFillCallback(c1),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: c1,
|
||||
pointHoverBorderColor: c1,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: lineOpts()
|
||||
});
|
||||
}
|
||||
|
||||
if (elNet) {
|
||||
chartNet?.destroy();
|
||||
const holeNet = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
const tcpColor = chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88);
|
||||
const udpColor = chartColorWithAlpha(colors[3] ?? '#eab308', 0.88);
|
||||
chartNet = new Chart(elNet, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['TCP', 'UDP'],
|
||||
datasets: [
|
||||
{
|
||||
data: [tcpN, udpN],
|
||||
backgroundColor: [tcpColor, udpColor],
|
||||
hoverBackgroundColor: [tcpColor, udpColor],
|
||||
borderWidth: 2,
|
||||
borderColor: holeNet,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: holeNet,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (elTop) {
|
||||
chartTop?.destroy();
|
||||
const topBarColor = chartColorWithAlpha(colors[0] ?? '#818cf8', 0.52);
|
||||
chartTop = new Chart(elTop, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: topList.map((x) => x.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Сессии',
|
||||
data: topList.map((x) => x.n),
|
||||
backgroundColor: topBarColor,
|
||||
hoverBackgroundColor: topBarColor,
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
void tick().then(() => {
|
||||
if (!cancelled) rebuildAll();
|
||||
});
|
||||
const obs = new MutationObserver(() => rebuildAll());
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
obs.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const labels = histDown.map((_, i) => String(i));
|
||||
if (chartTraffic) {
|
||||
chartTraffic.data.labels = labels;
|
||||
chartTraffic.data.datasets[0].data = [...histDown];
|
||||
chartTraffic.data.datasets[1].data = [...histUp];
|
||||
chartTraffic.update('none');
|
||||
}
|
||||
if (chartMem) {
|
||||
chartMem.data.labels = histMem.map((_, i) => String(i));
|
||||
chartMem.data.datasets[0].data = [...histMem];
|
||||
chartMem.update('none');
|
||||
}
|
||||
if (chartConn) {
|
||||
chartConn.data.labels = histConn.map((_, i) => String(i));
|
||||
chartConn.data.datasets[0].data = [...histConn];
|
||||
chartConn.update('none');
|
||||
}
|
||||
if (chartFlow) {
|
||||
chartFlow.data.datasets[0].data = [Math.max(totalDown, 0), Math.max(totalUp, 0)];
|
||||
chartFlow.update('none');
|
||||
}
|
||||
if (chartNet) {
|
||||
chartNet.data.datasets[0].data = [tcpN, udpN];
|
||||
chartNet.update('none');
|
||||
}
|
||||
if (chartTop) {
|
||||
chartTop.data.labels = topList.map((x) => x.name);
|
||||
chartTop.data.datasets[0].data = topList.map((x) => x.n);
|
||||
chartTop.update('none');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
chartTraffic?.destroy();
|
||||
chartFlow?.destroy();
|
||||
chartMem?.destroy();
|
||||
chartConn?.destroy();
|
||||
chartNet?.destroy();
|
||||
chartTop?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Трафик"
|
||||
description="Скорость загрузки и скачивания (история из /traffic)"
|
||||
headerClass="pb-2"
|
||||
contentClass="h-56 px-4 pb-4 pt-0"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Поток (накоплено)"
|
||||
description="Соотношение накопленного down / up"
|
||||
headerClass="pb-2"
|
||||
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>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Память (KiB)"
|
||||
description="Использование по снимкам (ось — KiB)"
|
||||
headerClass="pb-2"
|
||||
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>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Подключения"
|
||||
description="Число активных соединений во времени"
|
||||
headerClass="pb-2"
|
||||
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>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Типы сети"
|
||||
description="TCP и UDP по текущему снимку"
|
||||
headerClass="pb-2"
|
||||
contentClass="h-56 px-4 pb-4 pt-0"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<NetworkIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
<KpiPanelCard
|
||||
accent="neutral"
|
||||
title="Топ прокси (по сессиям)"
|
||||
description="Распределение по имени прокси"
|
||||
headerClass="pb-2"
|
||||
contentClass="h-56 px-4 pb-4 pt-0"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<GaugeIcon class="size-5" aria-hidden="true" />
|
||||
{/snippet}
|
||||
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
|
||||
</KpiPanelCard>
|
||||
</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,35 @@
|
||||
<script lang="ts">
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
aliases,
|
||||
variant = 'outline',
|
||||
linkToServer = true,
|
||||
class: className
|
||||
}: {
|
||||
aliases: string[] | null | undefined;
|
||||
variant?: BadgeVariant;
|
||||
linkToServer?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const list = $derived(((aliases ?? []) as string[]).filter(Boolean));
|
||||
|
||||
function href(alias: string): string | undefined {
|
||||
if (!linkToServer) return undefined;
|
||||
return `/servers/${encodeURIComponent(alias)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if list.length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{:else}
|
||||
<div class={cn('flex flex-wrap items-center gap-1', className)}>
|
||||
{#each list as alias (alias)}
|
||||
<Badge variant={variant} class="font-normal px-2 py-0.5" href={href(alias)}>
|
||||
{alias}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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,27 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonVariant,
|
||||
type ButtonSize,
|
||||
} from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action
|
||||
bind:ref
|
||||
data-slot="alert-dialog-action"
|
||||
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonVariant,
|
||||
type ButtonSize,
|
||||
} from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
data-slot="alert-dialog-cancel"
|
||||
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import AlertDialogPortal from "./alert-dialog-portal.svelte";
|
||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
size = "default",
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
size?: "default" | "sm";
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPortal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="alert-dialog-description"
|
||||
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -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="alert-dialog-footer"
|
||||
class={cn(
|
||||
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<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<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<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<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-media"
|
||||
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="alert-dialog-title"
|
||||
class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Root bind:open {...restProps} />
|
||||
@@ -0,0 +1,40 @@
|
||||
import Root from "./alert-dialog.svelte";
|
||||
import Portal from "./alert-dialog-portal.svelte";
|
||||
import Trigger from "./alert-dialog-trigger.svelte";
|
||||
import Title from "./alert-dialog-title.svelte";
|
||||
import Action from "./alert-dialog-action.svelte";
|
||||
import Cancel from "./alert-dialog-cancel.svelte";
|
||||
import Footer from "./alert-dialog-footer.svelte";
|
||||
import Header from "./alert-dialog-header.svelte";
|
||||
import Overlay from "./alert-dialog-overlay.svelte";
|
||||
import Content from "./alert-dialog-content.svelte";
|
||||
import Description from "./alert-dialog-description.svelte";
|
||||
import Media from "./alert-dialog-media.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Media,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
Media as AlertDialogMedia,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("size-5 [&>svg]:size-4 flex items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-item"
|
||||
class={cn("gap-1 inline-flex items-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
href = undefined,
|
||||
child,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||
} = $props();
|
||||
|
||||
const attrs = $derived({
|
||||
"data-slot": "breadcrumb-link",
|
||||
class: cn("hover:text-foreground transition-colors", className),
|
||||
href,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: attrs })}
|
||||
{:else}
|
||||
<a bind:this={ref} {...attrs}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLOlAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-list"
|
||||
class={cn("text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ol>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
class={cn("text-foreground font-normal", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("[&>svg]:size-3.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<ChevronRightIcon />
|
||||
{/if}
|
||||
</li>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb"
|
||||
aria-label="breadcrumb"
|
||||
class={cn("cn-breadcrumb", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
@@ -0,0 +1,25 @@
|
||||
import Root from "./breadcrumb.svelte";
|
||||
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||
import Item from "./breadcrumb-item.svelte";
|
||||
import Separator from "./breadcrumb-separator.svelte";
|
||||
import Link from "./breadcrumb-link.svelte";
|
||||
import List from "./breadcrumb-list.svelte";
|
||||
import Page from "./breadcrumb-page.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Ellipsis,
|
||||
Item,
|
||||
Separator,
|
||||
Link,
|
||||
List,
|
||||
Page,
|
||||
//
|
||||
Root as Breadcrumb,
|
||||
Ellipsis as BreadcrumbEllipsis,
|
||||
Item as BreadcrumbItem,
|
||||
Separator as BreadcrumbSeparator,
|
||||
Link as BreadcrumbLink,
|
||||
List as BreadcrumbList,
|
||||
Page as BreadcrumbPage,
|
||||
};
|
||||
@@ -7,15 +7,25 @@
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
density = "default",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
size?: "default" | "sm";
|
||||
density?: "default" | "compact";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
data-density={density}
|
||||
class={cn(
|
||||
"ring-foreground/10 bg-card text-card-foreground overflow-hidden rounded-xl text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
|
||||
"data-[density=default]:gap-4 data-[density=default]:py-4 data-[density=default]:data-[size=sm]:gap-3 data-[density=default]:data-[size=sm]:py-3",
|
||||
"data-[density=compact]:gap-0 data-[density=compact]:py-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div
|
||||
data-slot="checkbox-indicator"
|
||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||
>
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</CheckboxPrimitive.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import Command from "./command.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
showCloseButton = false,
|
||||
portalProps,
|
||||
children,
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
|
||||
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
title?: string;
|
||||
description?: string;
|
||||
showCloseButton?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...restProps}>
|
||||
<Dialog.Header class="sr-only">
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>{description}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content
|
||||
class={cn("rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0", className)}
|
||||
{showCloseButton}
|
||||
{portalProps}
|
||||
>
|
||||
<Command {...restProps} bind:value bind:ref {children} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.EmptyProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty
|
||||
bind:ref
|
||||
data-slot="command-empty"
|
||||
class={cn("py-6 text-center text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive, useId } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
heading,
|
||||
value,
|
||||
...restProps
|
||||
}: CommandPrimitive.GroupProps & {
|
||||
heading?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Group
|
||||
bind:ref
|
||||
data-slot="command-group"
|
||||
class={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
|
||||
value={value ?? heading ?? `----${useId()}`}
|
||||
{...restProps}
|
||||
>
|
||||
{#if heading}
|
||||
<CommandPrimitive.GroupHeading
|
||||
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
|
||||
>
|
||||
{heading}
|
||||
</CommandPrimitive.GroupHeading>
|
||||
{/if}
|
||||
<CommandPrimitive.GroupItems {children} />
|
||||
</CommandPrimitive.Group>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import * as InputGroup from "$lib/components/ui/input-group/index.js";
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(""),
|
||||
...restProps
|
||||
}: CommandPrimitive.InputProps = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="command-input-wrapper" class="p-1 pb-0">
|
||||
<InputGroup.Root class="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
class={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
bind:value
|
||||
/>
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon class="size-4 shrink-0 opacity-50" />
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: CommandPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="command-item"
|
||||
class={cn(
|
||||
"group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<CheckIcon class="cn-command-item-indicator ml-auto opacity-0 group-has-[[data-slot=command-shortcut]]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.LinkItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.LinkItem
|
||||
bind:ref
|
||||
data-slot="command-item"
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
bind:ref
|
||||
data-slot="command-list"
|
||||
class={cn("no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CommandPrimitive.LoadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Loading bind:ref {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="command-separator"
|
||||
class={cn("bg-border -mx-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -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<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="command-shortcut"
|
||||
class={cn("text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
|
||||
export type CommandRootApi = CommandPrimitive.Root;
|
||||
|
||||
let {
|
||||
api = $bindable(null),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.RootProps & {
|
||||
api?: CommandRootApi | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Root
|
||||
bind:this={api}
|
||||
bind:value
|
||||
bind:ref
|
||||
data-slot="command"
|
||||
class={cn("bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,37 @@
|
||||
import Root from "./command.svelte";
|
||||
import Loading from "./command-loading.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
import LinkItem from "./command-link-item.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
LinkItem,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
LinkItem as CommandLinkItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import Root from "./input-group.svelte";
|
||||
import Addon from "./input-group-addon.svelte";
|
||||
import Button from "./input-group-button.svelte";
|
||||
import Input from "./input-group-input.svelte";
|
||||
import Text from "./input-group-text.svelte";
|
||||
import Textarea from "./input-group-textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Addon,
|
||||
Button,
|
||||
Input,
|
||||
Text,
|
||||
Textarea,
|
||||
//
|
||||
Root as InputGroup,
|
||||
Addon as InputGroupAddon,
|
||||
Button as InputGroupButton,
|
||||
Input as InputGroupInput,
|
||||
Text as InputGroupText,
|
||||
Textarea as InputGroupTextarea,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
export const inputGroupAddonVariants = tv({
|
||||
base: "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
|
||||
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
|
||||
"block-start":
|
||||
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
|
||||
"block-end": "px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
});
|
||||
|
||||
export type InputGroupAddonAlign = VariantProps<typeof inputGroupAddonVariants>["align"];
|
||||
</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,
|
||||
align = "inline-start",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
align?: InputGroupAddonAlign;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
class={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onclick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
const inputGroupButtonVariants = tv({
|
||||
base: "gap-2 text-sm flex items-center shadow-none",
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "cn-input-group-button-size-sm",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
export type InputGroupButtonSize = VariantProps<typeof inputGroupButtonVariants>["size"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...restProps
|
||||
}: Omit<ComponentProps<typeof Button>, "href" | "size"> & {
|
||||
size?: InputGroupButtonSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
bind:ref
|
||||
{type}
|
||||
data-size={size}
|
||||
{variant}
|
||||
class={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</Button>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...props
|
||||
}: ComponentProps<typeof Input> = $props();
|
||||
</script>
|
||||
|
||||
<Input
|
||||
bind:ref
|
||||
data-slot="input-group-control"
|
||||
class={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
|
||||
bind:value
|
||||
{...props}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<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<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
class={cn("text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...props
|
||||
}: ComponentProps<typeof Textarea> = $props();
|
||||
</script>
|
||||
|
||||
<Textarea
|
||||
bind:ref
|
||||
data-slot="input-group-control"
|
||||
class={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
|
||||
bind:value
|
||||
{...props}
|
||||
/>
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user