Compare commits

...
35 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 5e29ff7ec3 Merge github/main: синхронизировать историю github и origin
Docker / build (push) Failing after 30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:49:50 +07:00
DenozordecandClaude Opus 5.5 a185ffa659 Merge branch 'claude/elated-pasteur-5c3133': новый дизайн фронтенда VPS-Tracker
Docker / build (push) Failing after 2m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:47:56 +07:00
DenozordecandClaude Opus 5.5 f38694da63 fix(notifications): изолировать уведомления по space и чинить отправку в Telegram
- «Истекает оплата» строится только по VPS текущего space
- ключ notification_state разделён по space: второй space больше не
  падает на PRIMARY KEY и не получает дубль каждый тик
- таймаут 10 с для Telegram и webhook
- текст от хостеров экранируется для parse_mode=HTML
- сообщения длиннее 4096 символов обрезаются по строке
- повторное падение того же VPS снова уведомляет
- ошибки по отдельным чатам пишутся в журнал при частичной доставке
- удалён неиспользуемый sendWebhook/notifyWebhook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:45:45 +07:00
DenozordecandClaude Opus 5.5 80af888cdb perf(api): чистить старые журналы и облегчить синк и dashboard
- раз в сутки удаляются notification_log и sync_log старше 90 дней и
  vps_health_checks старше 30 дней; синки, зависшие в running дольше
  часа, помечаются ошибкой
- dashboard stats не грузит тарифы, домены и проекты
- запросы характеристик VPS в BILLmanager идут по 4 параллельно

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:36:53 +07:00
DenozordecandClaude Opus 5.5 0b9b1cad54 fix(api): пускать к бэкапу и миграции только глобального админа
Скачивание и восстановление файла БД, JSON-бэкап и legacy-импорт
затрагивают все spaces и содержат учётные данные API, поэтому прав
vps:settings:admin в одном space для них недостаточно.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:36:53 +07:00
DenozordecandClaude Opus 5.5 273883f657 fix(api): закрыть SSRF в rates-proxy, проверять роль в space, убрать гонки синка
- rates-proxy запрашивает только сохранённый settings.ratesUrl
- viewer в space не может писать в платежи, баланс, провайдеров,
  аккаунты, проекты и запускать синк
- /api/sync/status показывает журнал только текущего space
- повторный синк аккаунта во время текущего отвечает 409
- тики планировщика не накладываются, каждый space идёт по своему интервалу

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:32:50 +07:00
DenozordecandClaude Opus 5.5 78f413324a fix(sync): не стирать тарифы при ошибке загрузки прайс-листа
Ошибка запроса тарифов превращалась в пустой список: active_tariffs
удалялись, а следующий успешный синк объявлял все тарифы новыми.
Пустой ответ теперь оставляет старый список, замена идёт в транзакции.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:32:50 +07:00
DenozordecandClaude Opus 5.5 01a771407d fix(sync): сохранять данные синка в текущий space и не склеивать VPS
Вставки VPS, платежей, тарифов и uptime-проверок не задавали spaceId и
уходили в space-main. Поиск существующего VPS матчил bm-1 с bm-12 и
сервера с пустым IP между собой; теперь сначала по id, затем по точному
токену в заметке, затем по непустому IP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:22:38 +07:00
DenozordecandClaude Opus 5.5 e37f69ebf0 fix(db): писать null в пустые FK и мигрировать старые БД без apiType
Пустая строка в vpsId/providerAccountId/providerId давала 500
SQLITE_CONSTRAINT_FOREIGNKEY при создании платежа, записи баланса и VPS
без провайдера. Старые БД падали с «no such column apiType»: добавлены
колонки providers.apiType/apiBaseUrl и перенос URL BILLmanager с аккаунтов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:22:37 +07:00
DenozordecandClaude Opus 5.5 a70955f90d docs(ui): отметить статус экранов в плане миграции и заголовок грида VPS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:07:49 +07:00
DenozordecandClaude Opus 5.5 166abb03b1 fix(web): крошки деталей, контраст warning и тёмный сайдбар на телефоне
Детальные VPS и проекта показывают «раздел / запись» в крошках, warning-значения
KPI читаемы в светлой теме, мобильный сайдбар в светлой теме остаётся тёмным,
заголовки групп не попадают в Tab-порядок свёрнутого меню.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:06:39 +07:00
DenozordecandClaude Opus 5.5 9483e1122d fix(web): учесть reduced motion у шеврона групп меню
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:03:14 +07:00
DenozordecandClaude Opus 5.5 e3403bcb56 feat(web): перевести настройки, VPS-детали и журнал на вкладки из дизайна VPS-Tracker
Настройки — line-вкладки сверху вместо боковой навигации, детали VPS —
line-вкладки и действия в шапке, журнал — общие CountedLineTabs, шапка
страницы 20px. Детальные маршруты VPS и проекта вынесены из вложенности
(vps_.$vpsId, projects_.$projectId): списки не рендерят Outlet, и детали
не открывались.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:02:04 +07:00
DenozordecandClaude Opus 5.5 b5300b9082 feat(web): перевести shell и дашборд на дизайн VPS-Tracker
Сворачиваемые группы меню с точками проблем, приложения в AppSwitcher вместо
AppsMenu в шапке, пилюля монитора со счётчиками, KPI со значением и бейджем
в одной строке, вкладки дашборда на уровне страницы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:57:47 +07:00
DenozordecandClaude Opus 5.5 10b20b9d55 feat(web): перевести тему на палитру и шрифты Geist из дизайна VPS-Tracker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:53:04 +07:00
DenozordecandClaude Opus 5.5 b3712af0c0 docs(ui): добавить план миграции на дизайн VPS-Tracker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:52:09 +07:00
denozordec b00fb9345f Add ReUI premium access configuration 2026-09-25 19:54:01 +07:00
denozordec e6ddb24964 Added mcp.json 2026-09-25 19:49:27 +07:00
DenozordecandCursor a64d8b9755 fix(topology): спрямить HA-линии и не показывать RR без пула
Docker / build (push) Failing after 19s
Membership идёт bezier по ближайшей грани; клик по сервису выделяет VPS-членов. Badge HA только при двух уникальных origin IP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:09:19 +07:00
DenozordecandCursor 5a61ce33b4 feat(topology): вынести сервис CFDM в отдельный узел со связями членства
Docker / build (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 14:20:09 +07:00
DenozordecandCursor 4dc5609f17 feat(topology): добавить группы сервисов CFDM на схему
Docker / build (push) Failing after 20s
Сервис ставится существующей группой с Badge типа резервирования; sync сохраняет lbMode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 11:30:40 +07:00
DenozordecandCursor 29325179f4 fix(ui): привести таблицы к стилю ReUI PRO
Docker / build (push) Failing after 23s
Статусы через ReUI Badge, колонка действий — outline-меню без pin-slab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 00:14:36 +07:00
DenozordecandCursor 573ca097aa fix(domains): привязывать общие FQDN ко всем совпавшим VPS
Docker / build (push) Failing after 29s
Общий binding с несколькими origin-IP больше не остаётся unmatched: строка размножается на каждый однозначно совпавший VPS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 22:02:06 +07:00
DenozordecandCursor 6e401533c7 feat(launcher): полный список ipregion на MikroTik (ros-8)
Docker / build (push) Failing after 27s
40 сервисов как у Linux: primary, custom и CDN. Имена совпадают с vernette/ipregion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 19:27:28 +07:00
DenozordecandCursor adea0ab070 fix(launcher): собирать ingest JSON MikroTik вручную (ros-7)
Docker / build (push) Failing after 23s
RouterOS serialize ломал тело POST. ros-7 пишет JSON строкой и печатает префикс при ошибке ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 16:30:47 +07:00
DenozordecandCursor f53ca1ed7d fix(launcher): принимать JSON-массив results от MikroTik
Docker / build (push) Failing after 28s
RouterOS serialize превращает results в объект с ключами 0,1 — API отвечал 400. ros-6 собирает массив, ingest принимает оба формата.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 15:59:30 +07:00
DenozordecandCursor d7bcd0d4ec fix(launcher): показывать ошибку ingest MikroTik и повторять без src-address
Docker / build (push) Failing after 23s
RouterOS скрывает HTTP 4xx как fetch error. ros-5 печатает код и тело ответа, затем повторяет POST без src-address.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 14:09:19 +07:00
DenozordecandCursor ecd053b832 fix(launcher): упростить пробы MikroTik до переменной vt-srcIp
Docker / build (push) Failing after 26s
Убраны автоопределение интерфейса и policy routing: исходный IP задаётся вручную на каждом роутере.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:55:17 +07:00
DenozordecandCursor 72aad059bd fix(launcher): выпускать пробы MikroTik через выбранный WAN
Docker / build (push) Failing after 24s
src-address недостаточно при нескольких default route: добавляем policy routing и снимаем правила после прогона.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:43:26 +07:00
DenozordecandCursor a9e3cce113 feat(launcher): требовать интерфейс для проверок MikroTik
Docker / build (push) Failing after 22s
Все пробы и ingest идут через src-address выбранного WAN, scheduler сохраняет vtIface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:22:25 +07:00
DenozordecandCursor c844874b0a feat(launcher): добавить скрипты GEO и blocking для MikroTik 7.22+
Docker / build (push) Failing after 27s
Пробы выполняются на роутере через /tool fetch и уходят в те же ingest API. Ежедневный запуск ставит scheduler, который каждый день заново качает .rsc со свежим токеном.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:47:04 +07:00
DenozordecandCursor b03318800f feat(launcher): добавить ежедневный запуск проверок cc и ic
Docker / build (push) Failing after 24s
Флаг --daily ставит cron, который раз в сутки заново качает launcher со свежим токеном. Снять: --remove-daily.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:18:14 +07:00
DenozordecandCursor 7480b9dc11 fix(geo): распознать CDN-локации и добавить иконки сервисов
Docker / build (push) Failing after 22s
CDN-значения вида SE (ARN) больше не помечаются как ошибка. У каждого GeoIP-сервиса — бренд-иконка, как в разделе блокировок.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 15:54:04 +07:00
DenozordecandCursor 235ae03489 feat(geo): добавить раздел IP-регионов
Docker / build (push) Failing after 26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 15:16:17 +07:00
DenozordecandCursor 60a22b9450 feat(blocking): определять и показывать хостер в матрице блокировок
Docker / build (push) Failing after 26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 13:19:02 +07:00
153 changed files with 12866 additions and 1367 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"reui_pro": {
"enabled": true
}
}
}
+101
View File
@@ -0,0 +1,101 @@
---
name: EvoBGP без NATS
overview: "Код EvoBGP уже работает без NATS и прочих брокеров: очередь задач in-process в `evobgp-all`. Оптимизировать Go под ваш compose не требуется — достаточно подчистить сам compose (убрать мёртвый сервис nats) и по желанию ужесточить prod-флаги."
todos:
- id: compose-drop-nats
content: Удалить nats + depends_on + runtime-logs entry из production.example (и sync microvps-full при необходимости)
status: pending
- id: docs-align
content: "Синхронизировать quickstart/architecture: NATS optional, minimal stack без брокера"
status: pending
- id: server-apply
content: На сервере применить тот же compose diff (без правок Go)
status: pending
isProject: false
---
# Анализ compose без NATS: нужен ли рефакторинг EvoBGP?
## Вердикт
**Нет — оптимизировать текущий Go-код под этот compose не нужно.** Приложение уже спроектировано как monolith (`evobgp-all`) с in-process очередью. NATS в репозитории — задел на будущее, не рабочая зависимость.
## Как устроен runtime без брокера
```mermaid
flowchart LR
subgraph host [Хост /opt/evobgp]
Web[evobgp-web nginx]
All[evobgp-all]
PG[(postgres)]
Bird[bird2]
Agent[evobgp-agent]
Edge[evobgp-edge Traefik]
end
Edge --> Web
Web -->|"/v1 proxy"| All
All --> PG
All --> Bird
Agent --> Bird
All -->|"HTTP wake NODE_DISPATCH"| Agent
```
| Компонент | Роль для кода | Нужен ли NATS? |
|-----------|---------------|----------------|
| `evobgp-all` | API + jobs + scheduler/ingest/render/deploy in-process | Нет |
| `postgres` | Store + `job_audit` reclaim (ADR-001) | Нет |
| `bird2` + `evobgp-agent` | Локальный speaker / birdc | Нет |
| `EVOBGP_NODE_DISPATCH_ENABLED` | HTTP `POST` на remote agent (`internal/nodedispatch`) | Нет |
| `EVOBGP_BROKER_URL` | Только [`internal/broker/connect.go`](c:/Users/shats/Dev/EvoBGP/internal/broker/connect.go) — лог, если URL задан | Нет |
| NATS-контейнер | Не читается бинарником | Можно удалить |
Источник правды: [`docs/architecture.md`](c:/Users/shats/Dev/EvoBGP/docs/architecture.md), [`docs/adr/001-durable-job-queue.md`](c:/Users/shats/Dev/EvoBGP/docs/adr/001-durable-job-queue.md) — «NATS JetStream remains optional (`EVOBGP_BROKER_URL` logged only)».
Ваш закомментированный `# EVOBGP_BROKER_URL` — корректно. Даже если URL оставить, поведение jobs не изменится.
## Что в вашем YAML лишнее / рискованное (ops, не код)
**Можно убрать без правок Go:**
1. Сервис `nats` целиком.
2. `depends_on: nats` у `evobgp-all`.
3. `nats` из списка `SERVICES` в `stack-runtime-logs`.
4. Проброс `4222:4222` (если не нужен снаружи).
**Prometheus** — тоже опционален для функционала: скрейпит `/metrics` с `evobgp-all`. Удаление не ломает BGP/UI; ломает только локальный monitoring. Код менять не нужно.
**Не трогать без замены:** `postgres`, `bird2`, `evobgp-agent`, `evobgp-all`, `evobgp-web`, `evobgp-edge`, volumes `bird_*` / `pgdata` / ACME.
## Prod-флаги в compose (важно, но это конфиг, не рефакторинг)
Сейчас example и ваш файл совпадают с lab-режимом:
- `EVOBGP_SEED_DEMO: "1"` → доступен `Bearer dev` (см. [`docs/access.md`](c:/Users/shats/Dev/EvoBGP/docs/access.md), [`docs/production-checklist.md`](c:/Users/shats/Dev/EvoBGP/docs/production-checklist.md))
- `EVOBGP_DEV_INSECURE: "1"` — dead/lab флаг; при `EVOBGP_PRODUCTION=1` процесс **не стартует** ([`internal/config/production.go`](c:/Users/shats/Dev/EvoBGP/internal/config/production.go))
- `POSTGRES_PASSWORD: evobgp` + `sslmode=disable` — ок только в private Docker network
- `EVOBGP_BUNDLE_SEED_HEX` захардкожен в yaml — ок для lab; в бою лучше свой секрет в `.env`
Для «настоящего» prod достаточно env, не переписывания архитектуры.
## Нужны ли изменения в коде проекта?
| Вопрос | Ответ |
|--------|--------|
| Убрать/упростить `internal/broker`? | Не обязательно: пакет уже no-op без URL |
| Переписать jobs на PG-only? | Уже есть durable reclaim через `job_audit`; брокер не требуется |
| Вырезать NATS из бинарника/зависимостей? | В Go NATS-клиент как runtime consumer не подключён — нечего «оптимизировать» |
| Менять `evobgp-all` под monolith? | Уже monolith; reference-воркеры (`scheduler`/`ingest`/…) вам не нужны |
**Итог:** отказ от NATS — правильное решение для текущего этапа; код уже соответствует этой модели. Оптимизация = правка compose/docs, не приложения.
## Рекомендуемые действия (если согласуете выполнение)
Только инфраструктура/документация в репо:
1. Обновить [`deploy/compose/docker-compose.production.example.yaml`](c:/Users/shats/Dev/EvoBGP/deploy/compose/docker-compose.production.example.yaml) (и при необходимости `stack.microvps-full.yaml` / `docker-compose.microvps-full.yaml`): удалить `nats`, `depends_on`, порт 4222, упоминание в runtime-logs.
2. Подправить [`docs/quickstart.md`](c:/Users/shats/Dev/EvoBGP/docs/quickstart.md) / architecture: NATS — optional future, не часть minimal production stack.
3. На сервере: тот же diff в `/opt/evobgp/docker-compose.yaml` → `docker compose up -d` (nats исчезнет).
Go/CI/образы — **не трогать** ради этого решения.
Опциональный hardening env на сервере (отдельно от «оптимизации кода»): `EVOBGP_SEED_DEMO=0`, убрать `EVOBGP_DEV_INSECURE`, при желании `EVOBGP_PRODUCTION=1` + checklist.
+3
View File
@@ -1 +1,4 @@
apps/api/scripts/censorcheck/*.sh text eol=lf
apps/api/scripts/censorcheck/*.rsc text eol=lf
apps/api/scripts/ipregion/*.sh text eol=lf
apps/api/scripts/ipregion/*.rsc text eol=lf
+16 -1
View File
@@ -93,13 +93,28 @@ vps-tracker/
## Статус блокировок (censorcheck)
Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/cc | bash` (тот же контейнер, Traefik dual Host).
Раз в сутки: `curl -fsSL https://vt.shnt.top/cc | bash -s -- --daily` (cron, свежий HMAC-токен на каждый запуск).
MikroTik 7.22+: `:global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc` (ежедневно: `?daily=1`; все пробы через `src-address` этого IP).
- **Vendor:** `apps/api/scripts/censorcheck/censorcheck.sh` (pin SHA `12c5839`, MIT)
- **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); по `/etc/os-release` ставит `jq`/`dig`/`column` без prompt; прогресс-бар в stderr; `GET /cc/vendor` — скрипт (LF)
- **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); по `/etc/os-release` ставит `jq`/`dig`/`column` без prompt; прогресс-бар в stderr; `GET /cc/vendor` — скрипт (LF); `--daily` / `--remove-daily`
- **MikroTik:** `GET /cc.rsc` — RouterOS-скрипт (HTTPS GET, без DPI); обязателен `:global vt-srcIp`; `?daily=1` / `?remove=daily`; scheduler `vt-cc`
- **Ingest:** `POST /api/integrations/censorcheck/runs` (без portal JWT)
- **UI:** `/blocking` — текущие прогоны и история, группировка VPS / сервис
- Env: `CENSORCHECK_INGEST_SECRET`, `CENSORCHECK_PUBLIC_URL`, `VPS_LAUNCHER_DOMAIN`
## GeoIP (ipregion)
Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/ic | bash`.
Раз в сутки: `curl -fsSL https://vt.shnt.top/ic | bash -s -- --daily`.
MikroTik 7.22+: `:global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc` (`?daily=1`; пробы через тот же `vt-srcIp`).
- **Vendor:** `apps/api/scripts/ipregion/ipregion.sh` (pin SHA `7d1c25c`, MIT, [vernette/ipregion](https://github.com/vernette/ipregion))
- **Launcher:** `GET /ic` минтит HMAC ingest-токен (тот же `CENSORCHECK_INGEST_SECRET`); `GET /ic/vendor` — pinned скрипт (LF); `--daily` / `--remove-daily`
- **MikroTik:** `GET /ic.rsc` — полный список vernette/ipregion (primary + custom + cdn, как Linux); обязателен `:global vt-srcIp`; scheduler `vt-ic`
- **Ingest:** `POST /api/integrations/ipregion/runs` (без portal JWT)
- **UI:** `/geo` — матрица ISO-стран VPS × сервисы (primary / custom / cdn)
## Команды
```bash
+212
View File
@@ -0,0 +1,212 @@
# VPS Tracker — blocking launcher for RouterOS 7.22+
# First run: :global "vt-srcIp" "1.2.3.4"; /tool fetch url="__VT_API_URL__/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc
# HTTPS GET only (no DPI/SNI). Ingest: POST /api/integrations/censorcheck/runs
:local vtApi "__VT_API_URL__"
:local vtToken "__VT_INGEST_TOKEN__"
:local vtDaily "__VT_DAILY__"
:local vtRemove "__VT_REMOVE_DAILY__"
:local launcherVer "ros-7"
:local schedName "vt-cc"
:local dstFile "vt-cc.rsc"
:local ver [/system resource get version]
:local dot [:find $ver "."]
:if ([:typeof $dot] = "nil") do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:local major [:tonum [:pick $ver 0 $dot]]
:local rest [:pick $ver ($dot + 1) [:len $ver]]
:local cut [:len $rest]
:local d2 [:find $rest "."]
:local sp [:find $rest " "]
:if ([:typeof $d2] != "nil") do={ :set cut $d2 }
:if (([:typeof $sp] != "nil") and (($cut = [:len $rest]) or ($sp < $cut))) do={ :set cut $sp }
:local minor [:tonum [:pick $rest 0 $cut]]
:if (($major < 7) or (($major = 7) and ($minor < 22))) do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:if ($vtRemove = "yes") do={
:do { /system scheduler remove [find name=$schedName] } on-error={}
:put ("Ежедневная проверка снята (" . $schedName . ")")
} else={
:put ("censorcheck launcher " . $launcherVer . " (RouterOS)")
:global "vt-srcIp"
:if (([:typeof $"vt-srcIp"] != "str") or ([:len $"vt-srcIp"] = 0)) do={
:put "Задайте исходный IP и повторите импорт:"
:put ":global \"vt-srcIp\" \"1.2.3.4\""
:error "vt-srcIp не задан"
}
:local srcIp $"vt-srcIp"
:put ("vt-srcIp: " . $srcIp)
:local ingestOk false
:local r
:local publicIp ""
:do {
:set r [/tool fetch url="https://api.ipify.org" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:local cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:local lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
:if ([:len $publicIp] = 0) do={
:do {
:set r [/tool fetch url="https://ifconfig.me/ip" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:set cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:set lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
}
:if ([:len $publicIp] = 0) do={
:error "Не удалось определить публичный IP"
}
:local hoster ""
:do {
:set r [/tool fetch url=("https://ipwho.is/" . $publicIp) src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:local j [:deserialize from=json value=($r->"data")]
:local isp ($j->"connection"->"isp")
:if ([:typeof $isp] = "str") do={ :set hoster $isp }
:if ([:len $hoster] = 0) do={
:local org ($j->"org")
:if ([:typeof $org] = "str") do={ :set hoster $org }
}
}
} on-error={}
:local runId ("mt-" . [:rndstr length=16])
:put ("probe IP: " . $publicIp)
:if ([:len $hoster] > 0) do={ :put ("хостер: " . $hoster) }
:put ("runId: " . $runId)
:put "Проверяю сайты (HTTPS GET, без DPI)..."
:local hosts {\
"youtube.com";"redirector.googlevideo.com";"discord.com";"instagram.com";"facebook.com";\
"x.com";"linkedin.com";"rutracker.org";"digitalocean.com";"amnezia.org";"getoutline.org";\
"mailfence.com";"flibusta.is";"rezka.ag";"api.telegram.org";"play.google.com";\
"spotify.com";"netflix.com";"patreon.com";"swagger.io";"snyk.io";"mongodb.com";\
"autodesk.com";"graylog.org";"redis.io";"copilot.microsoft.com"\
}
:local resultJson ""
:local itemJson ""
:local code 0
:local data
:local line
:local nl
:local cr2
:local sp1
:local rest2
:local sp2
:local codeStr
:local n
:foreach host in=$hosts do={
:set code 0
:do {
:set r [/tool fetch url=("https://" . $host . "/") src-address=$srcIp output=user-with-headers as-value]
:if (($r->"status") != "finished") do={
:set code -1
} else={
:set data ($r->"data")
:set line $data
:set nl [:find $data "\n"]
:if ([:typeof $nl] != "nil") do={ :set line [:pick $data 0 $nl] }
:set cr2 [:find $line "\r"]
:if ([:typeof $cr2] != "nil") do={ :set line [:pick $line 0 $cr2] }
:if ([:pick $line 0 4] = "HTTP") do={
:set sp1 [:find $line " "]
:if ([:typeof $sp1] != "nil") do={
:set rest2 [:pick $line ($sp1 + 1) [:len $line]]
:set sp2 [:find $rest2 " "]
:set codeStr $rest2
:if ([:typeof $sp2] != "nil") do={ :set codeStr [:pick $rest2 0 $sp2] }
:set n [:tonum $codeStr]
:if ([:typeof $n] = "num") do={ :set code $n } else={ :set code 200 }
} else={ :set code 200 }
} else={ :set code 200 }
}
} on-error={
:set code 0
}
:put ($host . " " . $code)
:set itemJson ("{\"service\":\"" . $host . "\",\"raw\":{\"https\":{\"ipv4\":{\"status\":" . $code . "}}}}")
:if ([:len $resultJson] > 0) do={ :set resultJson ($resultJson . ",") }
:set resultJson ($resultJson . $itemJson)
}
:local hosterJson ""
:if ([:len $hoster] > 0) do={
:local esc ""
:local hi 0
:while ($hi < [:len $hoster]) do={
:local ch [:pick $hoster $hi ($hi + 1)]
:if ($ch = "\\") do={ :set esc ($esc . "\\\\") } else={
:if ($ch = "\"") do={ :set esc ($esc . "\\\"") } else={ :set esc ($esc . $ch) }
}
:set hi ($hi + 1)
}
:set hosterJson (",\"hoster\":\"" . $esc . "\"")
}
:if ([:len $resultJson] = 0) do={ :error "ingest: results пустой" }
:local json ("{\"schemaVersion\":1,\"runId\":\"" . $runId . "\",\"probe\":{\"publicIp\":\"" . $publicIp . "\"" . $hosterJson . "},\"launcherVersion\":\"" . $launcherVer . "\",\"censorcheck\":{\"version\":\"ros\",\"mode\":\"https\"},\"results\":[" . $resultJson . "]}")
:local hdrs ("Content-Type: application/json,Authorization: Bearer " . $vtToken)
:put ("Отправляю ingest (" . [:len $json] . " B)...")
:set ingestOk false
:onerror err,attr in={
:set r [/tool fetch url=($vtApi . "/api/integrations/censorcheck/runs") src-address=$srcIp http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
:set ingestOk true
} do={
:put ("ingest: " . $err)
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"code")] != "nil")) do={
:put ("code: " . ($attr->"code"))
}
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"data")] = "str") and ([:len ($attr->"data")] > 0)) do={
:put ($attr->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
:if ($ingestOk = false) do={
:put "повтор ingest без src-address..."
:onerror err2,attr2 in={
:set r [/tool fetch url=($vtApi . "/api/integrations/censorcheck/runs") http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
} do={
:put ("ingest retry: " . $err2)
:if (([:typeof $attr2] = "array") and ([:typeof ($attr2->"data")] = "str") and ([:len ($attr2->"data")] > 0)) do={
:put ($attr2->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
}
:if ($vtDaily = "yes") do={
:local ident [/system identity get name]
:local h ([:len $ident] + [:len [/system resource get architecture-name]])
:local hour (3 + ($h % 4))
:local minute (($h * 17) % 60)
:local hh $hour
:local mm $minute
:if ($hour < 10) do={ :set hh ("0" . $hour) }
:if ($minute < 10) do={ :set mm ("0" . $minute) }
:local startTime ($hh . ":" . $mm . ":00")
:local ev (":global \"vt-srcIp\" \"" . $srcIp . "\"; /tool fetch url=" . $vtApi . "/cc.rsc dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
:do { /system scheduler remove [find name=$schedName] } on-error={}
/system scheduler add name=$schedName interval=1d start-time=$startTime on-event=$ev policy=read,write,test,policy comment="vps-tracker vt-cc"
:put ("Ежедневная проверка: каждый день в " . $startTime . " (" . $schedName . ")")
:put ("Снять: /tool fetch url=" . $vtApi . "/cc.rsc?remove=daily dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
}
}
+211 -3
View File
@@ -5,8 +5,10 @@ set -euo pipefail
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
LAUNCHER_VERSION="4"
LAUNCHER_VERSION="6"
VENDOR_SHA="12c5839"
DAILY_SLUG="vt-censorcheck"
DAILY_PATH="/cc"
OS_ID="unknown"
OS_LIKE=""
@@ -165,6 +167,184 @@ detect_public_ip() {
printf '%s' "$ip"
}
# Короткий таймаут: на обычном VPS link-local просто не ответит.
curl_meta() {
curl -fsS --connect-timeout 1 --max-time 1 "$@" 2>/dev/null || true
}
detect_cloud_hoster() {
local body=""
body="$(curl_meta http://169.254.169.254/hetzner/v1/metadata)"
if [ -n "$body" ]; then
printf 'Hetzner'
return 0
fi
body="$(curl_meta http://169.254.169.254/metadata/v1/id)"
if [ -n "$body" ]; then
printf 'DigitalOcean'
return 0
fi
body="$(curl_meta http://169.254.169.254/v1/instanceid)"
if [ -n "$body" ]; then
printf 'Vultr'
return 0
fi
body="$(curl_meta http://169.254.169.254/linode/v1/instance-id)"
if [ -n "$body" ]; then
printf 'Linode'
return 0
fi
body="$(curl_meta -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id)"
if [ -n "$body" ]; then
printf 'Google Cloud'
return 0
fi
body="$(curl_meta -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01')"
if [ -n "$body" ]; then
printf 'Azure'
return 0
fi
body="$(curl_meta http://169.254.169.254/latest/meta-data/instance-id)"
if [ -n "$body" ]; then
printf 'AWS'
return 0
fi
return 1
}
detect_asn_org() {
local ip="$1" json="" org=""
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipwho.is/${ip}" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.connection.org // .org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipinfo.io/${ip}/json" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
return 1
}
detect_ptr_hint() {
local ip="$1" ptr=""
command -v dig >/dev/null 2>&1 || return 1
ptr="$(dig +short -x "$ip" 2>/dev/null | awk 'NF{print; exit}' | tr -d '\r' | sed 's/\.$//')"
[ -n "$ptr" ] || return 1
printf '%s' "$ptr"
}
detect_hoster() {
local ip="$1" value=""
value="$(detect_cloud_hoster)" && { printf '%s' "$value"; return 0; }
value="$(detect_asn_org "$ip")" && { printf '%s' "$value"; return 0; }
value="$(detect_ptr_hint "$ip")" && { printf '%s' "$value"; return 0; }
return 1
}
show_launcher_help() {
cat <<EOF
Использование:
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --daily
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily
--daily выполнить проверку и поставить cron раз в сутки
--remove-daily убрать ежедневный cron
EOF
}
daily_run_cmd() {
local url="${VT_API_URL}${DAILY_PATH}"
local curl_bin bash_bin flock_bin
curl_bin="$(command -v curl)"
bash_bin="$(command -v bash)"
flock_bin="$(command -v flock || true)"
if [ -n "$flock_bin" ]; then
printf "%s -n /tmp/%s.lock -c '%s -fsSL --max-time 180 %s | %s'" \
"$flock_bin" "$DAILY_SLUG" "$curl_bin" "$url" "$bash_bin"
else
printf '%s -fsSL --max-time 180 %s | %s' "$curl_bin" "$url" "$bash_bin"
fi
}
daily_slot() {
local seed n
seed="$(hostname -f 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo vt)"
n="$(printf '%s' "$seed" | cksum | awk '{print $1}')"
DAILY_MINUTE=$((n % 60))
DAILY_HOUR=$((3 + (n / 60) % 4))
}
remove_daily() {
local prefix="" removed=0
if [ -e "/etc/cron.d/${DAILY_SLUG}" ]; then
if prefix="$(root_prefix)"; then
# shellcheck disable=SC2086
$prefix rm -f "/etc/cron.d/${DAILY_SLUG}"
removed=1
fi
fi
if command -v crontab >/dev/null 2>&1; then
local current
current="$(crontab -l 2>/dev/null || true)"
if printf '%s\n' "$current" | grep -qF "vps-tracker:${DAILY_SLUG}"; then
printf '%s\n' "$current" | grep -vF "vps-tracker:${DAILY_SLUG}" | crontab -
removed=1
fi
fi
if [ "$removed" -eq 1 ]; then
log "Ежедневная проверка снята (${DAILY_SLUG})"
else
log "Ежедневная проверка не была установлена"
fi
}
install_daily() {
daily_slot
local run line prefix=""
local marker="# vps-tracker:${DAILY_SLUG}"
run="$(daily_run_cmd)"
if [ -d /etc/cron.d ] && prefix="$(root_prefix)"; then
local tmp
tmp="$(mktemp)"
cat >"$tmp" <<EOF
# Managed by VPS Tracker launcher. ${marker}
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
${DAILY_MINUTE} ${DAILY_HOUR} * * * root ${run} >>/var/log/${DAILY_SLUG}.log 2>&1
EOF
# shellcheck disable=SC2086
$prefix cp "$tmp" "/etc/cron.d/${DAILY_SLUG}"
# shellcheck disable=SC2086
$prefix chmod 644 "/etc/cron.d/${DAILY_SLUG}"
rm -f "$tmp"
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (/etc/cron.d/${DAILY_SLUG})"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
die "Нет cron (ни /etc/cron.d, ни crontab). Установите пакет cron и повторите --daily"
fi
line="${DAILY_MINUTE} ${DAILY_HOUR} * * * ${run} >>/tmp/${DAILY_SLUG}.log 2>&1 ${marker}"
{
(crontab -l 2>/dev/null || true) | grep -vF "vps-tracker:${DAILY_SLUG}" || true
printf '%s\n' "$line"
} | crontab -
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (crontab)"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
}
write_vendor() {
local dest="$1"
if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then
@@ -191,6 +371,22 @@ VT_API_URL="${VT_API_URL%/}"
[ -n "$VT_API_URL" ] || die "VT_API_URL пуст"
[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст"
DAILY_INSTALL=0
DAILY_REMOVE=0
for arg in "$@"; do
case "$arg" in
--daily|--install-daily) DAILY_INSTALL=1 ;;
--remove-daily|--uninstall-daily) DAILY_REMOVE=1 ;;
-h|--help) show_launcher_help; exit 0 ;;
*) die "Неизвестный аргумент: $arg. См. --help" ;;
esac
done
if [ "$DAILY_REMOVE" -eq 1 ]; then
remove_daily
exit 0
fi
TMPDIR="$(mktemp -d /tmp/vt-censorcheck.XXXXXX)"
cleanup() { rm -rf "$TMPDIR"; }
trap 'cleanup; exit 130' INT
@@ -203,11 +399,14 @@ chmod +x "$VENDOR"
PUBLIC_IP="$(detect_public_ip)"
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
HOSTER="$(detect_hoster "$PUBLIC_IP" || true)"
RUN_ID="$(uuid4)"
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
log "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
log "probe IP: ${PUBLIC_IP}"
log "хостер: ${HOSTER:-не определён}"
log "runId: ${RUN_ID}"
log "Проверяю сайты (последовательно, несколько минут)..."
@@ -222,11 +421,11 @@ if [ "$CC_EXIT" -ne 0 ]; then
fi
PAYLOAD="$TMPDIR/payload.json"
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" '
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" --arg hoster "$HOSTER" '
{
schemaVersion: 1,
runId: $runId,
probe: { publicIp: $ip },
probe: ({ publicIp: $ip } + if ($hoster | length) > 0 then { hoster: $hoster } else {} end),
launcherVersion: $lv,
censorcheck: {
version: ((.version | tostring) // "1"),
@@ -251,6 +450,9 @@ set -e
if [ "$POST_EXIT" -ne 0 ]; then
cp "$PAYLOAD" "$FALLBACK"
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
@@ -260,9 +462,15 @@ if [ -z "$CHECK_ID" ]; then
cp "$PAYLOAD" "$FALLBACK"
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
log "$RESP" >&2
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
log "Check ID: ${CHECK_ID}"
log "VPS: ${MATCHED}"
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 0
File diff suppressed because it is too large. Load diff
+646
View File
@@ -0,0 +1,646 @@
# VPS Tracker — GeoIP launcher for RouterOS 7.22+
# First run: :global "vt-srcIp" "1.2.3.4"; /tool fetch url="__VT_API_URL__/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc
# Полный список vernette/ipregion (primary + custom + cdn, без Google Search Captcha). Ingest: POST /api/integrations/ipregion/runs
:local vtApi "__VT_API_URL__"
:local vtToken "__VT_INGEST_TOKEN__"
:local vtDaily "__VT_DAILY__"
:local vtRemove "__VT_REMOVE_DAILY__"
:local launcherVer "ros-8"
:local schedName "vt-ic"
:local dstFile "vt-ic.rsc"
:local ver [/system resource get version]
:local dot [:find $ver "."]
:if ([:typeof $dot] = "nil") do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:local major [:tonum [:pick $ver 0 $dot]]
:local rest [:pick $ver ($dot + 1) [:len $ver]]
:local cut [:len $rest]
:local d2 [:find $rest "."]
:local sp [:find $rest " "]
:if ([:typeof $d2] != "nil") do={ :set cut $d2 }
:if (([:typeof $sp] != "nil") and (($cut = [:len $rest]) or ($sp < $cut))) do={ :set cut $sp }
:local minor [:tonum [:pick $rest 0 $cut]]
:if (($major < 7) or (($major = 7) and ($minor < 22))) do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:if ($vtRemove = "yes") do={
:do { /system scheduler remove [find name=$schedName] } on-error={}
:put ("Ежедневная проверка снята (" . $schedName . ")")
} else={
:put ("ipregion launcher " . $launcherVer . " (RouterOS)")
:global "vt-srcIp"
:if (([:typeof $"vt-srcIp"] != "str") or ([:len $"vt-srcIp"] = 0)) do={
:put "Задайте исходный IP и повторите импорт:"
:put ":global \"vt-srcIp\" \"1.2.3.4\""
:error "vt-srcIp не задан"
}
:local srcIp $"vt-srcIp"
:put ("vt-srcIp: " . $srcIp)
:local ingestOk false
:local ua "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
:local twitchClientId "kimne78kx3ncx6brgo4mv6wki5h1ko"
:local chatgptKey "client-zUdXdSTygXJdzoE0sWTkP8GKTVsUMF2IRM7ShVO2JAG"
:local spotifyKey "142b583129b2df829de3656f9eb484e6"
:local spotifyClientId "9a8d2f0ce77a4e248bb71fefcb557637"
:local netflixKey "YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm"
:local redditBasic "b2hYcG9xclpZdWIxa2c6"
:local disneyKey "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
:local youtubeSocs "CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjUwNzMwLjA1X3AwGgJlbiACGgYIgPC_xAY"
:local disneyBody "{\"query\":\" mutation registerDevice($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } } \",\"variables\":{\"registerDevice\":{\"applicationRuntime\":\"android\",\"attributes\":{\"operatingSystem\":\"Android\",\"operatingSystemVersion\":\"13\"},\"deviceFamily\":\"android\",\"deviceLanguage\":\"en\",\"deviceProfile\":\"phone\",\"devicePlatformId\":\"android\"}},\"operationName\":\"registerDevice\"}"
:local twitchBody "[{\"operationName\":\"VerifyEmail_CurrentUser\",\"variables\":{},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f9e7dcdf7e99c314c82d8f7f725fab5f99d1df3d7359b53c9ae122deec590198\"}}}]"
:local redditGqlBody "{\"operationName\":\"UserLocation\",\"variables\":{},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f07de258c54537e24d7856080f662c1b1268210251e5789c8c08f20d76cc8ab2\"}}}"
:local redditUa "Reddit/Version 2025.29.0/Build 2529021/Android 13"
:local r
:local j
:local isp
:local org
:local publicIp ""
:do {
:set r [/tool fetch url="https://api.ipify.org" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:local cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:local lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
:if ([:len $publicIp] = 0) do={
:do {
:set r [/tool fetch url="https://ifconfig.me/ip" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:set cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:set lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
}
:if ([:len $publicIp] = 0) do={
:error "Не удалось определить публичный IP"
}
:local hoster ""
:do {
:set r [/tool fetch url=("https://ipwho.is/" . $publicIp) src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set isp ($j->"connection"->"isp")
:if ([:typeof $isp] = "str") do={ :set hoster $isp }
:if ([:len $hoster] = 0) do={
:set org ($j->"org")
:if ([:typeof $org] = "str") do={ :set hoster $org }
}
}
} on-error={}
:local runId ("mt-" . [:rndstr length=16])
:put ("probe IP: " . $publicIp)
:if ([:len $hoster] > 0) do={ :put ("хостер: " . $hoster) }
:put ("runId: " . $runId)
:put "Проверяю GeoIP (JSON, IPv4)..."
:local names {\
"maxmind.com";"rdap.db.ripe.net";"ipinfo.io";"cloudflare.com";"ipregistry.co";\
"ipapi.co";"ifconfig.co";"ip2location.io";"iplocation.com";"country.is";\
"geoapify.com";"geojs.io";"ipapi.is";"ipbase.com";"ipquery.io";"ipwho.is";\
"ip-api.com";\
"Google";"YouTube";"Twitch";"ChatGPT";"Netflix";"Spotify";"Reddit";"Disney+";\
"Gemini Supported";"Reddit (Guest Access)";"YouTube Premium";"Spotify Signup";\
"Disney+ Access";"Apple";"Steam";"Tiktok";"Ookla Speedtest";"JetBrains";\
"PlayStation";"Microsoft";\
"Cloudflare CDN";"YouTube CDN";"Netflix CDN"\
}
:local resultJson ""
:local itemJson ""
:local url ""
:local method "get"
:local postData ""
:local fetchHdrs ""
:local iso "N/A"
:local httpCode 0
:local body ""
:local data
:local line
:local nl
:local cr2
:local sp1
:local rest2
:local sp2
:local codeStr
:local n
:local sep
:local p
:local plain
:local pcr
:local plf
:local ctry
:local colo
:local iata
:local ci
:local marker
:local tmp
:local tmp2
:local tmp3
:local googleCode ""
:local token ""
:local statusStr ""
:local launched ""
:local errCnt ""
:local inLoc ""
:local countryName ""
:local regionsMd ""
:local lineStart ""
:local iataLoc ""
:local word3 ""
:local dashPos ""
:local hdrData ""
:local wi 0
:local wc 0
:foreach name in=$names do={
:set url ""
:set method "get"
:set postData ""
:set fetchHdrs ""
:if ($name = "maxmind.com") do={
:set url "https://geoip.maxmind.com/geoip/v2.1/city/me"
:set fetchHdrs "Referer: https://www.maxmind.com"
}
:if ($name = "rdap.db.ripe.net") do={ :set url ("https://rdap.db.ripe.net/ip/" . $publicIp) }
:if ($name = "ipinfo.io") do={ :set url ("https://ipinfo.io/widget/demo/" . $publicIp) }
:if ($name = "cloudflare.com") do={ :set url "https://speed.cloudflare.com/meta" }
:if ($name = "ipregistry.co") do={
:set url ("https://api.ipregistry.co/" . $publicIp . "?hostname=true&key=sb69ksjcajfs4c")
:set fetchHdrs "Origin: https://ipregistry.co"
}
:if ($name = "ipapi.co") do={ :set url ("https://ipapi.co/" . $publicIp . "/json") }
:if ($name = "ifconfig.co") do={ :set url ("https://ifconfig.co/country-iso?ip=" . $publicIp) }
:if ($name = "ip2location.io") do={ :set url ("https://api.ip2location.io/?ip=" . $publicIp) }
:if ($name = "iplocation.com") do={
:set url "https://iplocation.com"
:set method "post"
:set postData ("ip=" . $publicIp)
:set fetchHdrs ("User-Agent: " . $ua)
}
:if ($name = "country.is") do={ :set url ("https://api.country.is/" . $publicIp) }
:if ($name = "geoapify.com") do={ :set url ("https://api.geoapify.com/v1/ipinfo?&ip=" . $publicIp . "&apiKey=b8568cb9afc64fad861a69edbddb2658") }
:if ($name = "geojs.io") do={ :set url ("https://get.geojs.io/v1/ip/country.json?ip=" . $publicIp) }
:if ($name = "ipapi.is") do={ :set url ("https://api.ipapi.is/?q=" . $publicIp) }
:if ($name = "ipbase.com") do={ :set url ("https://api.ipbase.com/v2/info?ip=" . $publicIp) }
:if ($name = "ipquery.io") do={ :set url ("https://api.ipquery.io/" . $publicIp) }
:if ($name = "ipwho.is") do={ :set url ("https://ipwho.is/" . $publicIp) }
:if ($name = "ip-api.com") do={
:set url ("https://demo.ip-api.com/json/" . $publicIp . "?fields=countryCode")
:set fetchHdrs "Origin: https://ip-api.com"
}
:if ($name = "Google") do={
:set url "https://accounts.google.com/v3/signin/identifier?flowName=GlifSetupAndroid"
:set fetchHdrs ("User-Agent: " . $ua)
}
:if ($name = "YouTube") do={ :set url "https://www.youtube.com/sw.js_data" }
:if ($name = "Twitch") do={
:set url "https://gql.twitch.tv/gql"
:set method "post"
:set postData $twitchBody
:set fetchHdrs ("Client-Id: " . $twitchClientId . ",Content-Type: application/json")
}
:if ($name = "ChatGPT") do={
:set url "https://ab.chatgpt.com/v1/initialize"
:set method "post"
:set postData "{}"
:set fetchHdrs ("Statsig-Api-Key: " . $chatgptKey . ",Content-Type: application/json")
}
:if ($name = "Netflix") do={ :set url ("https://api.fast.com/netflix/speedtest/v2?https=true&token=" . $netflixKey . "&urlCount=1") }
:if ($name = "Spotify") do={
:set url ("https://spclient.wg.spotify.com/signup/public/v1/account/?validate=1&key=" . $spotifyKey)
:set fetchHdrs ("X-Client-Id: " . $spotifyClientId)
}
:if ($name = "Reddit") do={ :set url "reddit-oauth" }
:if ($name = "Disney+") do={
:set url "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"
:set method "post"
:set postData $disneyBody
:set fetchHdrs ("Authorization: Bearer " . $disneyKey . ",Content-Type: application/json")
}
:if ($name = "Gemini Supported") do={ :set url "gemini-check" }
:if ($name = "Reddit (Guest Access)") do={
:set url "https://www.reddit.com"
:set fetchHdrs ("User-Agent: " . $ua)
}
:if ($name = "YouTube Premium") do={
:set url "https://www.youtube.com/premium"
:set fetchHdrs ("User-Agent: " . $ua . ",Cookie: SOCS=" . $youtubeSocs . ",Accept-Language: en-US,en;q=0.9")
}
:if ($name = "Spotify Signup") do={
:set url ("https://spclient.wg.spotify.com/signup/public/v1/account/?validate=1&key=" . $spotifyKey)
:set fetchHdrs ("X-Client-Id: " . $spotifyClientId)
}
:if ($name = "Disney+ Access") do={
:set url "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"
:set method "post"
:set postData $disneyBody
:set fetchHdrs ("Authorization: Bearer " . $disneyKey . ",Content-Type: application/json")
}
:if ($name = "Apple") do={ :set url "https://gspe1-ssl.ls.apple.com/pep/gcc" }
:if ($name = "Steam") do={
:set url "https://store.steampowered.com"
:set method "head"
}
:if ($name = "Tiktok") do={ :set url "https://www.tiktok.com/api/v1/web-cookie-privacy/config?appId=1988" }
:if ($name = "Ookla Speedtest") do={ :set url "https://www.speedtest.net/api/js/config-sdk" }
:if ($name = "JetBrains") do={ :set url "https://data.services.jetbrains.com/geo" }
:if ($name = "PlayStation") do={
:set url "https://www.playstation.com"
:set method "head"
}
:if ($name = "Microsoft") do={ :set url "https://login.live.com" }
:if ($name = "Cloudflare CDN") do={
:set url "https://speed.cloudflare.com/meta"
:set fetchHdrs "Referer: https://speed.cloudflare.com"
}
:if ($name = "YouTube CDN") do={ :set url "https://redirector.googlevideo.com/report_mapping?di=no" }
:if ($name = "Netflix CDN") do={ :set url ("https://api.fast.com/netflix/speedtest/v2?https=true&token=" . $netflixKey . "&urlCount=1") }
:set iso "N/A"
:set httpCode 0
:set body ""
:set hdrData ""
:if ($url = "reddit-oauth") do={
:set token ""
:do {
:set r [/tool fetch url="https://www.reddit.com/auth/v2/oauth/access-token/loid" src-address=$srcIp http-method=post http-header-field=("Authorization: Basic " . $redditBasic . ",Content-Type: application/json,User-Agent: " . $redditUa) http-data="{\"scopes\":[\"email\"]}" output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set token [:tostr ($j->"access_token")]
}
} on-error={}
:if ([:len $token] > 0) do={
:do {
:set r [/tool fetch url="https://gql-fed.reddit.com" src-address=$srcIp http-method=post http-header-field=("Authorization: Bearer " . $token . ",Content-Type: application/json,User-Agent: " . $redditUa) http-data=$redditGqlBody output=user as-value]
:if (($r->"status") = "finished") do={
:set body ($r->"data")
:set httpCode 200
}
} on-error={}
}
} else={
:if ($url = "gemini-check") do={
:set googleCode ""
:do {
:set r [/tool fetch url="https://accounts.google.com/v3/signin/identifier?flowName=GlifSetupAndroid" src-address=$srcIp http-header-field=("User-Agent: " . $ua) output=user-with-headers as-value]
:if (($r->"status") = "finished") do={
:set data ($r->"data")
:set sep "\r\n\r\n"
:set p [:find $data $sep]
:if ([:typeof $p] = "nil") do={ :set sep "\n\n"; :set p [:find $data $sep] }
:if ([:typeof $p] != "nil") do={ :set plain [:pick $data ($p + [:len $sep]) [:len $data]] } else={ :set plain $data }
:set marker [:find $plain "name=\"region\" value=\""]
:if ([:typeof $marker] != "nil") do={ :set googleCode [:pick $plain ($marker + 21) ($marker + 23)] }
}
} on-error={}
:if ([:len $googleCode] = 2) do={
:set countryName ""
:do {
:set r [/tool fetch url=("https://www.apicountries.com/alpha/" . $googleCode) src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set countryName [:tostr ($j->"name")]
}
} on-error={}
:if (([:len $countryName] > 0) and ($countryName != "nil")) do={
:set regionsMd ""
:do {
:set r [/tool fetch url="https://ai.google.dev/gemini-api/docs/available-regions.md.txt" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={ :set regionsMd ($r->"data") }
} on-error={}
:set lineStart ("- " . $countryName)
:if ([:find $regionsMd $lineStart] != nil) do={ :set iso "Yes" } else={ :set iso "No" }
}
}
:set httpCode 200
} else={
:do {
:if ($method = "head") do={
:if ([:len $fetchHdrs] > 0) do={
:set r [/tool fetch url=$url src-address=$srcIp http-method=head http-header-field=$fetchHdrs output=user-with-headers as-value]
} else={
:set r [/tool fetch url=$url src-address=$srcIp http-method=head output=user-with-headers as-value]
}
} else={
:if ($method = "post") do={
:if ([:len $fetchHdrs] = 0) do={ :set fetchHdrs "Content-Type: application/x-www-form-urlencoded" }
:if ([:find $fetchHdrs "Content-Type:"] = nil) do={
:if ([:find $postData "{"] = 0) do={
:set fetchHdrs ($fetchHdrs . ",Content-Type: application/json")
} else={
:set fetchHdrs ($fetchHdrs . ",Content-Type: application/x-www-form-urlencoded")
}
}
:set r [/tool fetch url=$url http-method=post http-data=$postData http-header-field=$fetchHdrs src-address=$srcIp output=user-with-headers as-value]
} else={
:if ([:len $fetchHdrs] > 0) do={
:set r [/tool fetch url=$url src-address=$srcIp http-header-field=$fetchHdrs output=user-with-headers as-value]
} else={
:set r [/tool fetch url=$url src-address=$srcIp output=user-with-headers as-value]
}
}
}
:if (($r->"status") != "finished") do={
:set httpCode -1
} else={
:set data ($r->"data")
:set hdrData $data
:set line $data
:set nl [:find $data "\n"]
:if ([:typeof $nl] != "nil") do={ :set line [:pick $data 0 $nl] }
:set cr2 [:find $line "\r"]
:if ([:typeof $cr2] != "nil") do={ :set line [:pick $line 0 $cr2] }
:set httpCode 200
:if ([:pick $line 0 4] = "HTTP") do={
:set sp1 [:find $line " "]
:if ([:typeof $sp1] != "nil") do={
:set rest2 [:pick $line ($sp1 + 1) [:len $line]]
:set sp2 [:find $rest2 " "]
:set codeStr $rest2
:if ([:typeof $sp2] != "nil") do={ :set codeStr [:pick $rest2 0 $sp2] }
:set n [:tonum $codeStr]
:if ([:typeof $n] = "num") do={ :set httpCode $n }
}
}
:set sep "\r\n\r\n"
:set p [:find $data $sep]
:if ([:typeof $p] = "nil") do={
:set sep "\n\n"
:set p [:find $data $sep]
}
:if ([:typeof $p] != "nil") do={
:set body [:pick $data ($p + [:len $sep]) [:len $data]]
} else={
:set body $data
}
}
} on-error={
:set httpCode 0
}
}
}
:if (($httpCode = 0) or ($httpCode = -1)) do={ :set iso "N/A" }
:if (($httpCode = 401) or ($httpCode = 403)) do={ :set iso "Denied" }
:if ($httpCode = 429) do={ :set iso "Rate-limit" }
:if ($httpCode >= 500) do={ :set iso "Server error" }
:if (($httpCode >= 200) and ($httpCode < 400)) do={
:if ($name = "ifconfig.co") do={
:set plain $body
:set pcr [:find $plain "\r"]
:if ([:typeof $pcr] != "nil") do={ :set plain [:pick $plain 0 $pcr] }
:set plf [:find $plain "\n"]
:if ([:typeof $plf] != "nil") do={ :set plain [:pick $plain 0 $plf] }
:if ([:len $plain] > 0) do={ :set iso $plain } else={ :set iso "N/A" }
} else={
:if ($name = "Google") do={
:set marker [:find $body "name=\"region\" value=\""]
:if ([:typeof $marker] != "nil") do={ :set iso [:pick $body ($marker + 21) ($marker + 23)] } else={ :set iso "N/A" }
} else={
:if ($name = "YouTube") do={
:set plain $body
:set nl [:find $plain "\n"]
:if ([:typeof $nl] != "nil") do={ :set plain [:pick $plain ($nl + 1) [:len $plain]] }
:set nl [:find $plain "\n"]
:if ([:typeof $nl] != "nil") do={ :set plain [:pick $plain ($nl + 1) [:len $plain]] }
:do {
:set j [:deserialize from=json value=$plain]
:set iso [:tostr (($j->0)->2->0->0->1)]
:if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso "N/A" }
} on-error={ :set iso "N/A" }
} else={
:if ($name = "Reddit (Guest Access)") do={
:if ([:find $body "Denied"] != nil) do={ :set iso "No" } else={ :set iso "Yes" }
} else={
:if ($name = "YouTube Premium") do={
:if ([:find $body "youtube premium is not available in your country"] != nil) do={ :set iso "No" } else={ :set iso "Yes" }
} else={
:if ($name = "Apple") do={
:set plain $body
:set pcr [:find $plain "\r"]
:if ([:typeof $pcr] != "nil") do={ :set plain [:pick $plain 0 $pcr] }
:set plf [:find $plain "\n"]
:if ([:typeof $plf] != "nil") do={ :set plain [:pick $plain 0 $plf] }
:if ([:len $plain] > 0) do={ :set iso $plain } else={ :set iso "N/A" }
} else={
:if ($name = "Steam") do={
:set marker [:find $hdrData "steamCountry="]
:if ([:typeof $marker] != "nil") do={
:set tmp [:pick $hdrData ($marker + 13) [:len $hdrData]]
:set tmp2 [:find $tmp ";"]
:if ([:typeof $tmp2] != "nil") do={ :set iso [:pick $tmp 0 $tmp2] } else={ :set iso $tmp }
} else={ :set iso "N/A" }
} else={
:if ($name = "PlayStation") do={
:set marker [:find $hdrData "country="]
:if ([:typeof $marker] != "nil") do={
:set tmp [:pick $hdrData ($marker + 8) [:len $hdrData]]
:set tmp2 [:find $tmp ";"]
:if ([:typeof $tmp2] != "nil") do={ :set iso [:pick $tmp 0 $tmp2] } else={ :set iso $tmp }
} else={ :set iso "N/A" }
} else={
:if ($name = "Microsoft") do={
:set marker [:find $body "\"sRequestCountry\":\""]
:if ([:typeof $marker] != "nil") do={ :set iso [:pick $body ($marker + 19) ($marker + 21)] } else={ :set iso "N/A" }
} else={
:if ($name = "YouTube CDN") do={
:set iata ""
:set word3 ""
:set tmp $body
:set wi 0
:set wc 0
:while ($wi < [:len $tmp]) do={
:if ([:pick $tmp $wi ($wi + 1)] = " ") do={ :set wc ($wc + 1) }
:if ($wc = 3) do={
:set word3 [:pick $tmp $wi [:len $tmp]]
:set wi [:len $tmp]
}
:set wi ($wi + 1)
}
:if ([:len $word3] > 0) do={
:set dashPos [:find $word3 "-"]
:if ([:typeof $dashPos] != "nil") do={
:set tmp2 [:pick $word3 ($dashPos + 1) [:len $word3]]
:if ([:len $tmp2] >= 3) do={ :set iata [:pick $tmp2 0 3] }
}
}
:if ([:len $iata] = 3) do={
:set iataLoc ""
:do {
:set r [/tool fetch url="https://www.air-port-codes.com/api/v1/single" src-address=$srcIp http-method=post http-header-field="APC-Auth: 96dc04b3fb,Referer: https://www.air-port-codes.com/,Content-Type: application/x-www-form-urlencoded" http-data=("iata=" . $iata) output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set iataLoc [:tostr ($j->"airport"->"country"->"iso")]
}
} on-error={}
:if (([:len $iataLoc] > 0) and ($iataLoc != "nil")) do={ :set iso ($iataLoc . " (" . $iata . ")") } else={ :set iso ("N/A (" . $iata . ")") }
} else={ :set iso "N/A" }
} else={
:do {
:set j [:deserialize from=json value=$body]
:if ($name = "maxmind.com") do={ :set iso [:tostr ($j->"country"->"iso_code")] }
:if ($name = "rdap.db.ripe.net") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "ipinfo.io") do={
:set iso [:tostr ($j->"data"->"country")]
:if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso [:tostr ($j->"country")] }
}
:if ($name = "cloudflare.com") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "ipregistry.co") do={ :set iso [:tostr ($j->"location"->"country"->"code")] }
:if ($name = "ipapi.co") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "ip2location.io") do={ :set iso [:tostr ($j->"country_code")] }
:if ($name = "iplocation.com") do={ :set iso [:tostr ($j->"country_code")] }
:if ($name = "country.is") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "geoapify.com") do={ :set iso [:tostr ($j->"country"->"iso_code")] }
:if ($name = "geojs.io") do={ :set iso [:tostr ($j->0->"country")] }
:if ($name = "ipapi.is") do={ :set iso [:tostr ($j->"location"->"country_code")] }
:if ($name = "ipbase.com") do={ :set iso [:tostr ($j->"data"->"location"->"country"->"alpha2")] }
:if ($name = "ipquery.io") do={ :set iso [:tostr ($j->"location"->"country_code")] }
:if ($name = "ipwho.is") do={ :set iso [:tostr ($j->"country_code")] }
:if ($name = "ip-api.com") do={ :set iso [:tostr ($j->"countryCode")] }
:if ($name = "Twitch") do={ :set iso [:tostr ($j->0->"data"->"requestInfo"->"countryCode")] }
:if ($name = "ChatGPT") do={ :set iso [:tostr ($j->"derived_fields"->"country")] }
:if ($name = "Netflix") do={ :set iso [:tostr ($j->"client"->"location"->"country")] }
:if ($name = "Spotify") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "Reddit") do={ :set iso [:tostr ($j->"data"->"userLocation"->"countryCode")] }
:if ($name = "Disney+") do={ :set iso [:tostr ($j->"extensions"->"sdk"->"session"->"location"->"countryCode")] }
:if ($name = "Spotify Signup") do={
:set statusStr [:tostr ($j->"status")]
:set launched [:tostr ($j->"is_country_launched")]
:if (($statusStr = "120") or ($statusStr = "320") or ($launched = "false")) do={ :set iso "No" } else={ :set iso "Yes" }
}
:if ($name = "Disney+ Access") do={
:set inLoc [:tostr ($j->"extensions"->"sdk"->"session"->"inSupportedLocation")]
:if ($inLoc = "true") do={ :set iso "Yes" } else={ :set iso "No" }
}
:if ($name = "Tiktok") do={ :set iso [:tostr ($j->"body"->"appProps"->"region")] }
:if ($name = "Ookla Speedtest") do={ :set iso [:tostr ($j->"location"->"countryCode")] }
:if ($name = "JetBrains") do={ :set iso [:tostr ($j->"code")] }
:if ($name = "Netflix CDN") do={ :set iso [:tostr ($j->"targets"->0->"location"->"country")] }
:if ($name = "Cloudflare CDN") do={
:set ctry [:tostr ($j->"country")]
:set colo ($j->"colo")
:set iata ""
:if ([:typeof $colo] = "str") do={ :set iata $colo }
:if ([:typeof $colo] = "array") do={
:set ci ($colo->"iata")
:if ([:typeof $ci] = "str") do={ :set iata $ci }
}
:if (([:len $iata] = 3)) do={
:set iataLoc ""
:do {
:set r [/tool fetch url="https://www.air-port-codes.com/api/v1/single" src-address=$srcIp http-method=post http-header-field="APC-Auth: 96dc04b3fb,Referer: https://www.air-port-codes.com/,Content-Type: application/x-www-form-urlencoded" http-data=("iata=" . $iata) output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set iataLoc [:tostr ($j->"airport"->"country"->"iso")]
}
} on-error={}
:if (([:len $iataLoc] > 0) and ($iataLoc != "nil")) do={ :set iso ($iataLoc . " (" . $iata . ")") } else={
:if (([:len $ctry] > 0) and ($ctry != "nil")) do={ :set iso ($ctry . " (" . $iata . ")") } else={ :set iso ("N/A (" . $iata . ")") }
}
} else={
:if (([:len $ctry] > 0) and ($ctry != "nil")) do={ :set iso $ctry } else={ :set iso "N/A" }
}
}
:if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso "N/A" }
} on-error={
:set iso "Server error"
}
}
}
}
}
}
}
}
}
}
}
}
:put ($name . " " . $iso)
:set itemJson ("{\"service\":\"" . $name . "\",\"ipv4\":\"" . $iso . "\"}")
:if ([:len $resultJson] > 0) do={ :set resultJson ($resultJson . ",") }
:set resultJson ($resultJson . $itemJson)
}
:local hosterJson ""
:if ([:len $hoster] > 0) do={
:local esc ""
:local hi 0
:while ($hi < [:len $hoster]) do={
:local ch [:pick $hoster $hi ($hi + 1)]
:if ($ch = "\\") do={ :set esc ($esc . "\\\\") } else={
:if ($ch = "\"") do={ :set esc ($esc . "\\\"") } else={ :set esc ($esc . $ch) }
}
:set hi ($hi + 1)
}
:set hosterJson (",\"hoster\":\"" . $esc . "\"")
}
:if ([:len $resultJson] = 0) do={ :error "ingest: results пустой" }
:local json ("{\"schemaVersion\":1,\"runId\":\"" . $runId . "\",\"probe\":{\"publicIp\":\"" . $publicIp . "\"" . $hosterJson . "},\"launcherVersion\":\"" . $launcherVer . "\",\"ipregion\":{\"version\":\"ros\"},\"results\":[" . $resultJson . "]}")
:local hdrs ("Content-Type: application/json,Authorization: Bearer " . $vtToken)
:put ("Отправляю ingest (" . [:len $json] . " B)...")
:set ingestOk false
:onerror err,attr in={
:set r [/tool fetch url=($vtApi . "/api/integrations/ipregion/runs") src-address=$srcIp http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
:set ingestOk true
} do={
:put ("ingest: " . $err)
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"code")] != "nil")) do={
:put ("code: " . ($attr->"code"))
}
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"data")] = "str") and ([:len ($attr->"data")] > 0)) do={
:put ($attr->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
:if ($ingestOk = false) do={
:put "повтор ingest без src-address..."
:onerror err2,attr2 in={
:set r [/tool fetch url=($vtApi . "/api/integrations/ipregion/runs") http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
} do={
:put ("ingest retry: " . $err2)
:if (([:typeof $attr2] = "array") and ([:typeof ($attr2->"data")] = "str") and ([:len ($attr2->"data")] > 0)) do={
:put ($attr2->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
}
:if ($vtDaily = "yes") do={
:local ident [/system identity get name]
:local h ([:len $ident] + [:len [/system resource get architecture-name]])
:local hour (3 + ($h % 4))
:local minute (($h * 17) % 60)
:local hh $hour
:local mm $minute
:if ($hour < 10) do={ :set hh ("0" . $hour) }
:if ($minute < 10) do={ :set mm ("0" . $minute) }
:local startTime ($hh . ":" . $mm . ":00")
:local ev (":global \"vt-srcIp\" \"" . $srcIp . "\"; /tool fetch url=" . $vtApi . "/ic.rsc dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
:do { /system scheduler remove [find name=$schedName] } on-error={}
/system scheduler add name=$schedName interval=1d start-time=$startTime on-event=$ev policy=read,write,test,policy comment="vps-tracker vt-ic"
:put ("Ежедневная проверка: каждый день в " . $startTime . " (" . $schedName . ")")
:put ("Снять: /tool fetch url=" . $vtApi . "/ic.rsc?remove=daily dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
}
}
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env bash
# VPS Tracker launcher for vernette/ipregion (vendor pin 7d1c25c).
# Fetched via: curl -fsSL https://vt.shnt.top/ic | bash
set -euo pipefail
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
LAUNCHER_VERSION="2"
VENDOR_SHA="7d1c25c"
DAILY_SLUG="vt-ipregion"
DAILY_PATH="/ic"
OS_ID="unknown"
OS_LIKE=""
OS_NAME="unknown"
trap 'exit 130' INT
log() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Нужна команда '$1' (установите пакет и повторите)."
}
detect_os() {
OS_ID="unknown"
OS_LIKE=""
OS_NAME="unknown"
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_LIKE="${ID_LIKE:-}"
OS_NAME="${PRETTY_NAME:-$OS_ID}"
fi
}
detect_pkg_manager() {
case "$OS_ID" in
debian|ubuntu|linuxmint|pop|raspbian|kali|astra|devuan) printf 'apt'; return 0 ;;
alpine) printf 'apk'; return 0 ;;
arch|manjaro|endeavouros) printf 'pacman'; return 0 ;;
fedora) printf 'dnf'; return 0 ;;
rhel|centos|rocky|almalinux|ol)
if command -v dnf >/dev/null 2>&1; then printf 'dnf'; else printf 'yum'; fi
return 0
;;
esac
case " $OS_LIKE " in
*" debian "*|*" ubuntu "*) printf 'apt'; return 0 ;;
*" rhel "*|*" fedora "*|*" centos "*)
if command -v dnf >/dev/null 2>&1; then printf 'dnf'; else printf 'yum'; fi
return 0
;;
*" arch "*) printf 'pacman'; return 0 ;;
*" alpine "*) printf 'apk'; return 0 ;;
esac
if command -v apt-get >/dev/null 2>&1; then
printf 'apt'
elif command -v dnf >/dev/null 2>&1; then
printf 'dnf'
elif command -v yum >/dev/null 2>&1; then
printf 'yum'
elif command -v pacman >/dev/null 2>&1; then
printf 'pacman'
elif command -v apk >/dev/null 2>&1; then
printf 'apk'
else
return 1
fi
}
pkg_alts() {
local cmd="$1" pm="$2"
case "$pm:$cmd" in
*:jq) printf '%s\n' jq ;;
apt:dig|apt:nslookup) printf '%s\n' dnsutils bind9-dnsutils ;;
dnf:dig|yum:dig|dnf:nslookup|yum:nslookup) printf '%s\n' bind-utils ;;
pacman:dig|pacman:nslookup) printf '%s\n' bind ;;
apk:dig|apk:nslookup) printf '%s\n' bind-tools ;;
apt:column) printf '%s\n' bsdextrautils bsdmainutils ;;
dnf:column|yum:column|pacman:column) printf '%s\n' util-linux ;;
apk:column) printf '%s\n' util-linux-misc util-linux ;;
*) return 1 ;;
esac
}
root_prefix() {
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
return 0
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
printf 'sudo -n'
return 0
fi
return 1
}
install_one_package() {
local pm="$1" pkg="$2"
local prefix=""
prefix="$(root_prefix)" || die "Нужны права root, чтобы установить: ${pkg}"
# shellcheck disable=SC2086
case "$pm" in
apt)
$prefix env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "$pkg"
;;
dnf) $prefix dnf install -y "$pkg" ;;
yum) $prefix yum install -y "$pkg" ;;
pacman) $prefix pacman -S --noconfirm --needed "$pkg" ;;
apk) $prefix apk add --no-cache "$pkg" ;;
*) return 1 ;;
esac
}
install_cmd() {
local pm="$1" cmd="$2" alt
while IFS= read -r alt; do
[ -n "$alt" ] || continue
log " пакет ${alt} → команда ${cmd}"
if install_one_package "$pm" "$alt"; then
command -v "$cmd" >/dev/null 2>&1 && return 0
fi
done < <(pkg_alts "$cmd" "$pm")
return 1
}
ensure_cmds() {
local missing=() cmd pm prefix=""
detect_os
pm="$(detect_pkg_manager)" || pm=""
log "ОС: ${OS_NAME} (id=${OS_ID}${OS_LIKE:+ like=${OS_LIKE}}, pkg=${pm:-unknown})"
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
[ "${#missing[@]}" -eq 0 ] && return 0
[ -n "$pm" ] || die "Не удалось определить пакетный менеджер (${OS_NAME}). Установите вручную: ${missing[*]}"
log "Отсутствуют команды: ${missing[*]}. Устанавливаю..."
if [ "$pm" = apt ]; then
prefix="$(root_prefix)" || die "Нужны права root, чтобы установить: ${missing[*]}"
# shellcheck disable=SC2086
$prefix env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq
fi
for cmd in "${missing[@]}"; do
install_cmd "$pm" "$cmd" || die "Не удалось установить зависимость для '$cmd' (${OS_NAME})"
command -v "$cmd" >/dev/null 2>&1 || die "Команда '$cmd' так и не появилась после установки"
done
}
require_cmd curl
require_cmd bash
ensure_cmds jq dig column nslookup
detect_public_ip() {
local ip=""
ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
if [ -z "$ip" ]; then
ip="$(curl -fsS --max-time 8 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true)"
fi
if [ -z "$ip" ] && command -v dig >/dev/null 2>&1; then
ip="$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null | tr -d '[:space:]' || true)"
fi
printf '%s' "$ip"
}
# Короткий таймаут: на обычном VPS link-local просто не ответит.
curl_meta() {
curl -fsS --connect-timeout 1 --max-time 1 "$@" 2>/dev/null || true
}
detect_cloud_hoster() {
local body=""
body="$(curl_meta http://169.254.169.254/hetzner/v1/metadata)"
if [ -n "$body" ]; then
printf 'Hetzner'
return 0
fi
body="$(curl_meta http://169.254.169.254/metadata/v1/id)"
if [ -n "$body" ]; then
printf 'DigitalOcean'
return 0
fi
body="$(curl_meta http://169.254.169.254/v1/instanceid)"
if [ -n "$body" ]; then
printf 'Vultr'
return 0
fi
body="$(curl_meta http://169.254.169.254/linode/v1/instance-id)"
if [ -n "$body" ]; then
printf 'Linode'
return 0
fi
body="$(curl_meta -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id)"
if [ -n "$body" ]; then
printf 'Google Cloud'
return 0
fi
body="$(curl_meta -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01')"
if [ -n "$body" ]; then
printf 'Azure'
return 0
fi
body="$(curl_meta http://169.254.169.254/latest/meta-data/instance-id)"
if [ -n "$body" ]; then
printf 'AWS'
return 0
fi
return 1
}
detect_asn_org() {
local ip="$1" json="" org=""
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipwho.is/${ip}" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.connection.org // .org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipinfo.io/${ip}/json" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
return 1
}
detect_ptr_hint() {
local ip="$1" ptr=""
command -v dig >/dev/null 2>&1 || return 1
ptr="$(dig +short -x "$ip" 2>/dev/null | awk 'NF{print; exit}' | tr -d '\r' | sed 's/\.$//')"
[ -n "$ptr" ] || return 1
printf '%s' "$ptr"
}
detect_hoster() {
local ip="$1" value=""
value="$(detect_cloud_hoster)" && { printf '%s' "$value"; return 0; }
value="$(detect_asn_org "$ip")" && { printf '%s' "$value"; return 0; }
value="$(detect_ptr_hint "$ip")" && { printf '%s' "$value"; return 0; }
return 1
}
show_launcher_help() {
cat <<EOF
Использование:
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --daily
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily
--daily выполнить проверку и поставить cron раз в сутки
--remove-daily убрать ежедневный cron
EOF
}
daily_run_cmd() {
local url="${VT_API_URL}${DAILY_PATH}"
local curl_bin bash_bin flock_bin
curl_bin="$(command -v curl)"
bash_bin="$(command -v bash)"
flock_bin="$(command -v flock || true)"
if [ -n "$flock_bin" ]; then
printf "%s -n /tmp/%s.lock -c '%s -fsSL --max-time 180 %s | %s'" \
"$flock_bin" "$DAILY_SLUG" "$curl_bin" "$url" "$bash_bin"
else
printf '%s -fsSL --max-time 180 %s | %s' "$curl_bin" "$url" "$bash_bin"
fi
}
daily_slot() {
local seed n
seed="$(hostname -f 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo vt)"
n="$(printf '%s' "$seed" | cksum | awk '{print $1}')"
DAILY_MINUTE=$((n % 60))
DAILY_HOUR=$((3 + (n / 60) % 4))
}
remove_daily() {
local prefix="" removed=0
if [ -e "/etc/cron.d/${DAILY_SLUG}" ]; then
if prefix="$(root_prefix)"; then
# shellcheck disable=SC2086
$prefix rm -f "/etc/cron.d/${DAILY_SLUG}"
removed=1
fi
fi
if command -v crontab >/dev/null 2>&1; then
local current
current="$(crontab -l 2>/dev/null || true)"
if printf '%s\n' "$current" | grep -qF "vps-tracker:${DAILY_SLUG}"; then
printf '%s\n' "$current" | grep -vF "vps-tracker:${DAILY_SLUG}" | crontab -
removed=1
fi
fi
if [ "$removed" -eq 1 ]; then
log "Ежедневная проверка снята (${DAILY_SLUG})"
else
log "Ежедневная проверка не была установлена"
fi
}
install_daily() {
daily_slot
local run line prefix=""
local marker="# vps-tracker:${DAILY_SLUG}"
run="$(daily_run_cmd)"
if [ -d /etc/cron.d ] && prefix="$(root_prefix)"; then
local tmp
tmp="$(mktemp)"
cat >"$tmp" <<EOF
# Managed by VPS Tracker launcher. ${marker}
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
${DAILY_MINUTE} ${DAILY_HOUR} * * * root ${run} >>/var/log/${DAILY_SLUG}.log 2>&1
EOF
# shellcheck disable=SC2086
$prefix cp "$tmp" "/etc/cron.d/${DAILY_SLUG}"
# shellcheck disable=SC2086
$prefix chmod 644 "/etc/cron.d/${DAILY_SLUG}"
rm -f "$tmp"
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (/etc/cron.d/${DAILY_SLUG})"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
die "Нет cron (ни /etc/cron.d, ни crontab). Установите пакет cron и повторите --daily"
fi
line="${DAILY_MINUTE} ${DAILY_HOUR} * * * ${run} >>/tmp/${DAILY_SLUG}.log 2>&1 ${marker}"
{
(crontab -l 2>/dev/null || true) | grep -vF "vps-tracker:${DAILY_SLUG}" || true
printf '%s\n' "$line"
} | crontab -
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (crontab)"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
}
write_vendor() {
local dest="$1"
if [ -n "${IPREGION_VENDOR_B64:-}" ]; then
printf '%s' "$IPREGION_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \
|| printf '%s' "$IPREGION_VENDOR_B64" | base64 -D >"$dest"
return 0
fi
curl -fsSL --max-time 30 "${VT_API_URL}/ic/vendor" -o "$dest"
}
uuid4() {
if [ -r /proc/sys/kernel/random/uuid ]; then
tr -d '[:space:]' </proc/sys/kernel/random/uuid
return
fi
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import uuid; print(uuid.uuid4())'
return
fi
openssl rand -hex 16
}
VT_API_URL="${VT_API_URL%/}"
[ -n "$VT_API_URL" ] || die "VT_API_URL пуст"
[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст"
DAILY_INSTALL=0
DAILY_REMOVE=0
for arg in "$@"; do
case "$arg" in
--daily|--install-daily) DAILY_INSTALL=1 ;;
--remove-daily|--uninstall-daily) DAILY_REMOVE=1 ;;
-h|--help) show_launcher_help; exit 0 ;;
*) die "Неизвестный аргумент: $arg. См. --help" ;;
esac
done
if [ "$DAILY_REMOVE" -eq 1 ]; then
remove_daily
exit 0
fi
TMPDIR="$(mktemp -d /tmp/vt-ipregion.XXXXXX)"
cleanup() { rm -rf "$TMPDIR"; }
trap 'cleanup; exit 130' INT
trap 'cleanup' EXIT
VENDOR="$TMPDIR/ipregion.sh"
write_vendor "$VENDOR"
chmod +x "$VENDOR"
PUBLIC_IP="$(detect_public_ip)"
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
HOSTER="$(detect_hoster "$PUBLIC_IP" || true)"
RUN_ID="$(uuid4)"
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
log "ipregion launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
log "probe IP: ${PUBLIC_IP}"
log "хостер: ${HOSTER:-не определён}"
log "runId: ${RUN_ID}"
log "Определяю страны GeoIP (IPv4)..."
set +e
# stdout = JSON; stderr = прогресс (не перехватывать)
RAW_JSON="$(bash "$VENDOR" --json --ipv4)"
CC_EXIT=$?
set -e
if [ "$CC_EXIT" -ne 0 ]; then
log "ipregion завершился с кодом ${CC_EXIT}" >&2
exit 1
fi
PAYLOAD="$TMPDIR/payload.json"
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" --arg hoster "$HOSTER" '
def items($group):
((.results[$group] // []) | map({
service: .service,
group: $group,
ipv4: (.ipv4 // null),
ipv6: (.ipv6 // null)
}));
{
schemaVersion: 1,
runId: $runId,
probe: ({ publicIp: $ip } + if ($hoster | length) > 0 then { hoster: $hoster } else {} end),
launcherVersion: $lv,
ipregion: {
version: ((.version | tostring) // "1")
},
results: (items("primary") + items("custom") + items("cdn"))
}
' >"$PAYLOAD"
FALLBACK="/tmp/vt-ipregion-${RUN_ID}.json"
set +e
RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/ipregion/runs" \
-H "Authorization: Bearer ${VT_INGEST_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @"$PAYLOAD")"
POST_EXIT=$?
set -e
if [ "$POST_EXIT" -ne 0 ]; then
cp "$PAYLOAD" "$FALLBACK"
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
CHECK_ID="$(printf '%s' "$RESP" | jq -r '.id // empty')"
MATCHED="$(printf '%s' "$RESP" | jq -r '.matchedVpsId // "unmatched"')"
if [ -z "$CHECK_ID" ]; then
cp "$PAYLOAD" "$FALLBACK"
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
log "$RESP" >&2
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
log "Check ID: ${CHECK_ID}"
log "VPS: ${MATCHED}"
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 0
+2
View File
@@ -27,6 +27,7 @@ import { auditRoutes } from './routes/audit.js'
import { notificationsRoutes } from './routes/notifications.js'
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { censorcheckRoutes } from './routes/censorcheck.js'
import { ipregionRoutes } from './routes/ipregion.js'
import { launcherRoutes } from './routes/launcher.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
@@ -89,6 +90,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(notificationsRoutes)
await app.register(integrationsCfdmRoutes)
await app.register(censorcheckRoutes)
await app.register(ipregionRoutes)
await app.register(appSwitcherRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
+1
View File
@@ -21,6 +21,7 @@ describe('permissionForRequest', () => {
it('maps vps CRUD', () => {
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
expect(permissionForRequest('GET', '/api/censorcheck/current')).toBe('vps:vps:read')
expect(permissionForRequest('GET', '/api/ipregion/current')).toBe('vps:vps:read')
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
})
+2 -1
View File
@@ -52,7 +52,8 @@ const RULES: Rule[] = [
p.startsWith('/api/projects') ||
p.startsWith('/api/topology') ||
p.startsWith('/api/data') ||
p.startsWith('/api/censorcheck'),
p.startsWith('/api/censorcheck') ||
p.startsWith('/api/ipregion'),
permission: 'vps:vps:read',
},
{
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { coerceRouterosIngestBody } from './ros-ingest.js'
describe('coerceRouterosIngestBody', () => {
it('превращает results с числовыми ключами в массив', () => {
const out = coerceRouterosIngestBody({
schemaVersion: '1',
runId: 'mt-abcdefghijklmnop',
probe: { publicIp: '185.246.117.91' },
results: {
'0': { service: 'maxmind.com', ipv4: 'N/A' },
'1': { service: 'ipinfo.io', ipv4: 'RU' },
},
}) as { schemaVersion: number; results: Array<{ service: string }> }
expect(out.schemaVersion).toBe(1)
expect(out.results).toEqual([
{ service: 'maxmind.com', ipv4: 'N/A' },
{ service: 'ipinfo.io', ipv4: 'RU' },
])
})
it('оставляет обычный массив без изменений', () => {
const results = [{ service: 'ipinfo.io', ipv4: 'RU' }]
const out = coerceRouterosIngestBody({
schemaVersion: 1,
results,
}) as { results: unknown }
expect(out.results).toBe(results)
})
it('парсит JSON-строку', () => {
const out = coerceRouterosIngestBody(
'{"schemaVersion":1,"results":{"0":{"service":"a"}}}',
) as { results: Array<{ service: string }> }
expect(out.results).toEqual([{ service: 'a' }])
})
})
+33
View File
@@ -0,0 +1,33 @@
/**
* RouterOS `:serialize to=json` of `$arr->0 = $item` yields
* `{"0": {...}, "1": {...}}` instead of a JSON array.
* Numbers may also come as strings (`schemaVersion: "1"`).
*/
export function coerceRouterosIngestBody(body: unknown): unknown {
let value: unknown = body
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
value = value.toString('utf8')
}
if (typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
return body
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
const rec = { ...(value as Record<string, unknown>) }
if (rec.schemaVersion === '1') rec.schemaVersion = 1
rec.results = coerceIndexedObjectToArray(rec.results)
return rec
}
function coerceIndexedObjectToArray(value: unknown): unknown {
if (Array.isArray(value) || value == null || typeof value !== 'object') return value
const obj = value as Record<string, unknown>
const keys = Object.keys(obj)
if (keys.length === 0) return value
if (!keys.every((key) => /^\d+$/.test(key))) return value
return keys.sort((a, b) => Number(a) - Number(b)).map((key) => obj[key])
}
+3 -1
View File
@@ -74,9 +74,11 @@ function isPublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path === '/cc' || path.startsWith('/cc/')) return true
if (path === '/cc' || path === '/cc.rsc' || path.startsWith('/cc/')) return true
if (path === '/ic' || path === '/ic.rsc' || path.startsWith('/ic/')) return true
if (path.startsWith('/api/integrations/cfdm')) return true
if (path.startsWith('/api/integrations/censorcheck')) return true
if (path.startsWith('/api/integrations/ipregion')) return true
return false
}
+44
View File
@@ -62,6 +62,26 @@ export async function ensureUserSpaces(request: FastifyRequest): Promise<void> {
*/
import { runWithSpace } from '@cfdm/db'
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
/** Routes without their own requireSpaceRole: a space viewer must not write here. */
const MEMBER_WRITE_PREFIXES = [
'/api/payments',
'/api/balance-ledger',
'/api/providers',
'/api/provider-accounts',
'/api/projects',
]
export function needsMemberRole(method: string, url: string): boolean {
const path = url.split('?')[0] ?? url
// Every /api/sync call hits the hoster and writes (GET .../balance too), except the log.
if (path === '/api/sync' || path.startsWith('/api/sync/')) return path !== '/api/sync/status'
return (
WRITE_METHODS.has(method.toUpperCase()) &&
MEMBER_WRITE_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))
)
}
export const spacePlugin = fp(async (app) => {
app.addHook('onRequest', (request, reply, done) => {
void (async () => {
@@ -123,6 +143,16 @@ export const spacePlugin = fp(async (app) => {
}
})()
})
app.addHook('preHandler', async (request, reply) => {
if (!app.authConfig?.required || !request.authUser) return
if (!needsMemberRole(request.method, request.url)) return
if (!canWriteInSpace(request)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Недостаточно прав в пространстве (нужно: member)' },
})
}
})
})
export function requireSpaceRole(
@@ -156,6 +186,20 @@ export function requireSpaceRole(
return true
}
/**
* Whole-DB operations (backup download/restore, legacy import) cross every space and carry API credentials,
* so per-space settings admin is not enough.
*/
export function requireGlobalAdmin(request: FastifyRequest, reply: FastifyReply): boolean {
const user = request.authUser
if (!user) return true
if (user.isAdmin || hasPermission(user.permissions, 'vps:spaces:admin')) return true
void reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Операция доступна только глобальному администратору' },
})
return false
}
export function canWriteInSpace(request: FastifyRequest): boolean {
const role = request.spaceRole ?? 'viewer'
return roleAtLeast(role, 'member')
+5
View File
@@ -15,6 +15,7 @@ import {
type FullBackupPayload,
} from '../services/backup-full.js'
import { restartScheduler } from '../services/scheduler.js'
import { requireGlobalAdmin } from '../plugins/space.js'
/** Лимит тела для импорта бэкапа (Fastify default = 1 MiB → 413). */
function backupBodyLimitBytes(): number {
@@ -29,6 +30,10 @@ function backupBodyLimitBytes(): number {
export const backupRoutes: FastifyPluginAsync = async (app) => {
const bodyLimit = backupBodyLimitBytes()
app.addHook('preHandler', async (req, reply) => {
if (!requireGlobalAdmin(req, reply)) return reply
})
app.addContentTypeParser(
'application/octet-stream',
{ parseAs: 'buffer', bodyLimit },
+57
View File
@@ -161,4 +161,61 @@ describe('censorcheck ingest + reads', () => {
expect(detail.statusCode).toBe(200)
expect(detail.json().results).toHaveLength(1)
})
it('сохраняет хостер из probe и канонизирует ASN', async () => {
const res = await post(
ingestPayload({
runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
probe: { publicIp: '203.0.113.10', hoster: 'AS14061 DigitalOcean, LLC' },
}),
)
expect(res.statusCode).toBe(200)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
expect(current.json().items[0].detectedHoster).toBe('DigitalOcean')
expect(current.json().items[0].vps).toBeNull()
})
it('для matched VPS отдаёт имя хостера из инвентаря', async () => {
runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 2,
ramGb: 4,
diskGb: 40,
}),
)
await post(
ingestPayload({
runId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
probe: { publicIp: '203.0.113.10', hoster: 'Hetzner Online GmbH' },
}),
)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
const item = current.json().items[0]
expect(item.vps.providerName).toBe('Test Host')
expect(item.detectedHoster).toBe('Hetzner')
})
it('принимает results как объект с числовыми ключами (RouterOS serialize)', async () => {
const res = await post(
ingestPayload({
schemaVersion: '1',
runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
results: {
'0': {
service: 'youtube.com',
raw: { https: { ipv4: { status: 200 } } },
},
},
}),
)
expect(res.statusCode).toBe(200)
expect(res.json().summary.available).toBe(1)
})
})
+4 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { canonicalizeHoster, censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck'
import { actorFromRequest } from '../lib/audit-actor.js'
import {
@@ -7,6 +7,7 @@ import {
ingestSecret,
verifyIngestToken,
} from '../services/censorcheck/ingest-token.js'
import { coerceRouterosIngestBody } from '../lib/ros-ingest.js'
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js'
@@ -44,7 +45,7 @@ export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
async (request, reply) => {
if (!requireIngestToken(request, reply)) return
const parsed = censorcheckIngestBodySchema.safeParse(request.body)
const parsed = censorcheckIngestBodySchema.safeParse(coerceRouterosIngestBody(request.body))
if (!parsed.success) {
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
}
@@ -82,6 +83,7 @@ export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
censorcheckVersion: parsed.data.censorcheck?.version ?? null,
summary,
observedSourceIp: observed ?? null,
detectedHoster: canonicalizeHoster(parsed.data.probe.hoster),
results,
})
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
import { mintIngestToken } from '../services/censorcheck/ingest-token.js'
const SECRET = 'test-ipregion-ingest-secret'
function ingestPayload(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: 1,
runId: '11111111-1111-4111-8111-111111111111',
probe: { publicIp: '203.0.113.10' },
launcherVersion: '1',
ipregion: { version: '1' },
results: [
{
service: 'maxmind.com',
group: 'primary',
ipv4: 'NL',
ipv6: 'N/A',
},
{
service: 'Google',
group: 'custom',
ipv4: 'US',
ipv6: null,
},
],
...overrides,
}
}
describe('ipregion ingest + reads', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = SECRET
process.env.CENSORCHECK_RATE_LIMIT = '0'
process.env.TRUST_PROXY = '1'
resetTestDb()
seedTestProvider('p1')
seedTestProviderAccount('a1', 'p1')
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
delete process.env.TRUST_PROXY
})
async function post(body: unknown, token?: string, headers: Record<string, string> = {}) {
return app.inject({
method: 'POST',
url: '/api/integrations/ipregion/runs',
headers: {
authorization: `Bearer ${token ?? mintIngestToken(SECRET)}`,
'content-type': 'application/json',
...headers,
},
payload: body as object,
})
}
it('отклоняет запрос без токена', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/integrations/ipregion/runs',
payload: ingestPayload(),
})
expect(res.statusCode).toBe(401)
})
it('принимает прогон и оставляет unmatched', async () => {
const res = await post(ingestPayload())
expect(res.statusCode).toBe(200)
const json = res.json() as { matchedVpsId: string | null; probePublicIp: string }
expect(json.matchedVpsId).toBeNull()
expect(json.probePublicIp).toBe('203.0.113.10')
})
it('матчит VPS по IP', async () => {
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 2,
ramGb: 4,
diskGb: 40,
}),
)
const res = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }))
expect(res.json().matchedVpsId).toBe(vps.id)
})
it('повторяет duplicate runId без второй записи', async () => {
const first = await post(ingestPayload())
const second = await post(ingestPayload())
expect(second.json().id).toBe(first.json().id)
expect(second.json().replayed).toBe(true)
const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' })
expect(current.json().items).toHaveLength(1)
})
it('отдаёт историю и детали с ISO', async () => {
await post(ingestPayload())
const list = await app.inject({ method: 'GET', url: '/api/ipregion/runs?limit=10' })
expect(list.statusCode).toBe(200)
const items = list.json().items as Array<{
id: string
results?: Array<{ countryIpv4: string; status: string; group: string }>
}>
expect(items).toHaveLength(1)
expect(items[0]!.results).toHaveLength(2)
expect(items[0]!.results?.[0]).toMatchObject({
countryIpv4: 'NL',
status: 'ok',
group: 'primary',
})
const detail = await app.inject({ method: 'GET', url: `/api/ipregion/runs/${items[0]!.id}` })
expect(detail.statusCode).toBe(200)
expect(detail.json().results).toHaveLength(2)
})
it('сохраняет хостер из probe', async () => {
await post(
ingestPayload({
runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
probe: { publicIp: '203.0.113.10', hoster: 'AS14061 DigitalOcean, LLC' },
}),
)
const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' })
expect(current.json().items[0].detectedHoster).toBe('DigitalOcean')
})
it('принимает results как объект с числовыми ключами (RouterOS serialize)', async () => {
const res = await post(
ingestPayload({
schemaVersion: '1',
runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
results: {
'0': { service: 'maxmind.com', ipv4: 'NL' },
'1': { service: 'ipinfo.io', ipv4: 'RU' },
},
}),
)
expect(res.statusCode).toBe(200)
expect(res.json().summary.ok).toBe(2)
})
})
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { canonicalizeHoster } from '@cfdm/shared/contracts/censorcheck'
import { ipregionIngestBodySchema } from '@cfdm/shared/contracts/ipregion'
import { ipregionRepository } from '@cfdm/db/repositories/ipregion'
import { actorFromRequest } from '../lib/audit-actor.js'
import {
bearerToken,
ingestSecret,
verifyIngestToken,
} from '../services/censorcheck/ingest-token.js'
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
import { coerceRouterosIngestBody } from '../lib/ros-ingest.js'
import { normalizeIngestResult, summarizeResults } from '../services/ipregion/normalize.js'
const BODY_LIMIT = 512 * 1024
function sendError(reply: FastifyReply, status: number, code: string, message: string) {
return reply.code(status).send({ error: { code, message } })
}
function requireIngestToken(request: FastifyRequest, reply: FastifyReply): boolean {
const secret = ingestSecret()
if (!secret) {
void sendError(reply, 503, 'UNAVAILABLE', 'Ipregion ingest не настроен')
return false
}
const token = bearerToken(request.headers.authorization)
if (!token || !verifyIngestToken(token, secret)) {
void sendError(reply, 401, 'UNAUTHORIZED', 'Недействительный ingest-токен')
return false
}
return true
}
export const ipregionRoutes: FastifyPluginAsync = async (app) => {
const ingestOpts = {
bodyLimit: BODY_LIMIT,
...(process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
? {}
: { config: { rateLimit: { max: 6, timeWindow: '1 minute' } } }),
}
app.post(
'/api/integrations/ipregion/runs',
ingestOpts,
async (request, reply) => {
if (!requireIngestToken(request, reply)) return
const parsed = ipregionIngestBodySchema.safeParse(coerceRouterosIngestBody(request.body))
if (!parsed.success) {
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
}
const existing = ipregionRepository.getByClientRunId(parsed.data.runId)
if (existing) {
return {
id: existing.id,
runId: existing.runId,
matchedVpsId: existing.matchedVpsId,
probePublicIp: existing.probePublicIp,
summary: existing.summary,
replayed: true,
}
}
const claimed = parsed.data.probe.publicIp
const observed = actorFromRequest(request).ip ?? request.ip
const probePublicIp = resolveProbeIp(observed, claimed)
const claimedPublicIp =
claimed && claimed !== probePublicIp ? claimed : null
const match = matchVpsByPublicIp(probePublicIp)
const results = parsed.data.results.map(normalizeIngestResult)
const { summary, runStatus } = summarizeResults(results)
const created = ipregionRepository.create({
spaceId: match.spaceId,
runId: parsed.data.runId,
probePublicIp,
claimedPublicIp,
matchedVpsId: match.vpsId,
status: runStatus,
schemaVersion: parsed.data.schemaVersion,
launcherVersion: parsed.data.launcherVersion ?? null,
ipregionVersion: parsed.data.ipregion?.version ?? null,
summary,
observedSourceIp: observed ?? null,
detectedHoster: canonicalizeHoster(parsed.data.probe.hoster),
results,
})
return {
id: created.id,
runId: created.runId,
matchedVpsId: created.matchedVpsId,
probePublicIp: created.probePublicIp,
summary: created.summary,
}
},
)
app.get('/api/ipregion/current', async () => ({
items: ipregionRepository.listCurrent(),
}))
app.get('/api/ipregion/runs', async (request) => {
const q = request.query as Record<string, string | undefined>
const matched =
q.matched === '1' || q.matched === 'true'
? true
: q.matched === '0' || q.matched === 'false'
? false
: undefined
return ipregionRepository.listHistory({
cursor: q.cursor,
limit: q.limit ? Number(q.limit) : undefined,
q: q.q,
status: q.status,
matched,
})
})
app.get('/api/ipregion/runs/:id', async (request, reply) => {
const { id } = request.params as { id: string }
const run = ipregionRepository.getById(id)
if (!run) {
return sendError(reply, 404, 'NOT_FOUND', 'Прогон не найден')
}
return run
})
}
+208
View File
@@ -28,9 +28,16 @@ describe('GET /cc launcher', () => {
expect(res.body).toContain('VT_INGEST_TOKEN')
expect(res.body).toContain('ensure_cmds jq dig column')
expect(res.body).toContain('detect_os')
expect(res.body).toContain('detect_hoster')
expect(res.body).toContain('ipwho.is')
expect(res.body).toContain('/etc/os-release')
expect(res.body).toContain('apt-get install -y -qq')
expect(res.body).toContain('Проверяю сайты')
expect(res.body).toContain('--daily')
expect(res.body).toContain('--remove-daily')
expect(res.body).toContain('DAILY_SLUG="vt-censorcheck"')
expect(res.body).toContain('vps-tracker:${DAILY_SLUG}')
expect(res.body).toContain('/etc/cron.d')
expect(res.body).not.toContain('2>/tmp/vt-censorcheck-err')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
@@ -48,3 +55,204 @@ describe('GET /cc launcher', () => {
expect(res.body).not.toContain('\r')
})
})
describe('GET /ic launcher', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key'
process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top'
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('отдаёт bash-скрипт с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/ic' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/plain/)
expect(res.headers['cache-control']).toMatch(/no-store/)
expect(res.body).toContain('https://vt.shnt.top')
expect(res.body).toContain('VT_INGEST_TOKEN')
expect(res.body).toContain('ensure_cmds jq dig column nslookup')
expect(res.body).toContain('detect_hoster')
expect(res.body).toContain('--json --ipv4')
expect(res.body).toContain('/api/integrations/ipregion/runs')
expect(res.body).toContain('/ic/vendor')
expect(res.body).toContain('7d1c25c')
expect(res.body).toContain('--daily')
expect(res.body).toContain('--remove-daily')
expect(res.body).toContain('DAILY_SLUG="vt-ipregion"')
expect(res.body).toContain('vps-tracker:${DAILY_SLUG}')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
})
it('отдаёт vendor-скрипт ipregion', async () => {
const res = await app.inject({ method: 'GET', url: '/ic/vendor' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain('#!/usr/bin/env bash')
expect(res.body).toContain('SCRIPT_NAME="ipregion.sh"')
expect(res.body).toContain('finalize_json')
expect(res.body).not.toContain('\r')
})
})
describe('GET /cc.rsc RouterOS launcher', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key'
process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top'
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('отдаёт .rsc с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/plain/)
expect(res.headers['cache-control']).toMatch(/no-store/)
expect(res.body).toContain('https://vt.shnt.top')
expect(res.body).toContain('/tool fetch')
expect(res.body).toContain('/api/integrations/censorcheck/runs')
expect(res.body).toContain('ros-7')
expect(res.body).toContain(':local vtDaily "no"')
expect(res.body).toContain(':local vtRemove "no"')
expect(res.body).toContain('/system scheduler')
expect(res.body).toContain('youtube.com')
expect(res.body).toContain('vt-srcIp')
expect(res.body).toContain(':global "vt-srcIp"')
expect(res.body).toContain('src-address=$srcIp')
expect(res.body).toContain(':onerror')
expect(res.body).toContain('повтор ingest без src-address')
expect(res.body).toContain('schemaVersion\\":1')
expect(res.body).not.toContain('mark-routing')
expect(res.body).not.toContain('vtIface')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
expect(res.body).not.toContain('__VT_DAILY__')
expect(res.body).not.toContain('__VT_REMOVE_DAILY__')
})
it('?daily=1 включает установку scheduler', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc?daily=1' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain(':local vtDaily "yes"')
expect(res.body).toContain(':local vtRemove "no"')
expect(res.body).toContain('/system scheduler add')
})
it('?remove=daily снимает scheduler без проб', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc?remove=daily' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain(':local vtRemove "yes"')
expect(res.body).toContain(':local vtDaily "no"')
expect(res.body).toContain('Ежедневная проверка снята')
})
})
describe('GET /ic.rsc RouterOS launcher', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key'
process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top'
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('отдаёт .rsc с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/ic.rsc' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/plain/)
expect(res.headers['cache-control']).toMatch(/no-store/)
expect(res.body).toContain('https://vt.shnt.top')
expect(res.body).toContain('/tool fetch')
expect(res.body).toContain('/api/integrations/ipregion/runs')
expect(res.body).toContain('ros-8')
expect(res.body).toContain(':local vtDaily "no"')
expect(res.body).toContain('ipinfo.io')
expect(res.body).toContain('Cloudflare CDN')
expect(res.body).toContain('YouTube CDN')
expect(res.body).toContain('YouTube')
expect(res.body).toContain('Netflix CDN')
expect(res.body).toContain('vt-srcIp')
expect(res.body).toContain(':global "vt-srcIp"')
expect(res.body).toContain('src-address=$srcIp')
expect(res.body).toContain(':onerror')
expect(res.body).toContain('повтор ingest без src-address')
expect(res.body).toContain('schemaVersion\\":1')
expect(res.body).not.toContain('mark-routing')
expect(res.body).not.toContain('vtIface')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
})
it('?daily=1 включает установку scheduler', async () => {
const res = await app.inject({ method: 'GET', url: '/ic.rsc?daily=1' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain(':local vtDaily "yes"')
expect(res.body).toContain('/system scheduler add')
})
})
describe('GET /cc.rsc without ingest secret', () => {
let app: Awaited<ReturnType<typeof buildApp>>
const prevNodeEnv = process.env.NODE_ENV
const prevAuthRequired = process.env.AUTH_REQUIRED
const prevCc = process.env.CENSORCHECK_INGEST_SECRET
const prevAuth = process.env.AUTH_JWT_SECRET
const prevJwt = process.env.JWT_SECRET
beforeEach(async () => {
process.env.NODE_ENV = 'production'
process.env.AUTH_REQUIRED = 'false'
delete process.env.CENSORCHECK_INGEST_SECRET
delete process.env.AUTH_JWT_SECRET
delete process.env.JWT_SECRET
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
process.env.NODE_ENV = prevNodeEnv
if (prevAuthRequired === undefined) delete process.env.AUTH_REQUIRED
else process.env.AUTH_REQUIRED = prevAuthRequired
if (prevCc === undefined) delete process.env.CENSORCHECK_INGEST_SECRET
else process.env.CENSORCHECK_INGEST_SECRET = prevCc
if (prevAuth === undefined) delete process.env.AUTH_JWT_SECRET
else process.env.AUTH_JWT_SECRET = prevAuth
if (prevJwt === undefined) delete process.env.JWT_SECRET
else process.env.JWT_SECRET = prevJwt
})
it('отвечает 503', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc' })
expect(res.statusCode).toBe(503)
expect(res.body).toMatch(/not configured/)
})
})
+110 -21
View File
@@ -29,6 +29,78 @@ function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'publi
.send(unixText(body))
}
const IPREGION_SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'ipregion')
function truthyQuery(value: string | undefined): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
function mintLauncherScript(
reply: FastifyReply,
secret: string | undefined,
scriptDir: string,
missingSecretMessage: string,
): void {
if (!secret) {
void reply.code(503).send(missingSecretMessage)
return
}
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(scriptDir, 'launcher.sh'), 'utf8')
} catch {
void reply.code(500).send('launcher template missing\n')
return
}
const script = template
.replaceAll('__VT_API_URL__', apiUrl)
.replaceAll('__VT_INGEST_TOKEN__', token)
sendPlain(reply, script, 'no-store')
}
type RosQuery = { daily?: string; remove?: string }
function mintRosLauncher(
reply: FastifyReply,
secret: string | undefined,
scriptDir: string,
missingSecretMessage: string,
query: RosQuery,
): void {
if (!secret) {
void reply.code(503).send(missingSecretMessage)
return
}
const remove = query.remove === 'daily' || truthyQuery(query.remove)
const daily = !remove && truthyQuery(query.daily)
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(scriptDir, 'launcher.rsc'), 'utf8')
} catch {
void reply.code(500).send('launcher template missing\n')
return
}
const script = template
.replaceAll('__VT_API_URL__', apiUrl)
.replaceAll('__VT_INGEST_TOKEN__', token)
.replaceAll('__VT_DAILY__', daily ? 'yes' : 'no')
.replaceAll('__VT_REMOVE_DAILY__', remove ? 'yes' : 'no')
sendPlain(reply, script, 'no-store')
}
function sendVendor(reply: FastifyReply, filePath: string): void {
try {
const body = readFileSync(filePath, 'utf8')
sendPlain(reply, body, 'public')
} catch {
void reply.code(500).send('vendor script missing\n')
}
}
export const launcherRoutes: FastifyPluginAsync = async (app) => {
const secret = ingestSecret()
@@ -38,29 +110,46 @@ export const launcherRoutes: FastifyPluginAsync = async (app) => {
: { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
app.get('/cc', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
if (!secret) {
return reply.code(503).send('censorcheck ingest is not configured\n')
}
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(SCRIPT_DIR, 'launcher.sh'), 'utf8')
} catch {
return reply.code(500).send('launcher template missing\n')
}
const script = template
.replaceAll('__VT_API_URL__', apiUrl)
.replaceAll('__VT_INGEST_TOKEN__', token)
sendPlain(reply, script, 'no-store')
mintLauncherScript(reply, secret, SCRIPT_DIR, 'censorcheck ingest is not configured\n')
})
app.get<{ Querystring: RosQuery }>(
'/cc.rsc',
ccOpts,
async (request: FastifyRequest<{ Querystring: RosQuery }>, reply: FastifyReply) => {
mintRosLauncher(
reply,
secret,
SCRIPT_DIR,
'censorcheck ingest is not configured\n',
request.query,
)
},
)
app.get('/cc/vendor', async (_request, reply) => {
try {
const body = readFileSync(join(SCRIPT_DIR, 'censorcheck.sh'), 'utf8')
sendPlain(reply, body, 'public')
} catch {
return reply.code(500).send('vendor script missing\n')
}
sendVendor(reply, join(SCRIPT_DIR, 'censorcheck.sh'))
})
app.get('/ic', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
mintLauncherScript(reply, secret, IPREGION_SCRIPT_DIR, 'ipregion ingest is not configured\n')
})
app.get<{ Querystring: RosQuery }>(
'/ic.rsc',
ccOpts,
async (request: FastifyRequest<{ Querystring: RosQuery }>, reply: FastifyReply) => {
mintRosLauncher(
reply,
secret,
IPREGION_SCRIPT_DIR,
'ipregion ingest is not configured\n',
request.query,
)
},
)
app.get('/ic/vendor', async (_request, reply) => {
sendVendor(reply, join(IPREGION_SCRIPT_DIR, 'ipregion.sh'))
})
}
+9 -7
View File
@@ -1,8 +1,10 @@
import type { FastifyPluginAsync } from 'fastify'
import { consolidateAllProviderApiSources, getSqlite } from '@cfdm/db'
import { requireGlobalAdmin } from '../plugins/space.js'
export const migrateRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/migrate', async (req, reply) => {
if (!requireGlobalAdmin(req, reply)) return reply
const data = req.body
if (!data || typeof data !== 'object') {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Invalid payload' } })
@@ -71,8 +73,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
v.ipv6 ?? '',
additionalIps,
v.dns ?? '',
v.providerId ?? '',
v.providerAccountId ?? '',
v.providerId || null,
v.providerAccountId || null,
v.country ?? '',
v.city ?? '',
v.datacenter ?? '',
@@ -88,7 +90,7 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
v.purpose ?? '',
v.environment ?? '',
v.project ?? '',
v.projectId ?? null,
v.projectId || null,
v.monitoringEnabled ? 1 : 0,
v.backupEnabled ? 1 : 0,
v.status ?? 'active',
@@ -116,8 +118,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
pm.date ?? '',
Number(pm.amount) || 0,
pm.currency ?? '',
pm.providerAccountId ?? '',
pm.vpsId ?? '',
pm.providerAccountId || null,
pm.vpsId || null,
pm.note ?? '',
)
}
@@ -136,8 +138,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
Number(bl.amount) || 0,
bl.currency ?? '',
bl.direction ?? '',
bl.providerAccountId ?? '',
bl.vpsId ?? '',
bl.providerAccountId || null,
bl.vpsId || null,
bl.note ?? '',
)
}
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
describe('payments / balance-ledger without VPS', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('POST /api/payments stores empty vpsId/providerAccountId as null', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/payments',
payload: { type: 'topup', date: '2026-01-01', amount: 10, vpsId: '', providerAccountId: '' },
})
expect(res.statusCode).toBe(201)
const body = res.json() as { id: string; vpsId: string | null; providerAccountId: string | null }
expect(body.vpsId).toBeNull()
expect(body.providerAccountId).toBeNull()
const put = await app.inject({ method: 'PUT', url: `/api/payments/${body.id}`, payload: { note: 'x' } })
expect(put.statusCode).toBe(200)
})
it('POST /api/balance-ledger stores empty refs as null', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/balance-ledger',
payload: { type: 'topup', date: '2026-01-01', amount: 10, vpsId: '', providerAccountId: '' },
})
expect(res.statusCode).toBe(201)
})
it('POST /api/vps without provider does not hit FK', async () => {
const res = await app.inject({ method: 'POST', url: '/api/vps', payload: { ip: '10.0.0.1' } })
expect(res.statusCode).toBeLessThan(300)
})
})
+9 -3
View File
@@ -1,13 +1,19 @@
import type { FastifyPluginAsync } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings'
export const ratesProxyRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/rates-proxy', async (req, reply) => {
const ratesUrl = settingsRepository.getBySpace()?.ratesUrl?.trim()
if (!ratesUrl) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'ratesUrl не задан в настройках' } })
}
// Only the saved ratesUrl is fetched — an arbitrary ?url= would be an open SSRF proxy.
const url = (req.query as { url?: string })?.url
if (!url) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Missing url parameter' } })
if (url && url.trim() !== ratesUrl) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'url должен совпадать с ratesUrl из настроек' } })
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
const response = await fetch(ratesUrl, { signal: AbortSignal.timeout(10_000) })
if (!response.ok) {
return reply.code(502).send({ error: { code: 'UPSTREAM', message: `Upstream returned ${response.status}` } })
}
+189
View File
@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb, getSqlite } from '@cfdm/db'
import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { spacesRepository } from '@cfdm/db/repositories/spaces'
import { buildApp } from '../index.js'
import { needsMemberRole } from '../plugins/space.js'
import { runAccountSync, SyncInProgressError } from '../services/providers/sync-job.js'
import type { ProviderAdapter } from '../services/providers/types.js'
describe('rates-proxy', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
settingsRepository.upsertForSpace('space-main', { ratesUrl: 'https://rates.test/daily.json' })
app = await buildApp()
})
afterEach(async () => {
await app.close()
vi.unstubAllGlobals()
closeDb()
})
it('refuses a url other than the saved ratesUrl', async () => {
const fetchMock = vi.fn<typeof fetch>()
vi.stubGlobal('fetch', fetchMock)
const res = await app.inject({ url: '/api/rates-proxy?url=http://169.254.169.254/latest' })
expect(res.statusCode).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches the saved ratesUrl', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => Response.json({ USD: 1 }))
vi.stubGlobal('fetch', fetchMock)
const res = await app.inject({
url: `/api/rates-proxy?url=${encodeURIComponent('https://rates.test/daily.json')}`,
})
expect(res.statusCode).toBe(200)
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://rates.test/daily.json')
})
})
describe('GET /api/sync/status', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider()
})
afterEach(() => closeDb())
it('returns only the current space log', async () => {
const sqlite = getSqlite()
sqlite.exec(`INSERT INTO spaces (id, name, slug, kind, createdAt) VALUES ('space-x', 'X', 'x', 'personal', '2026-01-01')`)
sqlite.exec(`INSERT INTO provider_accounts (id, spaceId, providerId, name) VALUES ('acc-x', 'space-x', 'prov-1', 'X')`)
sqlite.exec(`INSERT INTO sync_log (id, spaceId, accountId, startedAt, status) VALUES ('l-x', 'space-x', 'acc-x', '2026-01-01', 'ok')`)
const app = await buildApp()
const main = await app.inject({ url: '/api/sync/status' })
expect((main.json() as unknown[]).length).toBe(0)
const x = await app.inject({ url: '/api/sync/status', headers: { 'x-space-id': 'space-x' } })
expect((x.json() as { id: string }[]).map((r) => r.id)).toEqual(['l-x'])
await app.close()
})
})
describe('runAccountSync lock', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider()
})
afterEach(() => closeDb())
it('rejects a second sync of the same account while one is running', async () => {
getSqlite().exec(`INSERT INTO provider_accounts (id, spaceId, providerId, name) VALUES ('acc-l', 'space-main', 'prov-1', 'L')`)
let finish!: () => void
const adapter = {
syncAccount: () =>
new Promise((resolve) => {
finish = () => resolve({ vpsCount: 0, paymentsCount: 0, syncSummary: {} })
}),
} as unknown as ProviderAdapter
const first = runAccountSync(adapter, { id: 'acc-l' })
await expect(runAccountSync(adapter, { id: 'acc-l' })).rejects.toBeInstanceOf(SyncInProgressError)
finish()
await first
finish = undefined as never
const again = runAccountSync(adapter, { id: 'acc-l' })
finish()
await expect(again).resolves.toMatchObject({ ok: true })
})
})
describe('space viewer cannot write', () => {
const secret = 'test-secret-at-least-8'
beforeEach(() => {
process.env.AUTH_REQUIRED = 'true'
process.env.AUTH_JWT_SECRET = secret
resetTestDb()
})
afterEach(() => {
delete process.env.AUTH_REQUIRED
delete process.env.AUTH_JWT_SECRET
closeDb()
})
it('needsMemberRole covers money/provider writes and sync', () => {
expect(needsMemberRole('POST', '/api/payments')).toBe(true)
expect(needsMemberRole('GET', '/api/payments')).toBe(false)
expect(needsMemberRole('GET', '/api/sync/acc-1/balance')).toBe(true)
expect(needsMemberRole('GET', '/api/sync/status')).toBe(false)
expect(needsMemberRole('DELETE', '/api/provider-accounts/a')).toBe(true)
})
it('403 for viewer, 201 for member on POST /api/payments', async () => {
spacesRepository.create({ id: 'space-team', name: 'Team', slug: 'team', kind: 'personal', ownerUserId: 'owner' })
spacesRepository.addMember('space-team', 'viewer-1', 'viewer')
spacesRepository.addMember('space-team', 'member-1', 'member')
const app = await buildApp()
const token = (sub: string) =>
app.jwt.sign({
sub,
email: `${sub}@t`,
name: sub,
apps: ['vps'],
permissions: ['vps:payments:write'],
iss: app.authConfig.issuer,
})
const post = (sub: string) =>
app.inject({
method: 'POST',
url: '/api/payments',
headers: { authorization: `Bearer ${token(sub)}`, 'x-space-id': 'space-team' },
payload: { type: 'topup', date: '2026-01-01', amount: 1 },
})
expect((await post('viewer-1')).statusCode).toBe(403)
expect((await post('member-1')).statusCode).toBe(201)
await app.close()
})
})
describe('whole-DB operations need a global admin', () => {
beforeEach(() => {
process.env.AUTH_REQUIRED = 'true'
process.env.AUTH_JWT_SECRET = 'test-secret-at-least-8'
resetTestDb()
})
afterEach(() => {
delete process.env.AUTH_REQUIRED
delete process.env.AUTH_JWT_SECRET
closeDb()
})
it('403 for settings admin, 200 for global admin', async () => {
const app = await buildApp()
const token = (sub: string, isAdmin: boolean) =>
app.jwt.sign({
sub,
email: `${sub}@t`,
name: sub,
apps: ['vps'],
permissions: ['vps:settings:admin'],
is_admin: isAdmin,
iss: app.authConfig.issuer,
})
const get = (t: string) =>
app.inject({ url: '/api/backup/json', headers: { authorization: `Bearer ${t}` } })
expect((await get(token('space-admin', false))).statusCode).toBe(403)
const migrate = await app.inject({
method: 'POST',
url: '/api/migrate',
headers: { authorization: `Bearer ${token('space-admin', false)}` },
payload: {},
})
expect(migrate.statusCode).toBe(403)
expect((await get(token('root', true))).statusCode).toBe(200)
await app.close()
})
})
describe('dashboard stats', () => {
beforeEach(() => resetTestDb())
afterEach(() => closeDb())
it('computes from the slim snapshot', async () => {
const app = await buildApp()
const res = await app.inject({ url: '/api/dashboard/stats' })
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ totalVpsCount: 0, providerCount: 0 })
await app.close()
})
})
+1 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resetTestDb } from '@cfdm/db/test-setup'
+6 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getCurrentSpaceId, getDb, schema } from '@cfdm/db'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providersRepository } from '@cfdm/db/repositories/providers'
@@ -9,7 +9,7 @@ import {
resolveSyncAccount,
SYNC_SETUP_ERRORS,
} from '../services/providers/index.js'
import { runAccountSync } from '../services/providers/sync-job.js'
import { runAccountSync, SyncInProgressError } from '../services/providers/sync-job.js'
interface SyncLogRow {
id: string
@@ -54,6 +54,7 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
const rows = getDb()
.select()
.from(schema.syncLog)
.where(eq(schema.syncLog.spaceId, getCurrentSpaceId()))
.orderBy(desc(schema.syncLog.startedAt))
.limit(50)
.all()
@@ -89,6 +90,9 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
},
}
} catch (err) {
if (err instanceof SyncInProgressError) {
return reply.code(409).send({ ok: false, error: err.message })
}
req.log.error(err)
const message = err instanceof Error ? err.message : 'Sync failed'
return reply.code(500).send({ ok: false, error: message })
+2
View File
@@ -6,6 +6,8 @@ import {
const TABLE_ORDER_DELETE = [
'vps_grants',
'ipregion_results',
'ipregion_runs',
'censorcheck_results',
'censorcheck_runs',
'notification_log',
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { mapConcurrent } from './sync.js'
describe('mapConcurrent', () => {
it('keeps order and caps parallelism', async () => {
let active = 0
let peak = 0
const out = await mapConcurrent([5, 1, 4, 2, 3, 0], 2, async (n) => {
active++
peak = Math.max(peak, active)
await new Promise((r) => setTimeout(r, n))
active--
return n * 10
})
expect(out).toEqual([50, 10, 40, 20, 30, 0])
expect(peak).toBe(2)
})
})
+96 -88
View File
@@ -2,8 +2,9 @@
* Sync BILLmanager data into vps-tracker DB (Drizzle / @cfdm/db)
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, getSqlite, schema } from '@cfdm/db'
import { findSyncedVps } from '../providers/find-synced-vps.js'
import type { BillmanagerSyncAccount } from './context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
@@ -87,6 +88,26 @@ async function mapVdsWithSpecs(
return vps
}
/** vds.edit lookups are one HTTP call per VPS — run a few at once, not all (hoster rate limits). */
const VDS_SPECS_CONCURRENCY = 4
export async function mapConcurrent<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const out: R[] = new Array(items.length)
let next = 0
const worker = async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i]!)
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return out
}
export async function syncFromBillmanager(
account: BillmanagerSyncAccount,
opts: SyncFromBillmanagerOptions = {},
@@ -137,20 +158,9 @@ export async function syncFromBillmanager(
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
if (fetchVpsPayments) {
const mappedRaw: Awaited<ReturnType<typeof mapVdsWithSpecs>>[] = []
for (const item of vdsItems) {
mappedRaw.push(
await mapVdsWithSpecs(
profile,
item,
providerId,
accountId,
tariffItems,
apiBaseUrl,
authinfo,
),
)
}
const mappedRaw = await mapConcurrent(vdsItems, VDS_SPECS_CONCURRENCY, (item) =>
mapVdsWithSpecs(profile, item, providerId, accountId, tariffItems, apiBaseUrl, authinfo),
)
const mappedList = profile.map.enrichVdsBatch
? profile.map.enrichVdsBatch(mappedRaw, {
balance: dashboardInfo?.balance ?? null,
@@ -165,16 +175,7 @@ export async function syncFromBillmanager(
const paidUntil = vps.paidUntil || ''
const notes = vps.notes ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%bm-${vps.externalId}%`)),
),
)
.get()
const existing = findSyncedVps(accountId, id, [`bm-${vps.externalId}`], vps.ip)
if (existing) {
let userOverrides: string[] = []
@@ -271,6 +272,7 @@ export async function syncFromBillmanager(
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
spaceId: getCurrentSpaceId(),
id,
ip: vps.ip,
ipv6: vps.ipv6,
@@ -331,6 +333,7 @@ export async function syncFromBillmanager(
const payId = `pay-bm-${accountId}-${payment.externalId}`
db.insert(schema.payments)
.values({
spaceId: getCurrentSpaceId(),
id: payId,
type: payment.type,
date: payment.date,
@@ -361,72 +364,77 @@ export async function syncFromBillmanager(
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
// Fetch errors come back as [] — keep the old list instead of wiping it (and re-announcing all as new).
if (fetchTariffs && tariffItems.length > 0) {
getSqlite().transaction(() => {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-bm-${accountId}-${t.externalId}-${dcKey}`
: `tariff-bm-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-bm-${accountId}-${t.externalId}-${dcKey}`
: `tariff-bm-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
spaceId: getCurrentSpaceId(),
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'SSD',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'SSD',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
if (Object.keys(slist).length > 0) {
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
db.insert(schema.tariffSyncOptions)
.values({
providerAccountId: accountId,
datacenters,
periods,
syncedAt,
})
.onConflictDoUpdate({
target: schema.tariffSyncOptions.providerAccountId,
set: { datacenters, periods, syncedAt },
})
.run()
}
if (Object.keys(slist).length > 0) {
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
db.insert(schema.tariffSyncOptions)
.values({
spaceId: getCurrentSpaceId(),
providerAccountId: accountId,
datacenters,
periods,
syncedAt,
})
.onConflictDoUpdate({
target: schema.tariffSyncOptions.providerAccountId,
set: { datacenters, periods, syncedAt },
})
.run()
}
})()
}
if (!fetchVpsPayments) {
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { canonicalizeHoster } from '@cfdm/shared/contracts/censorcheck'
describe('canonicalizeHoster', () => {
it('мапит ASN/org на короткое имя', () => {
expect(canonicalizeHoster('AS14061 DigitalOcean, LLC')).toBe('DigitalOcean')
expect(canonicalizeHoster('Hetzner Online GmbH')).toBe('Hetzner')
expect(canonicalizeHoster('hosted-by.vdsina.ru')).toBe('VDSina')
expect(canonicalizeHoster(' aeza.net. ')).toBe('Aeza')
})
it('пустую строку отбрасывает', () => {
expect(canonicalizeHoster('')).toBeNull()
expect(canonicalizeHoster(' ')).toBeNull()
expect(canonicalizeHoster(undefined)).toBeNull()
})
it('неизвестную org чистит без alias', () => {
expect(canonicalizeHoster('AS12345 Example Hosting LLC')).toBe('Example Hosting')
})
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
import { getDashboardSnapshot } from '@cfdm/db/repositories/snapshot'
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
@@ -76,7 +76,7 @@ export interface DashboardStats {
}
export function computeDashboardStats(): DashboardStats {
const snap = getSnapshot()
const snap = getDashboardSnapshot()
const now = new Date()
const activeVps = snap.vps.filter((v) => v.status === 'active')
+35 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb, getSqlite } from '@cfdm/db'
import { closeDb, getSqlite, runWithSpaceAsync } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
@@ -137,4 +137,38 @@ describe('syncFromFourvps', () => {
.get('acc-4vps') as { balance_api: number }
expect(bal.balance_api).toBe(77825)
})
it('writes synced rows into the current space', async () => {
getSqlite()
.prepare(`INSERT INTO spaces (id, name, slug, kind, createdAt) VALUES ('space-x', 'X', 'x', 'personal', '2026-01-01')`)
.run()
await runWithSpaceAsync('space-x', () => syncFromFourvps(account))
const rows = getSqlite()
.prepare(`SELECT spaceId FROM vps UNION ALL SELECT spaceId FROM active_tariffs`)
.all() as { spaceId: string }[]
expect(rows.length).toBe(2)
expect(rows.every((r) => r.spaceId === 'space-x')).toBe(true)
})
it('does not merge a server into another by note prefix or empty IP', async () => {
getSqlite()
.prepare(
`INSERT INTO vps (id, spaceId, ip, providerId, providerAccountId, notes) VALUES ('manual-12', 'space-main', '', 'prov-4vps', 'acc-4vps', '4vps-1000')`,
)
.run()
vi.mocked(fetchMyServers).mockResolvedValue([
{ ...(await fetchMyServers('', ''))[0]!, id: 100, ipv4: '' },
])
await syncFromFourvps(account)
const ids = (getSqlite().prepare(`SELECT id FROM vps ORDER BY id`).all() as { id: string }[]).map((r) => r.id)
expect(ids).toEqual(['manual-12', 'vps-4vps-acc-4vps-100'])
})
it('keeps tariffs when the tariff fetch fails and does not re-announce them', async () => {
await syncFromFourvps(account)
vi.mocked(fetchTarifList).mockRejectedValueOnce(new Error('timeout'))
await syncFromFourvps(account)
const count = getSqlite().prepare(`SELECT COUNT(*) as c FROM active_tariffs`).get() as { c: number }
expect(count.c).toBe(1)
const third = await syncFromFourvps(account)
expect(third.newTariffs).toEqual([])
})
})
+56 -59
View File
@@ -2,8 +2,9 @@
* Sync 4VPS data into vps-tracker DB (Drizzle / @cfdm/db)
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, getSqlite, schema } from '@cfdm/db'
import { findSyncedVps } from '../providers/find-synced-vps.js'
import type { FourvpsSyncAccount } from './context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
@@ -102,16 +103,7 @@ export async function syncFromFourvps(
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%4vps-${vps.externalId}%`)),
),
)
.get()
const existing = findSyncedVps(accountId, id, [`4vps-${vps.externalId}`], vps.ip)
if (existing) {
let userOverrides: string[] = []
@@ -175,6 +167,7 @@ export async function syncFromFourvps(
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
spaceId: getCurrentSpaceId(),
id,
ip: vps.ip,
ipv6: vps.ipv6,
@@ -230,55 +223,59 @@ export async function syncFromFourvps(
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
// Fetch errors come back as [] — keep the old list instead of wiping it (and re-announcing all as new).
if (fetchTariffs && tariffItems.length > 0) {
getSqlite().transaction(() => {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-4vps-${accountId}-${t.externalId}-${dcKey}`
: `tariff-4vps-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-4vps-${accountId}-${t.externalId}-${dcKey}`
: `tariff-4vps-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
spaceId: getCurrentSpaceId(),
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
})()
}
if (!fetchVpsData) {
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
canonicalizeCountryValue,
inferIpregionGroup,
} from '@cfdm/shared/contracts/ipregion'
import { normalizeIngestResult, summarizeResults } from './normalize.js'
describe('canonicalizeCountryValue', () => {
it('мапит ISO в ok', () => {
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU', iata: null })
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE', iata: null })
})
it('мапит CDN ISO + IATA', () => {
expect(canonicalizeCountryValue('SE (ARN)')).toEqual({
status: 'ok',
country: 'SE',
iata: 'ARN',
})
expect(canonicalizeCountryValue('RS (BEG)')).toEqual({
status: 'ok',
country: 'RS',
iata: 'BEG',
})
expect(canonicalizeCountryValue('(ARN)')).toEqual({
status: 'ok',
country: null,
iata: 'ARN',
})
})
it('мапит статусы ipregion', () => {
expect(canonicalizeCountryValue('N/A').status).toBe('na')
expect(canonicalizeCountryValue('Denied').status).toBe('denied')
expect(canonicalizeCountryValue('Rate-limit').status).toBe('rate_limit')
expect(canonicalizeCountryValue('Rate limit').status).toBe('rate_limit')
expect(canonicalizeCountryValue('Server error').status).toBe('server_error')
})
})
describe('ipregion normalize', () => {
it('нормализует сервис и группу', () => {
const row = normalizeIngestResult({
service: 'Maxmind.com',
ipv4: 'NL',
ipv6: 'N/A',
})
expect(row.serviceKey).toBe('maxmind.com')
expect(row.group).toBe('primary')
expect(row.status).toBe('ok')
expect(row.countryIpv4).toBe('NL')
expect(row.countryIpv6).toBeNull()
})
it('берёт IPv6 если IPv4 N/A', () => {
const row = normalizeIngestResult({
service: 'YouTube CDN',
group: 'cdn',
ipv4: 'N/A',
ipv6: 'US',
})
expect(row.status).toBe('ok')
expect(row.countryIpv6).toBe('US')
expect(row.group).toBe('cdn')
})
it('сохраняет CDN ISO + IATA и N/A', () => {
const cf = normalizeIngestResult({
service: 'Cloudflare CDN',
group: 'cdn',
ipv4: 'SE (ARN)',
})
expect(cf.status).toBe('ok')
expect(cf.countryIpv4).toBe('SE (ARN)')
expect(cf.group).toBe('cdn')
const yt = normalizeIngestResult({
service: 'YouTube CDN',
ipv4: 'RS (BEG)',
})
expect(yt.status).toBe('ok')
expect(yt.countryIpv4).toBe('RS (BEG)')
const nflx = normalizeIngestResult({
service: 'Netflix CDN',
ipv4: 'N/A',
})
expect(nflx.status).toBe('na')
expect(nflx.countryIpv4).toBeNull()
})
it('считает summary и partial', () => {
const { summary, runStatus } = summarizeResults([
{ status: 'ok' },
{ status: 'na' },
{ status: 'denied' },
])
expect(summary.total).toBe(3)
expect(summary.ok).toBe(1)
expect(summary.denied).toBe(1)
expect(runStatus).toBe('partial')
})
it('угадывает группу по имени', () => {
expect(inferIpregionGroup('cloudflare.com')).toBe('primary')
expect(inferIpregionGroup('Netflix')).toBe('custom')
expect(inferIpregionGroup('YouTube CDN')).toBe('cdn')
})
})
@@ -0,0 +1,53 @@
import {
canonicalizeCountryValue,
emptyIpregionSummary,
formatCanonicalCountry,
inferIpregionGroup,
type IpregionGroup,
type IpregionIngestResult,
type IpregionRunStatus,
type IpregionStatus,
type IpregionSummary,
} from '@cfdm/shared/contracts/ipregion'
export type NormalizedIpregionResult = {
serviceKey: string
serviceLabel: string
group: IpregionGroup
countryIpv4: string | null
countryIpv6: string | null
status: IpregionStatus
}
export function normalizeIngestResult(item: IpregionIngestResult): NormalizedIpregionResult {
const serviceLabel = item.service.trim()
const serviceKey = serviceLabel.toLowerCase()
const ipv4 = canonicalizeCountryValue(item.ipv4)
const ipv6 = canonicalizeCountryValue(item.ipv6)
const status = ipv4.status !== 'na' ? ipv4.status : ipv6.status
return {
serviceKey,
serviceLabel,
group: item.group ?? inferIpregionGroup(serviceKey),
countryIpv4: formatCanonicalCountry(ipv4),
countryIpv6: formatCanonicalCountry(ipv6),
status,
}
}
export function summarizeResults(results: { status: IpregionStatus }[]): {
summary: IpregionSummary
runStatus: IpregionRunStatus
} {
const summary = emptyIpregionSummary()
summary.total = results.length
for (const row of results) {
summary[row.status] += 1
}
const runStatus: IpregionRunStatus =
summary.denied > 0 || summary.rate_limit > 0 || summary.server_error > 0
? 'partial'
: 'complete'
return { summary, runStatus }
}
@@ -30,6 +30,7 @@ export async function deliverWebhook(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
return { ok: true }
@@ -10,6 +10,7 @@ export function shouldSkipDedup(
stateKey?: string,
newStatus?: string,
): boolean {
if (mode === 'none') return false
const key = stateKey ?? event
const state = notificationRepository.getState(key)
const now = Date.now()
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb, getSqlite, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildPaymentExpiryNotification, buildSyncDigestNotification, buildVpsHealthNotification } from './rules.js'
import { markDedupSent, shouldSkipDedup } from './dedup.js'
import { publishNotification } from './engine.js'
import { fitTelegramText, sendTelegramMessage } from '../telegram.js'
describe('notifications review fixes', () => {
beforeEach(() => {
resetTestDb()
getSqlite().exec(`INSERT INTO spaces (id, name, slug, kind, createdAt) VALUES ('space-x', 'X', 'x', 'personal', '2026-01-01')`)
})
afterEach(() => {
vi.unstubAllGlobals()
closeDb()
})
it('payment expiry only lists VPS of the current space', () => {
const now = new Date('2026-06-01T12:00:00Z')
getSqlite()
.prepare(`INSERT INTO vps (id, spaceId, ip, dns, status, paidUntil) VALUES ('v-x', 'space-x', '1.1.1.1', 'x.example', 'active', '2026-06-03')`)
.run()
expect(runWithSpace(MAIN_SPACE_ID, () => buildPaymentExpiryNotification(now))).toBeNull()
expect(runWithSpace('space-x', () => buildPaymentExpiryNotification(now))?.messagePlain).toContain('x.example')
})
it('dedup state does not collide between spaces', () => {
runWithSpace(MAIN_SPACE_ID, () => markDedupSent('payment_expiry', 'fp-main', 'daily'))
runWithSpace('space-x', () => markDedupSent('payment_expiry', 'fp-x', 'daily'))
expect(runWithSpace('space-x', () => shouldSkipDedup('payment_expiry', 'fp-x', 'daily'))).toBe(true)
expect(runWithSpace(MAIN_SPACE_ID, () => shouldSkipDedup('payment_expiry', 'fp-main', 'daily'))).toBe(true)
})
it('escapes hoster text in HTML', () => {
const p = buildSyncDigestNotification(['✗ Acc <1>: <html>502 & co'])
expect(p?.messageHtml).toContain('Acc &lt;1&gt;: &lt;html&gt;502 &amp; co')
expect(p?.messageHtml).toContain('<b>')
})
it('cuts long text at a line break under the Telegram limit', () => {
const text = Array.from({ length: 500 }, (_, i) => `• host-${i} &amp; more`).join('\n')
const out = fitTelegramText(text)
expect(out.length).toBeLessThanOrEqual(4096)
expect(out).toMatch(/… и ещё \d+ строк$/)
expect(fitTelegramText('short')).toBe('short')
})
it('repeat outage of the same VPS is sent again', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response('{}'))
vi.stubGlobal('fetch', fetchMock)
const settings = { webhookEnabled: 1, webhookUrl: 'https://hook.test' }
const down = buildVpsHealthNotification('vps_down', [{ id: 'v1', label: 'v1' }])!
expect(await publishNotification(settings, down)).toBe('sent')
expect(await publishNotification(settings, down)).toBe('sent')
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
})
it('keeps errors of failed chats on partial delivery and sets a timeout', async () => {
const fetchMock = vi.fn<typeof fetch>(async (_url, init) => {
const chat = JSON.parse(String(init?.body)).chat_id
return Response.json(chat === 'a' ? { ok: true } : { ok: false, description: 'Bad Request: chat not found' })
})
vi.stubGlobal('fetch', fetchMock)
const r = await sendTelegramMessage('tok', 'a,b', 'hi')
expect(r.ok).toBe(true)
expect(r.error).toContain('b: Bad Request: chat not found')
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
})
})
+35 -15
View File
@@ -1,19 +1,40 @@
import { desc } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { desc, eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, schema } from '@cfdm/db'
import { getPaidUntilDate } from '@cfdm/shared/utils/paid-until'
import type { NotificationPayload } from './types.js'
const UPCOMING_DAYS = 7
/** Names/errors come from hosters and users; Telegram parse_mode=HTML rejects the whole message on a stray < or &. */
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const htmlLines = (lines: string[]) => lines.map(escapeHtml).join('\n')
type VpsRow = typeof schema.vps.$inferSelect
export function buildPaymentExpiryNotification(now = new Date()): NotificationPayload | null {
const db = getDb()
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const providerAccounts = db.select().from(schema.providerAccounts).all()
const payments = db.select().from(schema.payments).all()
const balanceLedger = db.select().from(schema.balanceLedger).all()
const providers = db.select().from(schema.providers).all()
const spaceId = getCurrentSpaceId()
const vpsList = db
.select()
.from(schema.vps)
.where(eq(schema.vps.spaceId, spaceId))
.orderBy(desc(schema.vps.createdAt))
.all()
const providerAccounts = db
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.spaceId, spaceId))
.all()
const payments = db.select().from(schema.payments).where(eq(schema.payments.spaceId, spaceId)).all()
const balanceLedger = db
.select()
.from(schema.balanceLedger)
.where(eq(schema.balanceLedger.spaceId, spaceId))
.all()
const providers = db.select().from(schema.providers).where(eq(schema.providers.spaceId, spaceId)).all()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
@@ -36,7 +57,7 @@ export function buildPaymentExpiryNotification(now = new Date()): NotificationPa
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const plain = `Истекает оплата (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
const html = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
const html = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${htmlLines(lines)}`
const fingerprint = upcoming
.map(({ vps, paidUntil }) => `${vps.id}:${paidUntil.toISOString().slice(0, 10)}`)
.sort()
@@ -56,7 +77,7 @@ export function buildSyncDigestNotification(digestLines: string[]): Notification
const changed = digestLines.filter((line) => !line.includes('без изменений'))
if (changed.length === 0) return null
const plain = `Синхронизация VPS:\n\n${changed.join('\n')}`
const html = `📋 <b>Синхронизация VPS</b>\n\n${changed.join('\n')}`
const html = `📋 <b>Синхронизация VPS</b>\n\n${htmlLines(changed)}`
return {
event: 'sync_digest',
fingerprint: changed.sort().join('|'),
@@ -70,7 +91,7 @@ export function buildSyncDigestNotification(digestLines: string[]): Notification
export function buildLowBalanceNotification(lines: string[]): NotificationPayload | null {
if (lines.length === 0) return null
const plain = `Низкий баланс:\n\n${lines.join('\n')}`
const html = `💰 <b>Низкий баланс</b>\n\n${lines.join('\n')}`
const html = `💰 <b>Низкий баланс</b>\n\n${htmlLines(lines)}`
return {
event: 'low_balance',
fingerprint: lines.sort().join('|'),
@@ -88,7 +109,7 @@ export function buildNewTariffsNotification(
if (tariffs.length === 0) return null
const lines = tariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
const plain = `Новые тарифы (${providerName}):\n\n${lines.join('\n')}`
const html = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
const html = `🆕 <b>Новые тарифы</b> (${escapeHtml(providerName)}):\n\n${htmlLines(lines)}`
const fingerprint = tariffs
.map((t) => `${t.name}:${t.price}`)
.sort()
@@ -112,7 +133,7 @@ export function buildVpsHealthNotification(
const title = event === 'vps_down' ? 'VPS недоступны' : 'VPS восстановлены'
const icon = event === 'vps_down' ? '🔴' : '🟢'
const plain = `${title}:\n\n${lines.join('\n')}`
const html = `${icon} <b>${title}</b>\n\n${lines.join('\n')}`
const html = `${icon} <b>${title}</b>\n\n${htmlLines(lines)}`
const fingerprint = hosts
.map((h) => h.id)
.sort()
@@ -123,8 +144,7 @@ export function buildVpsHealthNotification(
messagePlain: plain,
messageHtml: html,
data: { hosts: hosts.map((h) => h.id) },
dedup: 'state_transition',
stateKey: `vps_health:${event}`,
newStatus: fingerprint,
// newlyDown/newlyUp are already transitions (last_health_status); deduping again hid a repeat outage.
dedup: 'none',
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number]
export type NotificationChannel = 'telegram' | 'webhook'
export type DedupMode = 'daily' | 'fingerprint' | 'state_transition'
export type DedupMode = 'daily' | 'fingerprint' | 'state_transition' | 'none'
export interface NotificationPayload {
event: NotificationEvent
@@ -0,0 +1,39 @@
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
type VpsRow = typeof schema.vps.$inferSelect
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Existing VPS of a synced account: deterministic id → exact note token (bm-1 ≠ bm-12) → non-empty IP.
*/
export function findSyncedVps(
accountId: string,
id: string,
tokens: string[],
ip?: string | null,
): VpsRow | undefined {
const rows = getDb()
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(
eq(schema.vps.id, id),
...(ip ? [eq(schema.vps.ip, ip)] : []),
...tokens.map((t) => like(schema.vps.notes, `%${t}%`)),
),
),
)
.all()
const byId = rows.find((r) => r.id === id)
if (byId) return byId
for (const t of tokens) {
const re = new RegExp(`(^|[^\\w-])${escapeRe(t)}(?![\\w-])`)
const hit = rows.find((r) => re.test(r.notes ?? ''))
if (hit) return hit
}
return ip ? rows.find((r) => r.ip === ip) : undefined
}
+25 -1
View File
@@ -8,13 +8,37 @@ export interface RunAccountSyncResult extends SyncResult {
logId: string
}
/** Manual and scheduled syncs of one account must not interleave (duplicate inserts, tariff wipe). */
const inFlightAccounts = new Set<string>()
export class SyncInProgressError extends Error {
constructor() {
super('Синхронизация этого аккаунта уже выполняется')
}
}
export async function runAccountSync(
adapter: ProviderAdapter,
account: unknown,
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
): Promise<RunAccountSyncResult> {
const db = getDb()
const accountRow = account as { id: string; spaceId?: string }
if (inFlightAccounts.has(accountRow.id)) throw new SyncInProgressError()
inFlightAccounts.add(accountRow.id)
try {
return await runAccountSyncLocked(adapter, account, accountRow, opts)
} finally {
inFlightAccounts.delete(accountRow.id)
}
}
async function runAccountSyncLocked(
adapter: ProviderAdapter,
account: unknown,
accountRow: { id: string; spaceId?: string },
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean },
): Promise<RunAccountSyncResult> {
const db = getDb()
const logId = `sync-${accountRow.id}-${Date.now()}`
const spaceId = accountRow.spaceId ?? getCurrentSpaceId()
+57 -62
View File
@@ -2,8 +2,9 @@
* Sync RuVDS API v2 data into vps-tracker DB
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, getSqlite, schema } from '@cfdm/db'
import { findSyncedVps } from '../providers/find-synced-vps.js'
import type { RuvdsSyncAccount } from './context.js'
import { ruvdsCredentialsString } from './context.js'
@@ -112,19 +113,7 @@ export async function syncFromRuvds(
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
like(schema.vps.notes, `%ruvds-${vps.externalId}%`),
),
),
)
.get()
const existing = findSyncedVps(accountId, id, [`ruvds-${vps.externalId}`], vps.ip)
if (existing) {
let userOverrides: string[] = []
@@ -205,6 +194,7 @@ export async function syncFromRuvds(
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
spaceId: getCurrentSpaceId(),
id,
ip: vps.ip,
ipv6: vps.ipv6,
@@ -265,6 +255,7 @@ export async function syncFromRuvds(
const payId = `pay-ruvds-${accountId}-${payment.externalId}`
db.insert(schema.payments)
.values({
spaceId: getCurrentSpaceId(),
id: payId,
type: payment.type,
date: payment.date,
@@ -295,55 +286,59 @@ export async function syncFromRuvds(
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
// Fetch errors come back as [] — keep the old list instead of wiping it (and re-announcing all as new).
if (fetchTariffs && tariffItems.length > 0) {
getSqlite().transaction(() => {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-ruvds-${accountId}-${t.externalId}-${dcKey}`
: `tariff-ruvds-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-ruvds-${accountId}-${t.externalId}-${dcKey}`
: `tariff-ruvds-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
spaceId: getCurrentSpaceId(),
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
})()
}
if (!fetchVpsData) {
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
vi.mock('./uptime-check.js', () => ({ runVpsUptimeChecks: vi.fn() }))
import { runVpsUptimeChecks } from './uptime-check.js'
import { resetSchedulerState, runScheduledUptimeChecks } from './scheduler.js'
const uptimeMock = vi.mocked(runVpsUptimeChecks)
describe('scheduler guards', () => {
beforeEach(() => {
resetTestDb()
resetSchedulerState()
uptimeMock.mockReset()
})
afterEach(() => closeDb())
it('skips a tick while the previous one runs and respects the space interval', async () => {
let finish!: () => void
uptimeMock.mockImplementation(
() => new Promise((resolve) => (finish = () => resolve({ checked: 0, down: 0, newlyDown: [], newlyUp: [] }))),
)
const first = runScheduledUptimeChecks()
await runScheduledUptimeChecks() // overlapping tick → skipped
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
finish()
await first
expect(uptimeMock).toHaveBeenCalledTimes(1)
uptimeMock.mockResolvedValue({ checked: 0, down: 0, newlyDown: [], newlyUp: [] })
await runScheduledUptimeChecks() // space interval (5 min) not elapsed
expect(uptimeMock).toHaveBeenCalledTimes(1)
resetSchedulerState()
await runScheduledUptimeChecks()
expect(uptimeMock).toHaveBeenCalledTimes(2)
})
})
+75 -17
View File
@@ -1,5 +1,5 @@
import { eq, sql } from 'drizzle-orm'
import { getDb, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
import { getDb, getSqlite, pruneOldLogs, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
@@ -25,6 +25,8 @@ let notifyIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let cfdmSyncIntervalId: ReturnType<typeof setInterval> | null = null
let cfdmSyncInFlight = false
let retentionIntervalId: ReturnType<typeof setInterval> | null = null
const RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000
type AccountRow = typeof schema.providerAccounts.$inferSelect
type SettingsRow = typeof schema.settings.$inferSelect
@@ -63,9 +65,63 @@ function allSettings(): SettingsRow[] {
return settingsRepository.listAllSpaces()
}
export async function runNotificationTick(): Promise<void> {
function spaceIntervals(s: SettingsRow) {
return {
notify: Math.max(15, Number(s.notifyIntervalMinutes) || 60),
uptime: Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
sync: Math.max(15, Number(s.syncIntervalMinutes) || 60),
tariffs: Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440),
}
}
const runningJobs = new Set<string>()
/** A tick that finds the previous run of the same job still going is skipped, not stacked. */
async function exclusive(job: string, fn: () => Promise<void>): Promise<void> {
if (runningJobs.has(job)) {
console.warn(`Scheduler: ${job} ещё выполняется — тик пропущен`)
return
}
runningJobs.add(job)
try {
await fn()
} finally {
runningJobs.delete(job)
}
}
const lastRunAt = new Map<string, number>()
/** Timers tick at the smallest interval of all spaces; each space still runs at its own. */
function dueForSpace(job: string, spaceId: string, minutes: number): boolean {
const key = `${job}:${spaceId}`
const now = Date.now()
const last = lastRunAt.get(key)
if (last != null && now - last < minutes * 60_000 - 30_000) return false
lastRunAt.set(key, now)
return true
}
export function resetSchedulerState(): void {
runningJobs.clear()
lastRunAt.clear()
}
export const runNotificationTick = () => exclusive('notify', notificationTick)
export const runScheduledSync = () => exclusive('sync', scheduledSync)
export const runScheduledSyncTariffs = () => exclusive('tariffs', scheduledSyncTariffs)
export const runScheduledUptimeChecks = () => exclusive('uptime', scheduledUptimeChecks)
export const runRetention = () =>
exclusive('retention', async () => {
const r = pruneOldLogs(getSqlite())
const total = r.notificationLog + r.syncLog + r.vpsHealthChecks + r.staleSyncs
if (total > 0) console.log('Retention:', JSON.stringify(r))
})
async function notificationTick(): Promise<void> {
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
if (!dueForSpace('notify', spaceId, spaceIntervals(settings).notify)) continue
try {
await runWithSpaceAsync(spaceId, async () => {
const payload = buildPaymentExpiryNotification()
@@ -80,10 +136,11 @@ export async function runNotificationTick(): Promise<void> {
}
}
export async function runScheduledSync(): Promise<void> {
async function scheduledSync(): Promise<void> {
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
if (!dueForSpace('sync', spaceId, spaceIntervals(settings).sync)) continue
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
@@ -134,10 +191,11 @@ export async function runScheduledSync(): Promise<void> {
}
}
export async function runScheduledSyncTariffs(): Promise<void> {
async function scheduledSyncTariffs(): Promise<void> {
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
if (!dueForSpace('tariffs', spaceId, spaceIntervals(settings).tariffs)) continue
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
@@ -178,9 +236,10 @@ export async function runScheduledSyncTariffs(): Promise<void> {
}
}
export async function runScheduledUptimeChecks(): Promise<void> {
async function scheduledUptimeChecks(): Promise<void> {
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
if (!dueForSpace('uptime', spaceId, spaceIntervals(settings).uptime)) continue
try {
await runWithSpaceAsync(spaceId, async () => {
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
@@ -261,19 +320,12 @@ function pickSchedulerIntervals(rows: SettingsRow[]): {
let tariffsInterval: number | null = null
for (const s of rows) {
notifyInterval = Math.min(
notifyInterval,
Math.max(15, Number(s.notifyIntervalMinutes) || 60),
)
uptimeInterval = Math.min(
uptimeInterval,
Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
)
const i = spaceIntervals(s)
notifyInterval = Math.min(notifyInterval, i.notify)
uptimeInterval = Math.min(uptimeInterval, i.uptime)
if (s.syncEnabled) {
const si = Math.max(15, Number(s.syncIntervalMinutes) || 60)
const ti = Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440)
syncInterval = syncInterval == null ? si : Math.min(syncInterval, si)
tariffsInterval = tariffsInterval == null ? ti : Math.min(tariffsInterval, ti)
syncInterval = syncInterval == null ? i.sync : Math.min(syncInterval, i.sync)
tariffsInterval = tariffsInterval == null ? i.tariffs : Math.min(tariffsInterval, i.tariffs)
}
}
return { notifyInterval, uptimeInterval, syncInterval, tariffsInterval }
@@ -290,6 +342,8 @@ export function startScheduler(): void {
uptimeIntervalId = null
if (cfdmSyncIntervalId) clearInterval(cfdmSyncIntervalId)
cfdmSyncIntervalId = null
if (retentionIntervalId) clearInterval(retentionIntervalId)
retentionIntervalId = null
try {
const rows = allSettings()
@@ -306,6 +360,8 @@ export function startScheduler(): void {
)
void runNotificationTick()
void runScheduledUptimeChecks()
retentionIntervalId = setInterval(() => void runRetention(), RETENTION_INTERVAL_MS)
void runRetention()
const parts = [
`notify every ${notifyInterval} min`,
@@ -349,6 +405,8 @@ export function stopScheduler(): void {
uptimeIntervalId = null
if (cfdmSyncIntervalId) clearInterval(cfdmSyncIntervalId)
cfdmSyncIntervalId = null
if (retentionIntervalId) clearInterval(retentionIntervalId)
retentionIntervalId = null
}
export function restartScheduler(): void {
+24 -2
View File
@@ -61,6 +61,25 @@ export function formatTelegramApiError(
return `${chatId}: ${fallback}`
}
const TELEGRAM_TIMEOUT_MS = 10_000
/** Telegram rejects text over 4096 chars; cut at a line break so no HTML entity/tag is split. */
const TELEGRAM_MAX_TEXT = 4096
export function fitTelegramText(text: string): string {
if (text.length <= TELEGRAM_MAX_TEXT) return text
const lines = text.split('\n')
const kept: string[] = []
let len = 0
const reserve = 40 // room for the "and N more" tail
for (const line of lines) {
if (len + line.length + 1 > TELEGRAM_MAX_TEXT - reserve) break
kept.push(line)
len += line.length + 1
}
if (kept.length === 0) return text.slice(0, TELEGRAM_MAX_TEXT - 1) + '…'
return `${kept.join('\n')}\n… и ещё ${lines.length - kept.length} строк`
}
export async function sendTelegramMessage(
token: string,
chatIds: string | string[],
@@ -81,7 +100,7 @@ export async function sendTelegramMessage(
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
const payload: Record<string, unknown> = {
text,
text: fitTelegramText(text),
parse_mode: 'HTML',
disable_web_page_preview: true,
}
@@ -97,6 +116,8 @@ export async function sendTelegramMessage(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, chat_id: chatId }),
// A hung API/proxy would otherwise block the notify tick (and every tick skipped behind it).
signal: AbortSignal.timeout(TELEGRAM_TIMEOUT_MS),
})
const rawBody = await res.text()
let data: TelegramApiResponse = {}
@@ -119,6 +140,7 @@ export async function sendTelegramMessage(
}
}
if (anyOk) return { ok: true }
// Partial delivery counts as sent, but the failed chats still reach the journal.
if (anyOk) return errors.length ? { ok: true, error: errors.join('; ') } : { ok: true }
return { ok: false, error: errors.join('; ') || 'Не удалось отправить' }
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getCurrentSpaceId, getDb, schema } from '@cfdm/db'
const CHECK_TIMEOUT_MS = 5000
@@ -67,6 +67,7 @@ export async function runVpsUptimeChecks(): Promise<UptimeCheckResult> {
db.insert(schema.vpsHealthChecks)
.values({
spaceId: getCurrentSpaceId(),
id: `hc-${randomUUID()}`,
vpsId: row.id,
checkedAt: now,
+57 -62
View File
@@ -2,8 +2,9 @@
* Sync Macloud / VDSina UserAPI data into vps-tracker DB
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, getSqlite, schema } from '@cfdm/db'
import { findSyncedVps } from '../providers/find-synced-vps.js'
import type { UserApiSyncAccount } from './context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
@@ -117,19 +118,7 @@ export async function syncFromUserApi(
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
like(schema.vps.notes, `%${idPrefix}-${vps.externalId}%`),
),
),
)
.get()
const existing = findSyncedVps(accountId, id, [`${idPrefix}-${vps.externalId}`], vps.ip)
if (existing) {
let userOverrides: string[] = []
@@ -193,6 +182,7 @@ export async function syncFromUserApi(
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
spaceId: getCurrentSpaceId(),
id,
ip: vps.ip,
ipv6: vps.ipv6,
@@ -253,6 +243,7 @@ export async function syncFromUserApi(
const payId = `pay-${idPrefix}-${accountId}-${payment.externalId}`
db.insert(schema.payments)
.values({
spaceId: getCurrentSpaceId(),
id: payId,
type: payment.type,
date: payment.date,
@@ -283,55 +274,59 @@ export async function syncFromUserApi(
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
// Fetch errors come back as [] — keep the old list instead of wiping it (and re-announcing all as new).
if (fetchTariffs && tariffItems.length > 0) {
getSqlite().transaction(() => {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-${idPrefix}-${accountId}-${t.externalId}-${dcKey}`
: `tariff-${idPrefix}-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-${idPrefix}-${accountId}-${t.externalId}-${dcKey}`
: `tariff-${idPrefix}-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
spaceId: getCurrentSpaceId(),
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
})()
}
if (!fetchVpsData) {
+57 -63
View File
@@ -2,8 +2,9 @@
* Sync Veesp client area data into vps-tracker DB
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, getSqlite, schema } from '@cfdm/db'
import { findSyncedVps } from '../providers/find-synced-vps.js'
import type { VeespSyncAccount } from './context.js'
import { veespCredentialsString } from './context.js'
@@ -101,20 +102,7 @@ export async function syncFromVeesp(
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
like(schema.vps.notes, `%veesp-${vps.externalId}%`),
like(schema.vps.notes, `%veesp-${record.serviceId}%`),
),
),
)
.get()
const existing = findSyncedVps(accountId, id, [`veesp-${vps.externalId}`, `veesp-${record.serviceId}`], vps.ip)
if (existing) {
let userOverrides: string[] = []
@@ -195,6 +183,7 @@ export async function syncFromVeesp(
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
spaceId: getCurrentSpaceId(),
id,
ip: vps.ip,
ipv6: vps.ipv6,
@@ -255,6 +244,7 @@ export async function syncFromVeesp(
const payId = `pay-veesp-${accountId}-${payment.externalId}`
db.insert(schema.payments)
.values({
spaceId: getCurrentSpaceId(),
id: payId,
type: payment.type,
date: payment.date,
@@ -285,55 +275,59 @@ export async function syncFromVeesp(
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
// Fetch errors come back as [] — keep the old list instead of wiping it (and re-announcing all as new).
if (fetchTariffs && tariffItems.length > 0) {
getSqlite().transaction(() => {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-veesp-${accountId}-${t.externalId}-${dcKey}`
: `tariff-veesp-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-veesp-${accountId}-${t.externalId}-${dcKey}`
: `tariff-veesp-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
spaceId: getCurrentSpaceId(),
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
})()
}
if (!fetchVpsData) {
-34
View File
@@ -4,37 +4,3 @@ export interface WebhookPayload {
data?: Record<string, unknown>
timestamp: string
}
export async function sendWebhook(url: string, payload: WebhookPayload): Promise<void> {
const target = url?.trim()
if (!target) return
try {
const res = await fetch(target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
console.warn(`Webhook failed (${res.status}): ${target}`)
}
} catch (err) {
console.warn('Webhook error:', err instanceof Error ? err.message : err)
}
}
export async function notifyWebhook(
settings: { webhookUrl?: string | null; webhookEnabled?: number | boolean | null },
event: string,
message: string,
data?: Record<string, unknown>,
): Promise<void> {
if (!settings.webhookEnabled) return
const url = settings.webhookUrl?.trim()
if (!url) return
await sendWebhook(url, {
event,
message,
data,
timestamp: new Date().toISOString(),
})
}
+2
View File
@@ -18,6 +18,8 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
+16
View File
@@ -2,6 +2,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
@@ -11,8 +12,10 @@ import {
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
import { Link } from '@tanstack/react-router'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
@@ -88,6 +91,19 @@ export function AppSwitcher() {
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={`${authPortalUrl().replace(/\/$/, '')}/admin/apps`} />}
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem nativeButton={false} render={<Link to="/settings/integrations" />}>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters'
import { filterCensorcheckRuns, groupRunsByService, collectServiceColumns, collectProbeColumns, shortHostLabel } from './blocking-filters'
import type { CensorcheckRunDto } from './types'
import { runHosterLabel, type CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
id: 'ccrun-1',
@@ -88,6 +88,37 @@ describe('filterCensorcheckRuns', () => {
]),
).toHaveLength(0)
})
it('фильтрует по хостеру из прогона, если инвентарь пуст', () => {
const unmatched = run({
matchedVpsId: null,
vps: null,
detectedHoster: 'DigitalOcean',
})
expect(
filterCensorcheckRuns([unmatched], [
{ id: '1', field: 'hoster', operator: 'contains', values: ['digital'] },
]),
).toHaveLength(1)
expect(
filterCensorcheckRuns([unmatched], [
{ id: '1', field: 'hoster', operator: 'contains', values: ['hetzner'] },
]),
).toHaveLength(0)
})
})
describe('runHosterLabel', () => {
it('предпочитает имя из инвентаря', () => {
expect(runHosterLabel(run())).toBe('Hoster')
expect(runHosterLabel(run({ detectedHoster: 'Hetzner' }))).toBe('Hoster')
})
it('берёт detectedHoster если VPS не сматчен', () => {
expect(runHosterLabel(run({ matchedVpsId: null, vps: null, detectedHoster: 'Hetzner' }))).toBe(
'Hetzner',
)
})
})
describe('groupRunsByService', () => {
@@ -39,7 +39,7 @@ export function filterCensorcheckRuns(
continue
}
if (filter.field === 'hoster') {
const name = (run.vps?.providerName ?? '').toLowerCase()
const name = `${run.vps?.providerName ?? ''} ${run.detectedHoster ?? ''}`.toLowerCase()
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
if (!hit) return false
continue
@@ -13,7 +13,7 @@ import {
} from './blocking-filters'
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { StatusMatrixCell } from './status-matrix-cell'
import type { CensorcheckRunDto } from './types'
import { runHosterLabel, type CensorcheckRunDto } from './types'
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
export const BLOCKING_MATRIX_GRID = {
@@ -31,12 +31,14 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
icon: ServerIcon,
enableHiding: false,
enablePinning: true,
size: 220,
minSize: 180,
size: 240,
minSize: 200,
sortValue: (row) => row.vps?.dns || row.probePublicIp,
cell: (row) => {
const title = row.vps?.dns || row.probePublicIp
const ip = row.probePublicIp
const hoster = runHosterLabel(row)
const secondary = hoster ? `${ip} · ${hoster}` : ip
const link = row.matchedVpsId ? (
<Link
to="/vps/$vpsId"
@@ -49,7 +51,7 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
) : (
<span className="font-medium">Unknown VPS</span>
)
return dataGridCellStack(link, ip)
return dataGridCellStack(link, secondary)
},
}
}
@@ -2,8 +2,10 @@ import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BanIcon,
CalendarClockIcon,
CopyIcon,
GlobeIcon,
RouterIcon,
ServerIcon,
ShieldAlertIcon,
} from 'lucide-react'
@@ -38,6 +40,9 @@ import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
import {
CENSORCHECK_STATUS_LABELS,
LAUNCHER_CMD,
LAUNCHER_CMD_DAILY,
LAUNCHER_CMD_ROS,
LAUNCHER_CMD_ROS_DAILY,
formatCheckedAt,
type CensorcheckRunDto,
} from './types'
@@ -143,21 +148,47 @@ export function BlockingPage() {
const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
const copyLauncher = (
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
<>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_DAILY, 'Команда для ежедневного запуска скопирована')}
>
<CalendarClockIcon data-icon="inline-start" />
Раз в день
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS, 'Команда MikroTik скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS_DAILY, 'Команда MikroTik для ежедневного запуска скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik · день
</Button>
</>
)
return (
<PageShell>
<PageHeader
title="Статус блокировок"
description="Проверки DPI и геоблокировок с VPS через censorcheck."
description="Проверки DPI и геоблокировок с VPS или MikroTik 7.22+."
actions={copyLauncher}
/>
<KpiStatGrid
@@ -241,7 +272,7 @@ export function BlockingPage() {
onRetry={() => void currentQuery.refetch()}
empty={filtered.length === 0}
emptyTitle="Пока нет проверок"
emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`}
emptyDescription={`На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`}
emptyAction={copyLauncher}
skeleton={<TableSkeleton />}
>
@@ -278,7 +309,7 @@ export function BlockingPage() {
onRowClick={setSelected}
emptyState={{
title: 'История пуста',
description: `На VPS выполните: ${LAUNCHER_CMD}`,
description: `На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`,
action: copyLauncher,
}}
/>
@@ -1,4 +1,8 @@
import type { CensorcheckRunDto } from './types'
export type SnapshotRun = {
id: string
createdAt: string
probePublicIp: string
}
export type BlockingSnapshotTick = {
key: string
@@ -24,18 +28,18 @@ export function formatSnapshotTickLabel(dayKey: string): string {
})
}
export function mergeCensorcheckRuns(
current: CensorcheckRunDto[],
history: CensorcheckRunDto[],
): CensorcheckRunDto[] {
const map = new Map<string, CensorcheckRunDto>()
export function mergeCensorcheckRuns<T extends { id: string }>(
current: T[],
history: T[],
): T[] {
const map = new Map<string, T>()
for (const run of history) map.set(run.id, run)
for (const run of current) map.set(run.id, run)
return [...map.values()]
}
export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapshotTick[] {
const byDay = new Map<string, CensorcheckRunDto[]>()
export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] {
const byDay = new Map<string, SnapshotRun[]>()
for (const run of runs) {
const key = snapshotDayKey(run.createdAt)
const list = byDay.get(key)
@@ -56,12 +60,12 @@ export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapsho
}
/** Latest run per probe IP at or before `asOf`. */
export function latestRunsAsOf(
runs: CensorcheckRunDto[],
export function latestRunsAsOf<T extends SnapshotRun>(
runs: T[],
asOf: string,
): CensorcheckRunDto[] {
): T[] {
if (!asOf) return []
const byIp = new Map<string, CensorcheckRunDto>()
const byIp = new Map<string, T>()
for (const run of runs) {
if (run.createdAt > asOf) continue
const previous = byIp.get(run.probePublicIp)
@@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { Building2Icon, GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
@@ -16,6 +16,7 @@ import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
runHosterLabel,
type CensorcheckRunDto,
} from './types'
@@ -67,6 +68,12 @@ export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) {
</Link>
) : undefined,
},
{
id: 'hoster',
icon: <Building2Icon />,
label: 'Хостер',
description: runHosterLabel(detail) || '—',
},
{
id: 'geo',
icon: <MapPinIcon />,
@@ -40,6 +40,7 @@ export type CensorcheckRunDto = {
createdAt: string
completedAt: string
observedSourceIp: string | null
detectedHoster?: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
@@ -56,6 +57,11 @@ export const CENSORCHECK_STATUS_LABELS: Record<string, string> = {
}
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash'
export const LAUNCHER_CMD_DAILY = 'curl -fsSL https://vt.shnt.top/cc | bash -s -- --daily'
export const LAUNCHER_CMD_ROS =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc'
export const LAUNCHER_CMD_ROS_DAILY =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/cc.rsc?daily=1" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc'
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
@@ -67,12 +73,19 @@ export function formatCheckedAt(iso: string): string {
return date.toLocaleString('ru-RU')
}
export function runHosterLabel(run: CensorcheckRunDto): string {
const inventory = run.vps?.providerName?.trim() ?? ''
if (inventory) return inventory
return run.detectedHoster?.trim() ?? ''
}
export function runSearchText(run: CensorcheckRunDto): string {
const parts = [
run.probePublicIp,
run.claimedPublicIp ?? '',
run.vps?.dns ?? '',
run.vps?.providerName ?? '',
run.detectedHoster ?? '',
run.vps?.country ?? '',
...(run.results ?? []).map((row) => `${row.serviceKey} ${row.serviceLabel}`),
]
@@ -17,7 +17,7 @@ interface CountedLineTabsProps {
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
/** Page line tabs with mono counts (design «VPS-Tracker» ptabs; c-tabs-2). */
export function CountedLineTabs({
tabs,
value,
@@ -37,7 +37,7 @@ export function CountedLineTabs({
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{tab.count}
</span>
) : null}
+40 -3
View File
@@ -1,7 +1,9 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { IconTile } from '@/components/reui/icon-tile'
import { TruncatedText } from '@/components/truncated-text'
import { cn } from '@cfdm/ui/lib/utils'
export function dataGridCellStack(
primary: ReactNode,
@@ -26,14 +28,49 @@ export function dataGridCellStack(
)
}
/**
* Name cell DNA — IconTile elevated size-10.5 + truncate.
* Preview: https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/icon-tile
*/
export function DataGridNameCell({
icon: Icon,
title,
subtitle,
iconClassName = 'text-muted-foreground',
className,
}: {
icon: LucideIcon
title: ReactNode
subtitle?: ReactNode
iconClassName?: string
className?: string
}) {
return (
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<IconTile variant="elevated" className="size-10.5" aria-hidden>
<Icon className={iconClassName} />
</IconTile>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
</div>
)
}
export function dataGridCellWithIcon(
icon: ReactNode,
children: ReactNode,
className?: string,
) {
return (
<div className={cn('flex items-center gap-2', className)}>
<span className="shrink-0 text-muted-foreground">{icon}</span>
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<IconTile variant="elevated" className="size-10.5" aria-hidden>
{icon}
</IconTile>
{children}
</div>
)
@@ -9,6 +9,7 @@ import {
LayoutDashboardIcon,
SearchIcon,
ShieldAlertIcon,
GlobeIcon,
} from 'lucide-react'
import {
@@ -69,6 +70,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
<ShieldAlertIcon />
<span>Статус блокировок</span>
</CommandItem>
<CommandItem onSelect={() => go('/geo')}>
<GlobeIcon />
<span>GeoIP</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
@@ -0,0 +1,97 @@
import { Badge } from '@/components/reui/badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
import { countryBadgeText } from './geo-filters'
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
const STATUS_VARIANT: Record<
string,
'success-light' | 'outline' | 'destructive-outline' | 'warning-light' | 'warning-outline'
> = {
ok: 'success-light',
na: 'outline',
denied: 'destructive-outline',
rate_limit: 'warning-light',
server_error: 'warning-outline',
}
function countryLabel(code: string | null | undefined): string {
if (!code) return ''
const iso = canonicalizeCountryValue(code).country ?? code
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
}
function formatTipCountry(value: string | null | undefined): string | null {
const parsed = canonicalizeCountryValue(value)
const iso = parsed.country
const name = iso ? countryLabel(iso) : ''
const parts = [iso, parsed.iata, name && name !== iso ? name : null].filter(Boolean)
return parts.length ? parts.join(' · ') : null
}
/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */
export function CountryMatrixCell({
status,
countryIpv4,
countryIpv6,
serviceLabel,
vpsLabel,
checkedAt,
onSelect,
}: {
status?: string | null
countryIpv4?: string | null
countryIpv6?: string | null
serviceLabel: string
vpsLabel: string
checkedAt?: string
onSelect?: () => void
}) {
const stored = countryIpv4 || countryIpv6 || null
const badgeIso = countryBadgeText(stored)
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
const display = status === 'ok' && badgeIso ? badgeIso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
const variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline'
const tip = [
serviceLabel,
vpsLabel,
formatTipCountry(stored) ?? statusLabel,
countryIpv4 ? `IPv4 ${countryIpv4}` : null,
countryIpv6 ? `IPv6 ${countryIpv6}` : null,
checkedAt ? formatCheckedAt(checkedAt) : null,
]
.filter(Boolean)
.join(' · ')
const badge = (
<Badge variant={variant} size="sm" radius="full" aria-label={tip}>
{display}
</Badge>
)
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="inline-flex"
onClick={(event) => {
if (!onSelect) return
event.stopPropagation()
onSelect()
}}
/>
}
>
{badge}
</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters'
import {
collectServiceColumns,
filterIpregionRuns,
isGeoMismatch,
uniqueCountries,
} from './geo-filters'
import { runHosterLabel, type IpregionRunDto } from './types'
const run = (overrides: Partial<IpregionRunDto> = {}): IpregionRunDto => ({
id: 'iprun-1',
spaceId: 'space-main',
runId: '11111111-1111-4111-8111-111111111111',
probePublicIp: '203.0.113.10',
claimedPublicIp: null,
matchedVpsId: 'vps-1',
status: 'complete',
schemaVersion: 1,
launcherVersion: '1',
ipregionVersion: '1',
summary: {
total: 2,
ok: 2,
na: 0,
denied: 0,
rate_limit: 0,
server_error: 0,
},
createdAt: '2026-08-22T00:00:00.000Z',
completedAt: '2026-08-22T00:00:00.000Z',
observedSourceIp: '203.0.113.10',
vps: {
id: 'vps-1',
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerName: 'Hoster',
country: 'Нидерланды',
city: 'Amsterdam',
datacenter: 'AMS',
vcpu: 2,
ramGb: 4,
diskGb: 40,
},
results: [
{
id: 'r1',
runId: 'iprun-1',
serviceKey: 'maxmind.com',
serviceLabel: 'maxmind.com',
group: 'primary',
countryIpv4: 'NL',
countryIpv6: null,
status: 'ok',
},
{
id: 'r2',
runId: 'iprun-1',
serviceKey: 'google',
serviceLabel: 'Google',
group: 'custom',
countryIpv4: 'US',
countryIpv6: null,
status: 'ok',
},
],
...overrides,
})
describe('filterIpregionRuns', () => {
it('фильтрует по ISO страны', () => {
const filters: Filter[] = [
{ id: '1', field: 'country', operator: 'contains', values: ['NL'] },
]
expect(filterIpregionRuns([run()], filters)).toHaveLength(1)
expect(
filterIpregionRuns([run()], [
{ id: '1', field: 'country', operator: 'contains', values: ['JP'] },
]),
).toHaveLength(0)
})
})
describe('runHosterLabel', () => {
it('предпочитает имя из инвентаря', () => {
expect(runHosterLabel(run())).toBe('Hoster')
})
})
describe('collectServiceColumns', () => {
it('ставит primary, затем custom, затем cdn', () => {
const cols = collectServiceColumns([run()])
expect(cols[0]?.key).toBe('maxmind.com')
expect(cols[0]?.group).toBe('primary')
const google = cols.find((col) => col.key === 'google')
const maxmind = cols.find((col) => col.key === 'maxmind.com')
const cdn = cols.find((col) => col.key === 'cloudflare cdn')
expect(google).toBeDefined()
expect(cdn).toBeDefined()
expect(cols.indexOf(maxmind!)).toBeLessThan(cols.indexOf(google!))
expect(cols.indexOf(google!)).toBeLessThan(cols.indexOf(cdn!))
})
})
describe('uniqueCountries / mismatch', () => {
it('собирает уникальные ISO', () => {
expect(uniqueCountries([run()]).sort()).toEqual(['NL', 'US'])
})
it('извлекает ISO из CDN SE (ARN)', () => {
expect(
uniqueCountries([
run({
results: [
{
id: 'r-cdn',
runId: 'iprun-1',
serviceKey: 'cloudflare cdn',
serviceLabel: 'Cloudflare CDN',
group: 'cdn',
countryIpv4: 'SE (ARN)',
countryIpv6: null,
status: 'ok',
},
],
}),
]),
).toEqual(['SE'])
})
it('считает расхождение с инвентарём', () => {
expect(
isGeoMismatch(
run({
results: [
{
id: 'r1',
runId: 'iprun-1',
serviceKey: 'maxmind.com',
serviceLabel: 'maxmind.com',
group: 'primary',
countryIpv4: 'US',
countryIpv6: null,
status: 'ok',
},
{
id: 'r2',
runId: 'iprun-1',
serviceKey: 'google',
serviceLabel: 'Google',
group: 'custom',
countryIpv4: 'US',
countryIpv6: null,
status: 'ok',
},
],
}),
),
).toBe(true)
expect(
isGeoMismatch(
run({
results: [
{
id: 'r1',
runId: 'iprun-1',
serviceKey: 'maxmind.com',
serviceLabel: 'maxmind.com',
group: 'primary',
countryIpv4: 'NL',
countryIpv6: null,
status: 'ok',
},
],
}),
),
).toBe(false)
})
})
@@ -0,0 +1,296 @@
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
import {
IPREGION_CDN_SERVICES,
IPREGION_CUSTOM_SERVICES,
IPREGION_PRIMARY_SERVICES,
} from '@cfdm/shared/contracts/ipregion'
import { getActiveFilters } from '@/components/reui-kit'
import type { Filter } from '@/components/reui/filters'
import {
runSearchText,
type IpregionResultDto,
type IpregionRunDto,
} from './types'
const GROUP_RANK: Record<string, number> = { primary: 0, custom: 1, cdn: 2 }
export function resultCountryIso(value: string | null | undefined): string | null {
return canonicalizeCountryValue(value).country
}
export function inventoryCountryCode(run: IpregionRunDto): string | null {
const raw = run.vps?.country?.trim() ?? ''
if (!raw) return null
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase()
return COUNTRY_BY_NAME_RU[raw.toLowerCase()]?.code ?? COUNTRY_BY_CODE[raw.toUpperCase()]?.code ?? null
}
export function countryName(code: string | null | undefined): string {
if (!code) return ''
const iso = resultCountryIso(code) ?? code
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
}
export function countryBadgeText(value: string | null | undefined): string {
const parsed = canonicalizeCountryValue(value)
if (parsed.country && parsed.iata) return parsed.country
if (parsed.country) return parsed.country
if (parsed.iata) return parsed.iata
return value?.trim() || ''
}
export function majorityCountry(run: IpregionRunDto): string | null {
const counts = new Map<string, number>()
for (const row of run.results ?? []) {
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(row.countryIpv6)
if (row.status !== 'ok' || !code) continue
counts.set(code, (counts.get(code) ?? 0) + 1)
}
let best: string | null = null
let bestCount = 0
for (const [code, count] of counts) {
if (count > bestCount) {
best = code
bestCount = count
}
}
return best
}
export function isGeoMismatch(run: IpregionRunDto): boolean {
const inventory = inventoryCountryCode(run)
const geo = majorityCountry(run)
if (!inventory || !geo) return false
return inventory !== geo
}
export function uniqueCountries(runs: IpregionRunDto[]): string[] {
const set = new Set<string>()
for (const run of runs) {
for (const row of run.results ?? []) {
if (row.status === 'ok') {
const v4 = resultCountryIso(row.countryIpv4)
const v6 = resultCountryIso(row.countryIpv6)
if (v4) set.add(v4)
if (v6) set.add(v6)
}
}
}
return [...set].sort()
}
export function filterIpregionRuns(runs: IpregionRunDto[], filters: Filter[]): IpregionRunDto[] {
const active = getActiveFilters(filters)
if (active.length === 0) return runs
return runs.filter((run) => {
for (const filter of active) {
const values = filter.values.map((value) => String(value))
if (filter.field === 'status') {
const statuses = (run.results ?? []).map((row) => row.status)
const hit = values.some((value) => statuses.includes(value))
if (filter.operator === 'is_not_any_of' ? hit : !hit) return false
continue
}
if (filter.field === 'service') {
const hay = (run.results ?? [])
.map((row) => `${row.serviceKey} ${row.serviceLabel}`)
.join(' ')
.toLowerCase()
const hit = values.some(
(value) =>
hay.includes(value.toLowerCase()) ||
(run.results ?? []).some((row) => row.serviceKey === value.toLowerCase()),
)
if (!hit) return false
continue
}
if (filter.field === 'hoster') {
const name = `${run.vps?.providerName ?? ''} ${run.detectedHoster ?? ''}`.toLowerCase()
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
if (!hit) return false
continue
}
if (filter.field === 'country') {
const codes = [
inventoryCountryCode(run) ?? '',
majorityCountry(run) ?? '',
...(run.results ?? []).flatMap((row) => [row.countryIpv4 ?? '', row.countryIpv6 ?? '']),
]
.join(' ')
.toLowerCase()
const names = countryName(majorityCountry(run)).toLowerCase()
const hay = `${codes} ${names} ${(run.vps?.country ?? '').toLowerCase()}`
const hit = values.some((value) => hay.includes(value.toLowerCase()))
if (!hit) return false
continue
}
if (filter.field === 'matched') {
const matched = run.matchedVpsId ? 'matched' : 'unmatched'
if (!values.includes(matched)) return false
continue
}
if (filter.field === 'q') {
const hay = runSearchText(run)
const hit = values.some((token) => hay.includes(token.toLowerCase()))
if (!hit) return false
}
}
return true
})
}
export type GeoServiceRow = {
id: string
serviceKey: string
serviceLabel: string
group: string
probes: Array<{
runId: string
probePublicIp: string
matchedVpsId: string | null
dns: string
country: string | null
status: string
countryIpv4: string | null
countryIpv6: string | null
createdAt: string
vpsId: string | null
}>
}
export function groupRunsByService(runs: IpregionRunDto[]): GeoServiceRow[] {
const map = new Map<string, GeoServiceRow>()
for (const run of runs) {
for (const result of run.results ?? []) {
const existing = map.get(result.serviceKey)
const probe = {
runId: run.id,
probePublicIp: run.probePublicIp,
matchedVpsId: run.matchedVpsId,
dns: run.vps?.dns ?? '',
country: result.countryIpv4,
status: result.status,
countryIpv4: result.countryIpv4,
countryIpv6: result.countryIpv6,
createdAt: run.createdAt,
vpsId: run.matchedVpsId,
}
if (existing) {
existing.probes.push(probe)
} else {
map.set(result.serviceKey, {
id: result.serviceKey,
serviceKey: result.serviceKey,
serviceLabel: result.serviceLabel,
group: result.group,
probes: [probe],
})
}
}
}
return [...map.values()].sort((a, b) => {
const rank = (GROUP_RANK[a.group] ?? 9) - (GROUP_RANK[b.group] ?? 9)
if (rank !== 0) return rank
return a.serviceKey.localeCompare(b.serviceKey)
})
}
export type MatrixColumn = {
key: string
label: string
title: string
group: string
}
export function shortServiceLabel(value: string): string {
const host = value.trim()
if (host.length <= 14) return host
return host.replace(/\.(com|org|net|io|co)$/i, '')
}
function canonicalKeys(): Array<{ key: string; group: string; label: string }> {
return [
...IPREGION_PRIMARY_SERVICES.map((key) => ({ key, group: 'primary', label: key })),
...IPREGION_CUSTOM_SERVICES.map((key) => ({ key, group: 'custom', label: key })),
...IPREGION_CDN_SERVICES.map((key) => ({ key, group: 'cdn', label: key })),
]
}
export function collectServiceColumns(runs: IpregionRunDto[]): MatrixColumn[] {
const canonical = canonicalKeys()
const canonicalSet = new Set(canonical.map((item) => item.key))
const extras: MatrixColumn[] = []
for (const run of runs) {
for (const result of run.results ?? []) {
if (canonicalSet.has(result.serviceKey)) continue
if (extras.some((col) => col.key === result.serviceKey)) continue
extras.push({
key: result.serviceKey,
label: shortServiceLabel(result.serviceLabel),
title: result.serviceLabel,
group: result.group,
})
}
}
extras.sort((a, b) => {
const rank = (GROUP_RANK[a.group] ?? 9) - (GROUP_RANK[b.group] ?? 9)
if (rank !== 0) return rank
return a.key.localeCompare(b.key)
})
const extrasByGroup = {
primary: extras.filter((col) => col.group === 'primary'),
custom: extras.filter((col) => col.group === 'custom'),
cdn: extras.filter((col) => col.group === 'cdn'),
}
const fromCanonical = (group: string, keys: readonly string[]) =>
keys.map((key) => ({
key,
label: shortServiceLabel(key),
title: key,
group,
}))
return [
...fromCanonical('primary', IPREGION_PRIMARY_SERVICES),
...extrasByGroup.primary,
...fromCanonical('custom', IPREGION_CUSTOM_SERVICES),
...extrasByGroup.custom,
...fromCanonical('cdn', IPREGION_CDN_SERVICES),
...extrasByGroup.cdn,
]
}
export function collectProbeColumns(runs: IpregionRunDto[]): MatrixColumn[] {
return runs.map((run) => {
const title = run.vps?.dns || run.probePublicIp
return {
key: run.id,
label: shortServiceLabel(title),
title,
group: 'probe',
}
})
}
export function resultByService(
run: IpregionRunDto,
serviceKey: string,
): IpregionResultDto | undefined {
return (run.results ?? []).find((row) => row.serviceKey === serviceKey)
}
export function serviceMatrixRows(runs: IpregionRunDto[]): GeoServiceRow[] {
const grouped = new Map(groupRunsByService(runs).map((row) => [row.serviceKey, row]))
return collectServiceColumns(runs).map((col) => {
const existing = grouped.get(col.key)
if (existing) return existing
return {
id: col.key,
serviceKey: col.key,
serviceLabel: col.title,
group: col.group,
probes: [],
}
})
}
@@ -0,0 +1,198 @@
import { useMemo, type ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, ServerIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
import {
collectProbeColumns,
collectServiceColumns,
resultByService,
type GeoServiceRow,
} from './geo-filters'
import { CountryMatrixCell } from './country-matrix-cell'
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { runHosterLabel, type IpregionRunDto } from './types'
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
export const GEO_MATRIX_GRID = {
tableWidth: 'auto' as const,
horizontalScroll: true,
}
const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
function vpsIdentityColumn(): DataGridColumn<IpregionRunDto> {
return {
key: 'vps',
header: 'VPS / IP',
headerTitle: 'VPS / IP',
icon: ServerIcon,
enableHiding: false,
enablePinning: true,
size: 240,
minSize: 200,
sortValue: (row) => row.vps?.dns || row.probePublicIp,
cell: (row) => {
const title = row.vps?.dns || row.probePublicIp
const ip = row.probePublicIp
const hoster = runHosterLabel(row)
const secondary = hoster ? `${ip} · ${hoster}` : ip
const link = row.matchedVpsId ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: row.matchedVpsId }}
className="hover:text-primary font-medium"
onClick={(event) => event.stopPropagation()}
>
{title}
</Link>
) : (
<span className="font-medium">Unknown VPS</span>
)
return dataGridCellStack(link, secondary)
},
}
}
export function GeoVpsGrid({
runs,
onRowClick,
emptyAction,
}: {
runs: IpregionRunDto[]
onRowClick: (run: IpregionRunDto) => void
emptyAction?: ReactNode
}) {
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
return [
vpsIdentityColumn(),
...serviceCols.map(
(svc): DataGridColumn<IpregionRunDto> => ({
key: `svc:${svc.key}`,
header: svc.label,
headerTitle: svc.title,
icon: resolveServiceIcon(svc.key),
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
size: 72,
minSize: 64,
sortable: true,
sortValue: (row) => resultByService(row, svc.key)?.countryIpv4 ?? resultByService(row, svc.key)?.status ?? '',
cell: (row) => {
const item = resultByService(row, svc.key)
return (
<CountryMatrixCell
status={item?.status}
countryIpv4={item?.countryIpv4}
countryIpv6={item?.countryIpv6}
serviceLabel={svc.title}
vpsLabel={row.vps?.dns || row.probePublicIp}
checkedAt={row.createdAt}
/>
)
},
}),
),
]
}, [serviceCols])
return (
<FrameDataGrid
columns={columnDefFromDataGrid(columns)}
data={runs}
rowId={(row) => row.id}
dense
pagination={runs.length > 10}
pinLeftColumnIds={['vps']}
{...GEO_MATRIX_GRID}
emptyTitle="Нет проверок"
emptyDescription="Запустите launcher на VPS, чтобы увидеть страны GeoIP."
emptyAction={emptyAction}
onRowClick={onRowClick}
/>
)
}
export function GeoServiceGrid({
groups,
runs,
onProbeClick,
emptyAction,
}: {
groups: GeoServiceRow[]
runs: IpregionRunDto[]
onProbeClick: (run: IpregionRunDto) => void
emptyAction?: ReactNode
}) {
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
const columns = useMemo((): DataGridColumn<GeoServiceRow>[] => {
return [
{
key: 'service',
header: 'Сервис',
headerTitle: 'Сервис',
icon: GlobeIcon,
enableHiding: false,
enablePinning: true,
size: 180,
minSize: 140,
sortValue: (row) => row.serviceKey,
cell: (row) =>
dataGridCellWithIcon(
<ServiceGlyph serviceKey={row.serviceKey} />,
dataGridCellStack(row.serviceLabel, row.group),
),
},
...probeCols.map(
(probe): DataGridColumn<GeoServiceRow> => ({
key: `probe:${probe.key}`,
header: probe.label,
headerTitle: probe.title,
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
size: 72,
minSize: 64,
sortable: true,
sortValue: (row) =>
row.probes.find((item) => item.runId === probe.key)?.countryIpv4 ??
row.probes.find((item) => item.runId === probe.key)?.status ??
'',
cell: (row) => {
const item = row.probes.find((probeRow) => probeRow.runId === probe.key)
const run = runById.get(probe.key)
return (
<CountryMatrixCell
status={item?.status}
countryIpv4={item?.countryIpv4}
countryIpv6={item?.countryIpv6}
serviceLabel={row.serviceKey}
vpsLabel={probe.title}
checkedAt={item?.createdAt}
onSelect={run ? () => onProbeClick(run) : undefined}
/>
)
},
}),
),
]
}, [onProbeClick, probeCols, runById])
return (
<FrameDataGrid
columns={columnDefFromDataGrid(columns)}
data={groups}
rowId={(row) => row.id}
dense
pagination={groups.length > 10}
pinLeftColumnIds={['service']}
{...GEO_MATRIX_GRID}
emptyTitle="Нет сервисов"
emptyAction={emptyAction}
/>
)
}
@@ -0,0 +1,332 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
CalendarClockIcon,
CopyIcon,
GlobeIcon,
MapPinIcon,
RouterIcon,
ServerIcon,
ShieldAlertIcon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { KpiStatGrid, ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import { Filters, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { copyText } from '@/lib/clipboard'
import { useSpaceId } from '@/lib/space'
import {
ipregionCurrentQueryOptions,
ipregionHistoryQueryOptions,
} from '@/queries/ipregion'
import { StatusBadge } from '@/components/status-badge'
import type { DataGridColumn } from '@/components/data-grid-types'
import { BlockingSnapshotScrubber } from '@/components/censorcheck/blocking-snapshot-scrubber'
import {
collectSnapshotTicks,
latestRunsAsOf,
mergeCensorcheckRuns,
resolveSnapshotIndex,
} from '@/components/censorcheck/blocking-snapshots'
import { GeoServiceGrid, GeoVpsGrid } from './geo-grid'
import { GeoRunSheet } from './geo-run-sheet'
import {
filterIpregionRuns,
isGeoMismatch,
serviceMatrixRows,
uniqueCountries,
} from './geo-filters'
import {
IPREGION_STATUS_LABELS,
LAUNCHER_CMD,
LAUNCHER_CMD_DAILY,
LAUNCHER_CMD_ROS,
LAUNCHER_CMD_ROS_DAILY,
formatCheckedAt,
type IpregionRunDto,
} from './types'
type GroupMode = 'vps' | 'service'
type TabId = 'current' | 'history'
const FILTER_FIELDS: FilterFieldConfig[] = [
{ key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, ISO' },
{
key: 'status',
label: 'Статус',
type: 'multiselect',
defaultOperator: 'is_any_of',
options: [
{ value: 'ok', label: 'Страна' },
{ value: 'na', label: 'N/A' },
{ value: 'denied', label: 'Отказ' },
{ value: 'rate_limit', label: 'Лимит' },
{ value: 'server_error', label: 'Ошибка' },
],
},
{ key: 'service', label: 'Сервис', type: 'text', defaultOperator: 'is_any_of' },
{ key: 'hoster', label: 'Хостер', type: 'text', defaultOperator: 'contains' },
{ key: 'country', label: 'Страна', type: 'text', defaultOperator: 'contains' },
{
key: 'matched',
label: 'Привязка',
type: 'select',
defaultOperator: 'is',
options: [
{ value: 'matched', label: 'Известный VPS' },
{ value: 'unmatched', label: 'Unknown VPS' },
],
},
]
const historyColumns: DataGridColumn<IpregionRunDto>[] = [
{
key: 'ip',
header: 'IP',
sortValue: (row) => row.probePublicIp,
cell: (row) => row.probePublicIp,
},
{
key: 'vps',
header: 'VPS',
sortValue: (row) => row.vps?.dns ?? '',
cell: (row) => row.vps?.dns || (row.matchedVpsId ? row.matchedVpsId : 'Unknown VPS'),
},
{
key: 'status',
header: 'Статус',
cell: (row) => (
<StatusBadge status={row.status} label={IPREGION_STATUS_LABELS[row.status] ?? row.status} />
),
},
{
key: 'summary',
header: 'OK / всего',
sortValue: (row) => row.summary.ok,
sortingFn: 'basic',
cell: (row) => `${row.summary.ok} / ${row.summary.total}`,
},
{
key: 'createdAt',
header: 'Проверено',
sortValue: (row) => row.createdAt,
cell: (row) => formatCheckedAt(row.createdAt),
},
]
export function GeoPage() {
const { spaceId } = useSpaceId()
const [tab, setTab] = useState<TabId>('current')
const [group, setGroup] = useState<GroupMode>('vps')
const [filters, setFilters] = useState<Filter[]>([])
const [selected, setSelected] = useState<IpregionRunDto | null>(null)
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(null)
const currentQuery = useQuery(ipregionCurrentQueryOptions(spaceId))
const historyQuery = useQuery(ipregionHistoryQueryOptions({ limit: 200 }, spaceId))
const allRuns = useMemo(
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
[currentQuery.data?.items, historyQuery.data?.items],
)
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
const snapshotRuns = useMemo(() => {
const asOf = ticks[resolvedIndex]?.asOf
if (!asOf) return currentQuery.data?.items ?? []
return latestRunsAsOf(allRuns, asOf)
}, [allRuns, currentQuery.data?.items, resolvedIndex, ticks])
const filtered = useMemo(
() => filterIpregionRuns(snapshotRuns, filters),
[snapshotRuns, filters],
)
const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
const matched = filtered.filter((row) => row.matchedVpsId).length
const countries = uniqueCountries(filtered).length
const mismatches = filtered.filter(isGeoMismatch).length
const copyLauncher = (
<>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_DAILY, 'Команда для ежедневного запуска скопирована')}
>
<CalendarClockIcon data-icon="inline-start" />
Раз в день
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS, 'Команда MikroTik скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS_DAILY, 'Команда MikroTik для ежедневного запуска скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik · день
</Button>
</>
)
return (
<PageShell>
<PageHeader
title="GeoIP"
description="Страны по GeoIP-сервисам с VPS или MikroTik 7.22+."
actions={copyLauncher}
/>
<KpiStatGrid
items={[
{
id: 'probes',
label: 'Пробы',
value: filtered.length,
icon: <GlobeIcon />,
},
{
id: 'matched',
label: 'Известные VPS',
value: matched,
icon: <ServerIcon />,
},
{
id: 'countries',
label: 'Уникальные страны',
value: countries,
icon: <MapPinIcon />,
},
{
id: 'mismatch',
label: 'Расхождения GeoIP',
value: mismatches,
icon: <ShieldAlertIcon />,
variant: mismatches > 0 ? 'warning' : 'default',
},
]}
isLoading={currentQuery.isLoading}
/>
<CountedLineTabs
tabs={[
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
]}
value={tab}
onValueChange={(value) => setTab(value as TabId)}
/>
{tab === 'current' ? (
<div className="flex min-w-0 w-full flex-col gap-3">
<BlockingSnapshotScrubber
ticks={ticks}
index={resolvedIndex}
onIndexChange={setSnapshotIndex}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<Filters
filters={filters}
fields={FILTER_FIELDS}
onChange={setFilters}
trigger={
<Button type="button" variant="outline">
Фильтры
</Button>
}
/>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[group]}
onValueChange={(next) => {
const selectedMode = next[0]
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
}}
aria-label="Группировка"
>
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
</ToggleGroup>
</div>
<QueryState
data={filtered}
isLoading={currentQuery.isLoading}
isError={currentQuery.isError}
error={currentQuery.error}
onRetry={() => void currentQuery.refetch()}
empty={filtered.length === 0}
emptyTitle="Пока нет проверок"
emptyDescription={`На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`}
emptyAction={copyLauncher}
skeleton={<TableSkeleton />}
>
{(rows) =>
group === 'vps' ? (
<GeoVpsGrid
runs={rows}
onRowClick={setSelected}
emptyAction={copyLauncher}
/>
) : (
<GeoServiceGrid
groups={serviceGroups}
runs={rows}
onProbeClick={setSelected}
emptyAction={copyLauncher}
/>
)
}
</QueryState>
</div>
) : (
<ResourcePage
title="История проверок"
description="Все сохранённые прогоны ipregion."
hideHeader
columns={columnDefFromDataGrid(historyColumns)}
data={historyQuery.data?.items ?? []}
getRowId={(row) => row.id}
isLoading={historyQuery.isLoading}
isError={historyQuery.isError}
error={historyQuery.error instanceof Error ? historyQuery.error : null}
onRetry={() => void historyQuery.refetch()}
onRowClick={setSelected}
emptyState={{
title: 'История пуста',
description: `На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`,
action: copyLauncher,
}}
/>
)}
<GeoRunSheet
run={selected}
open={Boolean(selected)}
onOpenChange={(open) => {
if (!open) setSelected(null)
}}
/>
</PageShell>
)
}
@@ -0,0 +1,139 @@
import { Link } from '@tanstack/react-router'
import { Building2Icon, GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { DetailPanel } from '@/components/reui-kit/detail-panel'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { ipregionRunQueryOptions } from '@/queries/ipregion'
import { countryName } from './geo-filters'
import { ServiceGlyph } from './service-icons'
import {
IPREGION_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
runHosterLabel,
type IpregionRunDto,
} from './types'
interface GeoRunSheetProps {
run: IpregionRunDto | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function GeoRunSheet({ run, open, onOpenChange }: GeoRunSheetProps) {
const needFetch = Boolean(run && !run.results)
const { data: fetched } = useQuery({
...ipregionRunQueryOptions(needFetch ? run?.id ?? null : null),
})
const detail = run?.results ? run : fetched ?? run
const title = detail?.vps?.dns || detail?.probePublicIp || 'Проверка'
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>
{detail ? formatCheckedAt(detail.createdAt) : 'Загрузка…'}
</SheetDescription>
</SheetHeader>
{detail ? (
<DetailPanel>
<DetailPanel.Metrics
cards={[
{
id: 'ip',
icon: <GlobeIcon />,
label: 'IP',
description: detail.probePublicIp,
},
{
id: 'vps',
icon: <ServerIcon />,
label: 'VPS',
description: detail.matchedVpsId ? detail.vps?.dns || detail.matchedVpsId : 'Unknown VPS',
footer: detail.matchedVpsId ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: detail.matchedVpsId }}
className="text-primary text-xs"
>
Открыть карточку
</Link>
) : undefined,
},
{
id: 'hoster',
icon: <Building2Icon />,
label: 'Хостер',
description: runHosterLabel(detail) || '—',
},
{
id: 'geo',
icon: <MapPinIcon />,
label: 'Локация',
description: detail.vps?.country || '—',
},
]}
/>
<DetailPanel.Section title="Сводка">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={detail.status}
label={IPREGION_STATUS_LABELS[detail.status] ?? detail.status}
/>
{detail.vps ? (
<span className="text-muted-foreground text-sm">
{formatVpsResources(detail.vps.vcpu, detail.vps.ramGb, detail.vps.diskGb)}
</span>
) : null}
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Сервисы">
<div className="flex flex-col gap-2">
{(detail.results ?? []).map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<ServiceGlyph serviceKey={item.serviceKey} className="text-muted-foreground" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
<span className="text-muted-foreground text-xs">{item.group}</span>
</div>
</div>
{item.status === 'ok' && (item.countryIpv4 || item.countryIpv6) ? (
<Badge size="sm" variant="success-light" radius="full">
{item.countryIpv4 || item.countryIpv6}
{countryName(item.countryIpv4 || item.countryIpv6)
? ` · ${countryName(item.countryIpv4 || item.countryIpv6)}`
: ''}
</Badge>
) : (
<StatusBadge
status={item.status}
label={IPREGION_STATUS_LABELS[item.status] ?? item.status}
/>
)}
</div>
))}
</div>
</DetailPanel.Section>
</DetailPanel>
) : (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<ShieldAlertIcon className="size-4" />
Нет данных прогона
</div>
)}
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { GlobeIcon } from 'lucide-react'
import {
IPREGION_CDN_SERVICES,
IPREGION_CUSTOM_SERVICES,
IPREGION_PRIMARY_SERVICES,
} from '@cfdm/shared/contracts/ipregion'
import { resolveServiceIcon } from './service-icons'
describe('resolveServiceIcon', () => {
it('резолвит все GeoIP-сервисы без fallback Globe', () => {
const keys = [
...IPREGION_PRIMARY_SERVICES,
...IPREGION_CUSTOM_SERVICES,
...IPREGION_CDN_SERVICES,
]
for (const key of keys) {
expect(resolveServiceIcon(key), key).not.toBe(GlobeIcon)
}
})
it('unknown / custom → Globe', () => {
expect(resolveServiceIcon('unknown.example')).toBe(GlobeIcon)
})
})
@@ -0,0 +1,136 @@
import type { ComponentType, SVGProps } from 'react'
import {
AppWindowIcon,
BinaryIcon,
CircleHelpIcon,
ClapperboardIcon,
CodeXmlIcon,
DatabaseIcon,
EarthIcon,
FileJsonIcon,
FlagIcon,
GlobeIcon,
InfoIcon,
LayersIcon,
LibraryIcon,
MapIcon,
MapPinIcon,
MapPinnedIcon,
NetworkIcon,
SearchIcon,
SparklesIcon,
TerminalIcon,
type LucideIcon,
} from 'lucide-react'
import {
siApple,
siCloudflare,
siGoogle,
siGooglegemini,
siJetbrains,
siNetflix,
siPlaystation,
siReddit,
siSpeedtest,
siSpotify,
siSteam,
siTiktok,
siTwitch,
siYoutube,
type SimpleIcon,
} from 'simple-icons'
import { cn } from '@cfdm/ui/lib/utils'
function BrandGlyph({
icon,
className,
}: {
icon: SimpleIcon
className?: string
}) {
return (
<svg
role="img"
viewBox="0 0 24 24"
className={cn('size-3.5 shrink-0', className)}
aria-hidden
>
<title>{icon.title}</title>
<path fill="currentColor" d={icon.path} />
</svg>
)
}
function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> {
function BrandIcon({ className }: { className?: string }) {
return <BrandGlyph icon={icon} className={className} />
}
BrandIcon.displayName = `BrandIcon(${icon.slug})`
return BrandIcon
}
const BRAND_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'cloudflare.com': brandComponent(siCloudflare),
'cloudflare cdn': brandComponent(siCloudflare),
google: brandComponent(siGoogle),
'google search captcha': brandComponent(siGoogle),
youtube: brandComponent(siYoutube),
'youtube premium': brandComponent(siYoutube),
'youtube cdn': brandComponent(siYoutube),
twitch: brandComponent(siTwitch),
netflix: brandComponent(siNetflix),
'netflix cdn': brandComponent(siNetflix),
spotify: brandComponent(siSpotify),
'spotify signup': brandComponent(siSpotify),
reddit: brandComponent(siReddit),
'reddit (guest access)': brandComponent(siReddit),
apple: brandComponent(siApple),
steam: brandComponent(siSteam),
tiktok: brandComponent(siTiktok),
jetbrains: brandComponent(siJetbrains),
playstation: brandComponent(siPlaystation),
'gemini supported': brandComponent(siGooglegemini),
'ookla speedtest': brandComponent(siSpeedtest),
}
const GENERIC_ICONS: Record<string, LucideIcon> = {
'maxmind.com': DatabaseIcon,
'rdap.db.ripe.net': NetworkIcon,
'ipinfo.io': InfoIcon,
'ipregistry.co': LibraryIcon,
'ipapi.co': CodeXmlIcon,
'ifconfig.co': TerminalIcon,
'ip2location.io': MapPinnedIcon,
'iplocation.com': MapIcon,
'country.is': FlagIcon,
'geoapify.com': MapPinIcon,
'geojs.io': FileJsonIcon,
'ipapi.is': BinaryIcon,
'ipbase.com': LayersIcon,
'ipquery.io': SearchIcon,
'ipwho.is': CircleHelpIcon,
'ip-api.com': EarthIcon,
chatgpt: SparklesIcon,
'disney+': ClapperboardIcon,
'disney+ access': ClapperboardIcon,
microsoft: AppWindowIcon,
}
export function resolveServiceIcon(
serviceKey: string,
): ComponentType<SVGProps<SVGSVGElement> & { className?: string }> {
const key = serviceKey.trim().toLowerCase()
return BRAND_ICONS[key] ?? GENERIC_ICONS[key] ?? GlobeIcon
}
export function ServiceGlyph({
serviceKey,
className,
}: {
serviceKey: string
className?: string
}) {
const Icon = resolveServiceIcon(serviceKey)
return <Icon className={cn('size-3.5 shrink-0', className)} />
}
+94
View File
@@ -0,0 +1,94 @@
import type { IpregionSummary } from '@cfdm/shared/contracts/ipregion'
export type IpregionResultDto = {
id: string
runId: string
serviceKey: string
serviceLabel: string
group: string
countryIpv4: string | null
countryIpv6: string | null
status: string
}
export type IpregionVpsInfo = {
id: string
ip: string
dns: string
providerId: string
providerName: string
country: string
city: string
datacenter: string
vcpu: number
ramGb: number
diskGb: number
}
export type IpregionRunDto = {
id: string
spaceId: string
runId: string
probePublicIp: string
claimedPublicIp: string | null
matchedVpsId: string | null
status: string
schemaVersion: number
launcherVersion: string | null
ipregionVersion: string | null
summary: IpregionSummary
createdAt: string
completedAt: string
observedSourceIp: string | null
detectedHoster?: string | null
vps: IpregionVpsInfo | null
results?: IpregionResultDto[]
}
export const IPREGION_STATUS_LABELS: Record<string, string> = {
ok: 'Страна',
na: 'N/A',
denied: 'Отказ',
rate_limit: 'Лимит',
server_error: 'Ошибка',
complete: 'Полный',
partial: 'Частичный',
}
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/ic | bash'
export const LAUNCHER_CMD_DAILY = 'curl -fsSL https://vt.shnt.top/ic | bash -s -- --daily'
export const LAUNCHER_CMD_ROS =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc'
export const LAUNCHER_CMD_ROS_DAILY =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/ic.rsc?daily=1" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc'
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
}
export function formatCheckedAt(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('ru-RU')
}
export function runHosterLabel(run: IpregionRunDto): string {
const inventory = run.vps?.providerName?.trim() ?? ''
if (inventory) return inventory
return run.detectedHoster?.trim() ?? ''
}
export function runSearchText(run: IpregionRunDto): string {
const parts = [
run.probePublicIp,
run.claimedPublicIp ?? '',
run.vps?.dns ?? '',
run.vps?.providerName ?? '',
run.detectedHoster ?? '',
run.vps?.country ?? '',
...(run.results ?? []).map(
(row) => `${row.serviceKey} ${row.serviceLabel} ${row.countryIpv4 ?? ''} ${row.countryIpv6 ?? ''}`,
),
]
return parts.join(' ').toLowerCase()
}
+96 -46
View File
@@ -15,6 +15,8 @@ import {
UsersIcon,
Network,
ShieldAlert,
Globe,
ChevronDownIcon,
} from 'lucide-react'
import {
@@ -31,7 +33,13 @@ import {
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@cfdm/ui/components/sidebar'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
Breadcrumb,
BreadcrumbItem,
@@ -40,8 +48,6 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { Badge } from '@cfdm/ui/components/badge'
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router'
@@ -49,7 +55,6 @@ import { useQuery } from '@tanstack/react-query'
import { useState, type CSSProperties, type ReactNode } from 'react'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SpaceSwitcher } from '@/components/layout/space-switcher'
import { NavUser } from '@/components/layout/nav-user'
import { AppSwitcher } from '@/components/app-switcher'
@@ -60,13 +65,16 @@ import {
permissionForPath,
} from '@/lib/auth'
import { SpaceProvider, useSpaceId } from '@/lib/space'
import { PageCrumbContext } from '@/lib/page-crumb'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
import { cn } from '@cfdm/ui/lib/utils'
interface NavItem {
to: string
label: string
icon: typeof LayoutDashboard
badge?: number
/** Problem dot (design `pdot`): destructive / warning. */
dot?: { tone: 'destructive' | 'warning'; label: string }
}
interface NavGroup {
@@ -84,6 +92,7 @@ const NAV_GROUPS: NavGroup[] = [
items: [
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert },
{ to: '/geo', label: 'GeoIP', icon: Globe },
{ to: '/topology', label: 'Схема', icon: Network },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
@@ -148,7 +157,10 @@ export function AppShell({ children }: { children: ReactNode }) {
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const [pageCrumb, setPageCrumb] = useState<string | null>(null)
// Detail routes (design: «VPS / 203.0.113.21»): section links back, page shows the record title.
const isDetail = pageCrumb != null && pathname !== activeItem.to
const parentTo = isDetail ? activeItem.to : PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const { data: stats } = useQuery(dashboardStatsQueryOptions())
@@ -160,9 +172,21 @@ export function AppShell({ children }: { children: ReactNode }) {
const perm = permissionForPath(item.to)
return !perm || can(perm)
})
.map((item) => {
.map((item): NavItem => {
if (item.to === '/dashboard' && stats?.issuesCount) {
return { ...item, badge: stats.issuesCount }
return {
...item,
dot: { tone: 'destructive', label: `Проблем в инвентаре: ${stats.issuesCount}` },
}
}
if (item.to === '/accounts' && stats?.lowBalanceAccountCount) {
return {
...item,
dot: {
tone: 'warning',
label: `Низкий баланс: ${stats.lowBalanceAccountCount}`,
},
}
}
return item
}),
@@ -188,38 +212,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarHeader>
<SidebarContent>
{navGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive =
pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.badge ? (
<Badge
variant="destructive"
className="ml-auto size-5 justify-center p-0 text-xs"
>
{item.badge}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavGroupSection key={group.label} group={group} pathname={pathname} />
))}
</SidebarContent>
<SidebarFooter>
@@ -229,10 +222,6 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
@@ -247,18 +236,19 @@ export function AppShell({ children }: { children: ReactNode }) {
) : null}
<BreadcrumbItem>
<BreadcrumbPage>
{ROUTE_LABELS[activeItem.to] ?? ''}
{isDetail ? pageCrumb : (ROUTE_LABELS[activeItem.to] ?? '')}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
<div className="flex min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<SpaceScopedMain>{children}</SpaceScopedMain>
<PageCrumbContext.Provider value={setPageCrumb}>
<SpaceScopedMain>{children}</SpaceScopedMain>
</PageCrumbContext.Provider>
</div>
</SidebarInset>
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
@@ -268,6 +258,66 @@ export function AppShell({ children }: { children: ReactNode }) {
)
}
/** Collapsible nav group (design `grph`): uppercase label + chevron; always open in icon rail. */
function NavGroupSection({ group, pathname }: { group: NavGroup; pathname: string }) {
const { state, isMobile } = useSidebar()
const [open, setOpen] = useState(true)
const iconRail = state === 'collapsed' && !isMobile
return (
<Collapsible open={open || iconRail} onOpenChange={setOpen}>
<SidebarGroup>
<SidebarGroupLabel
render={<CollapsibleTrigger tabIndex={iconRail ? -1 : undefined} />}
className="hover:text-sidebar-foreground cursor-pointer uppercase tracking-wider transition-colors"
>
{group.label}
<ChevronDownIcon
aria-hidden
className={cn(
'ml-auto transition-transform motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.dot ? (
<span
role="img"
aria-label={item.dot.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full',
'group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1',
item.dot.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
/** Remount route content when space changes so snapshot queries pick new X-Space-Id. */
function SpaceScopedMain({ children }: { children: ReactNode }) {
const { spaceId } = useSpaceId()
@@ -1,105 +0,0 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={portalAppsUrl} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/integrations" />}
className="justify-center text-sm font-medium"
>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+5 -5
View File
@@ -141,12 +141,12 @@ export function NavUser() {
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
className="bg-muted/50 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
<Avatar className="size-8 rounded-full border">
<AvatarFallback className="bg-background text-xs">
{fallback}
</AvatarFallback>
</Avatar>
@@ -166,8 +166,8 @@ export function NavUser() {
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
<Avatar className="size-8 rounded-full border">
<AvatarFallback className="bg-background text-xs">
{fallback}
</AvatarFallback>
</Avatar>
@@ -3,12 +3,16 @@ import { useQuery } from '@tanstack/react-query'
import {
Activity,
Bell,
ChevronRight,
RefreshCw,
Server,
Wallet,
} from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
@@ -214,42 +218,43 @@ export function SystemMonitorPopover() {
],
)
const spiking = metrics.some((m) => m.alert) || !apiOk
const errorCount = metrics.filter((m) => m.alert && m.tone === 'destructive').length + (apiOk ? 0 : 1)
const warningCount = metrics.filter((m) => m.alert && m.tone === 'warning').length
const spiking = errorCount + warningCount > 0
const statusLabel = spiking ? 'Внимание' : 'Норма'
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
<Button
variant="outline"
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ошибок ${errorCount}, предупреждений ${warningCount}`}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
<Activity
aria-hidden
className={cn(
errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
)}
/>
<span className="hidden sm:inline">{statusLabel}</span>
{errorCount > 0 ? (
<Badge variant="destructive-light" size="sm" className="tabular-nums">
{errorCount}
</Badge>
) : null}
{warningCount > 0 ? (
<Badge variant="warning-light" size="sm" className="tabular-nums">
{warningCount}
</Badge>
) : null}
{!spiking ? (
<Badge variant="success-light" size="sm">
OK
</Badge>
) : null}
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
@@ -290,6 +295,13 @@ export function SystemMonitorPopover() {
</>
) : null}
</div>
<Link
to="/sync-journal"
className="border-border text-muted-foreground hover:text-foreground flex items-center justify-between border-t px-3 py-2 text-xs transition-colors"
>
Журнал синка
<ChevronRight aria-hidden className="size-3.5" />
</Link>
</PopoverContent>
</Popover>
)
+5 -5
View File
@@ -6,17 +6,17 @@ interface PageHeaderProps {
actions?: ReactNode
}
/** Page-level section header — etalon EvoBGP. */
/** Page header — design «VPS-Tracker»: 20px title + muted sub, actions bottom-aligned right. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { kitDataGridTableLayout } from './frame-data-grid'
import { BLOCKING_MATRIX_GRID } from '../censorcheck/blocking-grid'
import { GEO_MATRIX_GRID } from '../ipregion/geo-grid'
describe('kitDataGridTableLayout', () => {
it('CRUD defaults: без bg-muted header и width fixed', () => {
@@ -29,3 +30,10 @@ describe('BLOCKING_MATRIX_GRID', () => {
expect(BLOCKING_MATRIX_GRID.horizontalScroll).toBe(true)
})
})
describe('GEO_MATRIX_GRID', () => {
it('data-grid-base-4: auto + horizontal scroll', () => {
expect(GEO_MATRIX_GRID.tableWidth).toBe('auto')
expect(GEO_MATRIX_GRID.horizontalScroll).toBe(true)
})
})
+3 -4
View File
@@ -3,8 +3,7 @@ export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @5xl:grid-cols-4'
// Design «VPS-Tracker»: at most 4 KPI columns, wrapping to the next row.
return 'grid-cols-1 @xl:grid-cols-2 @5xl:grid-cols-4'
}
@@ -44,7 +44,8 @@ const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
// text-warning on white fails contrast; readable *-foreground in light theme.
warning: 'text-warning-foreground dark:text-warning',
destructive: 'text-destructive',
}
@@ -63,11 +64,17 @@ function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
const HINT_BADGE_VARIANT = {
default: 'outline',
warning: 'warning-light',
destructive: 'destructive-light',
} as const
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant={HINT_BADGE_VARIANT[item.variant ?? 'default']} size="sm">
{item.hint}
</Badge>
)
@@ -81,7 +88,7 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="relative z-10 flex h-full items-center gap-3">
{item.icon ? (
<IconTile
variant="elevated"
@@ -92,18 +99,19 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
{/* Design «VPS-Tracker» KPI: label, then value + hint Badge on one baseline. */}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="text-muted-foreground truncate text-sm font-medium">{item.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span
className={cn(
'text-2xl leading-none font-semibold tracking-tight tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</span>
{footer ? <span className="shrink-0">{footer}</span> : null}
</div>
</div>
</div>
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
@@ -16,13 +9,15 @@ interface OpsDashboardProps {
title?: string
description?: string
kpiCards: OpsKpiCard[]
charts: ReactNode
queue: ReactNode
/** Alert between the page header and KPI (design order: title → banner → KPI → tabs). */
banner?: ReactNode
/** Page body after KPI — design «VPS-Tracker»: line tabs (Проблемы / Последние VPS / Аккаунты). */
children: ReactNode
isLoading?: boolean
}
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
'text-foreground @container flex w-full flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() {
return (
@@ -45,8 +40,8 @@ export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор VPS, платежей, тарифов и синхронизации',
kpiCards,
charts,
queue,
banner,
children,
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
@@ -57,25 +52,13 @@ export function OpsDashboard({
<div className={rootClassName}>
<PageHeader title={title} description={description} />
{banner}
<section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
<section aria-label="Аналитика" className="grid w-full min-w-0 gap-2">
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы оплаты, истекающие VPS и ошибки синхронизации
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
{children}
</div>
)
}
@@ -7,11 +7,9 @@ import {
SettingsIcon,
} from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@cfdm/ui/components/separator'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
export interface SettingsTabConfig {
id: string
@@ -55,69 +53,42 @@ interface SettingsShellProps {
tabs?: SettingsTabConfig[]
}
/** Settings shell — preview https://reui.io/preview/base/settings-16 */
/** Settings shell — line tabs on top (design «VPS-Tracker»); sections: https://reui.io/preview/base/settings-16 */
export function SettingsShell({
title = 'Настройки',
description = 'Параметры приложения и связи с другими сервисами',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const active =
tabs.find((tab) =>
tab.exact ? pathname === tab.to || pathname === `${tab.to}/` : pathname.startsWith(tab.to),
) ?? tabs[0]
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<div className="flex flex-col gap-3">
<PageHeader title={title} description={description} />
<Separator />
</div>
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
<PageHeader title={title} description={description} />
{/* Design «VPS-Tracker»: settings sections as page line tabs. */}
{tabs.length > 1 ? (
<Tabs value={active?.id}>
<TabsList variant="line" aria-label="Разделы настроек" className="gap-5">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
nativeButton={false}
render={<Link to={tab.to} />}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<div className="min-w-0">
<Outlet />
</div>
</PageShell>
)
+67 -16
View File
@@ -1,8 +1,25 @@
import type { ReactNode } from 'react'
import { PencilIcon, Trash2Icon } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { cn } from '@cfdm/ui/lib/utils'
import { ConfirmDialog } from './confirm-dialog'
export interface RowActionExtra {
label: string
icon?: LucideIcon
onSelect: () => void
disabled?: boolean
}
interface RowActionsProps {
onEdit?: () => void
onDelete?: () => void
@@ -10,10 +27,15 @@ interface RowActionsProps {
deleteTitle?: string
deleteDescription?: ReactNode
deleteLabel?: string
extra?: ReactNode
extra?: RowActionExtra[]
className?: string
}
/**
* Data-grid row actions — outline ⋯ menu.
* Preview: https://reui.io/preview/base/components/c-dropdown-menu-12
* Docs: https://reui.io/docs/components/base/dropdown-menu
*/
export function RowActions({
onEdit,
onDelete,
@@ -24,23 +46,52 @@ export function RowActions({
extra,
className,
}: RowActionsProps) {
if (!onEdit && !onDelete && !extra) return null
const [deleteOpen, setDeleteOpen] = useState(false)
const extras = extra ?? []
if (!onEdit && !onDelete && extras.length === 0) return null
return (
<div className={`flex justify-end gap-1 ${className ?? ''}`}>
{extra}
{onEdit ? (
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label={editLabel}>
<PencilIcon />
</Button>
) : null}
<div className={cn('flex justify-end', className)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="icon-sm" aria-label="Действия" />
}
>
<MoreHorizontalIcon className="size-4" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{extras.map((item) => {
const Icon = item.icon
return (
<DropdownMenuItem
key={item.label}
disabled={item.disabled}
onClick={item.onSelect}
>
{Icon ? <Icon aria-hidden /> : null}
{item.label}
</DropdownMenuItem>
)
})}
{onEdit ? (
<DropdownMenuItem onClick={onEdit}>
<PencilIcon aria-hidden />
{editLabel}
</DropdownMenuItem>
) : null}
{onDelete ? (
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2Icon aria-hidden />
{deleteLabel}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{onDelete ? (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
<Trash2Icon />
</Button>
}
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={deleteTitle}
description={deleteDescription}
destructive
+39 -17
View File
@@ -1,40 +1,62 @@
import type { ComponentProps } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success',
ok: 'success',
paid: 'success',
available: 'success',
complete: 'success',
paused: 'secondary',
active: 'success-light',
ok: 'success-light',
up: 'success-light',
paid: 'success-light',
available: 'success-light',
complete: 'success-light',
paused: 'invert-light',
archived: 'outline',
error: 'destructive',
denied: 'destructive',
blocked: 'destructive',
running: 'info',
overdue: 'warning',
stale: 'warning',
timeout: 'warning',
redirected: 'warning',
partial: 'warning',
error: 'destructive-light',
denied: 'destructive-light',
blocked: 'destructive-light',
down: 'destructive-light',
running: 'info-light',
overdue: 'warning-light',
stale: 'warning-light',
timeout: 'warning-light',
redirected: 'warning-light',
partial: 'warning-light',
}
const DOT_COLOR: Record<string, string> = {
'success-light': 'bg-success',
success: 'bg-success',
'info-light': 'bg-info',
info: 'bg-info',
'warning-light': 'bg-warning',
warning: 'bg-warning',
'destructive-light': 'bg-destructive',
destructive: 'bg-destructive',
'invert-light': 'bg-muted-foreground',
secondary: 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
export function StatusBadge({
status,
label,
size = 'default',
size = 'sm',
className,
}: {
status: string
label?: string
size?: NonNullable<ComponentProps<typeof Badge>['size']>
className?: string
}) {
const variant = STATUS_VARIANT[status] ?? 'outline'
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size={size}>
<Badge variant={variant} size={size} radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? status}
</Badge>
)
@@ -1,28 +1,48 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Badge } from '@/components/reui/badge'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { vpsSpecsLine } from './types'
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
import { lbModeBadgeVariant, lbModeLabel, vpsSpecsLine } from './types'
import type { Vps } from '@/types/entities'
interface AddVpsSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
existingVpsIds: Set<string>
existingCfdmServiceIds: Set<number>
onAdd: (vpsIds: string[]) => void
onAddServices: (services: CfdmTopologyService[]) => void
}
export function AddVpsSheet({
open,
onOpenChange,
existingVpsIds,
existingCfdmServiceIds,
onAdd,
onAddServices,
}: AddVpsSheetProps) {
const { data: snapshot } = useQuery(snapshotQueryOptions())
const [tab, setTab] = useState('vps')
const [q, setQ] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectedServices, setSelectedServices] = useState<Set<number>>(new Set())
const list = useMemo(() => {
const all = (snapshot?.vps ?? []) as Vps[]
@@ -38,6 +58,20 @@ export function AddVpsSheet({
.slice(0, 80)
}, [snapshot?.vps, q])
const services = useMemo(
() => aggregateCfdmServices(snapshot?.vpsDomains ?? []),
[snapshot?.vpsDomains],
)
const filteredServices = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term) return services
return services.filter((s) => {
if (s.name.toLowerCase().includes(term) || s.slug.toLowerCase().includes(term)) return true
return s.fqdns.some((f) => f.toLowerCase().includes(term))
})
}, [services, q])
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev)
@@ -47,77 +81,192 @@ export function AddVpsSheet({
})
}
function toggleService(id: number) {
setSelectedServices((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function reset() {
setSelected(new Set())
setSelectedServices(new Set())
setQ('')
setTab('vps')
}
const submitCount = tab === 'cfdm' ? selectedServices.size : selected.size
const submitDisabled = submitCount === 0
return (
<FormSheet
open={open}
onOpenChange={(v) => {
if (!v) {
setSelected(new Set())
setQ('')
}
if (!v) reset()
onOpenChange(v)
}}
title="Добавить VPS на схему"
description="Сервер появится на канве. Позицию и связи можно настроить вручную."
submitLabel={`Добавить${selected.size ? ` (${selected.size})` : ''}`}
submitDisabled={selected.size === 0}
title={tab === 'cfdm' ? 'Добавить сервис CFDM' : 'Добавить VPS на схему'}
description={
tab === 'cfdm'
? 'Сервис появится как узел. Серверы свяжутся пунктиром членства — один VPS может быть в нескольких сервисах.'
: 'Сервер появится на канве. Позицию и связи можно настроить вручную.'
}
submitLabel={`Добавить${submitCount ? ` (${submitCount})` : ''}`}
submitDisabled={submitDisabled}
onSubmit={() => {
onAdd([...selected])
setSelected(new Set())
setQ('')
if (tab === 'cfdm') {
const picked = services.filter((s) => selectedServices.has(s.serviceId))
onAddServices(picked)
} else {
onAdd([...selected])
}
reset()
onOpenChange(false)
}}
>
<Input
placeholder="Поиск по IP, DNS, проекту…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{v.dns || v.ip}
</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
{selected.size > 0 ? (
<CountedLineTabs
tabs={[
{ id: 'vps', label: 'VPS', count: list.length },
{ id: 'cfdm', label: 'Сервисы CFDM', count: services.length },
]}
value={tab}
onValueChange={setTab}
className="flex flex-col gap-3"
>
<Input
placeholder={
tab === 'cfdm' ? 'Поиск по сервису, FQDN…' : 'Поиск по IP, DNS, проекту…'
}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<TabsContent value="vps" className="mt-0 flex flex-col gap-1">
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{v.dns || v.ip}</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
</TabsContent>
<TabsContent value="cfdm" className="mt-0 flex flex-col gap-1">
{services.length === 0 ? (
<EmptyState
title="Нет сервисов CFDM"
description="Включите интеграцию и синхронизируйте bindings — сервисы появятся здесь."
centered={false}
action={
<Button variant="outline" size="sm" render={<Link to="/settings/integrations" />}>
Настройки интеграции
</Button>
}
/>
) : filteredServices.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих сервисов</p>
) : (
<ItemGroup className="max-h-[50vh] gap-1 overflow-y-auto">
{filteredServices.map((s) => {
const already = existingCfdmServiceIds.has(s.serviceId)
const noServers = s.matchedVpsIds.length === 0
const disabled = already || noServers
const checked = selectedServices.has(s.serviceId)
return (
<Item
key={s.serviceId}
size="sm"
variant={checked ? 'muted' : 'default'}
render={
<button
type="button"
disabled={disabled}
onClick={() => toggleService(s.serviceId)}
/>
}
className="w-full text-left disabled:opacity-50"
data-selected={checked || undefined}
>
<ItemContent className="gap-0.5">
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span className="truncate">{s.name}</span>
{s.lbMode ? (
<Badge variant={lbModeBadgeVariant(s.lbMode)} size="xs">
{lbModeLabel(s.lbMode)}
</Badge>
) : null}
</ItemTitle>
<ItemDescription>
{s.fqdns.slice(0, 2).join(', ')}
{s.fqdns.length > 2 ? ` +${s.fqdns.length - 2}` : ''}
{' · '}
{s.matchedVpsIds.length} серв.
</ItemDescription>
</ItemContent>
<ItemActions>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : noServers ? (
<span className="text-[10px] text-muted-foreground">нет серверов</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</ItemActions>
</Item>
)
})}
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
{(tab === 'vps' ? selected.size : selectedServices.size) > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelected(new Set())}
onClick={() => {
setSelected(new Set())
setSelectedServices(new Set())
}}
>
Сбросить выбор
</Button>
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import type { VpsDomain } from '@/types/entities'
import { aggregateCfdmServices, servicesForVps } from './cfdm-services'
function domain(partial: Partial<VpsDomain> & Pick<VpsDomain, 'id' | 'cfdmServiceId'>): VpsDomain {
return {
vpsId: null,
fqdn: 'vpn.example.com',
zoneName: 'example.com',
hostname: 'vpn',
serviceName: 'VPN',
serviceSlug: 'vpn',
cfdmBindingId: 1,
source: 'cfdm',
matchStatus: 'unmatched',
syncedAt: '2026-01-01T00:00:00.000Z',
...partial,
}
}
describe('aggregateCfdmServices', () => {
it('groups bindings by service and collects matched VPS', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
fqdn: 'vpn-a.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.10']),
lbMode: 'failover',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-2',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.20']),
lbMode: 'failover',
}),
domain({
id: 'c',
cfdmServiceId: 11,
cfdmBindingId: 3,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
matchStatus: 'unmatched',
targetIps: JSON.stringify(['198.51.100.1']),
lbMode: 'round_robin',
}),
]
const services = aggregateCfdmServices(rows)
expect(services).toHaveLength(2)
const vpn = services.find((s) => s.serviceId === 10)
expect(vpn?.name).toBe('VPN')
expect(vpn?.lbMode).toBe('failover')
expect(vpn?.matchedVpsIds.sort()).toEqual(['vps-1', 'vps-2'])
expect(vpn?.fqdns).toEqual(['vpn-a.example.com', 'vpn-b.example.com'])
expect(vpn?.unmatchedIps).toEqual([])
const dns = services.find((s) => s.serviceId === 11)
expect(dns?.matchedVpsIds).toEqual([])
expect(dns?.unmatchedIps).toEqual(['198.51.100.1'])
expect(dns?.lbMode).toBeUndefined()
})
it('omits lbMode when unique origin IPs are below two', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.10']),
lbMode: 'round_robin',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-2',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.10']),
lbMode: 'round_robin',
}),
]
expect(aggregateCfdmServices(rows)[0]?.lbMode).toBeUndefined()
})
it('lists services a VPS belongs to', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'failover',
}),
domain({
id: 'b',
cfdmServiceId: 11,
cfdmBindingId: 3,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'round_robin',
}),
]
const chips = servicesForVps(aggregateCfdmServices(rows), 'vps-1')
expect(chips.map((c) => c.name).sort()).toEqual(['DNS', 'VPN'])
})
it('deduplicates the same vpsId across bindings of one service', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
}),
]
const [svc] = aggregateCfdmServices(rows)
expect(svc?.matchedVpsIds).toEqual(['vps-1'])
})
})
@@ -0,0 +1,93 @@
import type { TopologyLbMode } from '@cfdm/shared/contracts/integration-cfdm'
import type { VpsDomain } from '@/types/entities'
import { isTopologyLbMode } from './types'
export type CfdmTopologyService = {
serviceId: number
name: string
slug: string
lbMode?: TopologyLbMode
fqdns: string[]
matchedVpsIds: string[]
unmatchedIps: string[]
}
function parseTargetIps(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
/** Группирует vps_domains по сервису CFDM для пикера на схеме. */
export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService[] {
const byService = new Map<
number,
{
name: string
slug: string
lbMode?: TopologyLbMode
fqdns: Set<string>
matchedVpsIds: Set<string>
originIps: Set<string>
unmatchedIps: Set<string>
}
>()
for (const row of domains) {
if (row.source && row.source !== 'cfdm') continue
let bucket = byService.get(row.cfdmServiceId)
if (!bucket) {
bucket = {
name: row.serviceName,
slug: row.serviceSlug,
fqdns: new Set(),
matchedVpsIds: new Set(),
originIps: new Set(),
unmatchedIps: new Set(),
}
byService.set(row.cfdmServiceId, bucket)
}
if (row.fqdn) bucket.fqdns.add(row.fqdn)
if (isTopologyLbMode(row.lbMode)) bucket.lbMode = row.lbMode
for (const ip of parseTargetIps(row.targetIps)) bucket.originIps.add(ip)
if (row.vpsId && row.matchStatus === 'matched') {
bucket.matchedVpsIds.add(row.vpsId)
} else {
for (const ip of parseTargetIps(row.targetIps)) bucket.unmatchedIps.add(ip)
}
}
return [...byService.entries()]
.map(([serviceId, b]) => ({
serviceId,
name: b.name,
slug: b.slug,
lbMode: b.originIps.size >= 2 ? b.lbMode : undefined,
fqdns: [...b.fqdns].sort(),
matchedVpsIds: [...b.matchedVpsIds],
unmatchedIps: [...b.unmatchedIps],
}))
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
}
export function serviceFqdnMeta(service: Pick<CfdmTopologyService, 'fqdns'>): {
fqdn: string
extraFqdns: string[]
} {
const [primary, ...rest] = service.fqdns
return { fqdn: primary ?? '', extraFqdns: rest }
}
/** Сервисы CFDM, в которых состоит данный VPS (для чипов на карточке). */
export function servicesForVps(
services: CfdmTopologyService[],
vpsId: string,
): Pick<CfdmTopologyService, 'serviceId' | 'name' | 'lbMode'>[] {
return services
.filter((s) => s.matchedVpsIds.includes(vpsId))
.map((s) => ({ serviceId: s.serviceId, name: s.name, lbMode: s.lbMode }))
}
@@ -47,3 +47,37 @@ export function createConnectedEdge(connection: {
data,
)
}
export function membershipEdgeData(): TopologyEdgeData {
return {
relation: 'membership',
label: '',
lineStyle: 'dashed',
direction: 'none',
protocol: '',
localIp: '',
remoteIp: '',
notes: '',
}
}
export function createMembershipEdge(
serviceNodeId: string,
vpsNodeId: string,
): Edge<TopologyEdgeData> {
const data = membershipEdgeData()
return applyEdgeVisuals(
{
id: `e-membership-${serviceNodeId}-${vpsNodeId}`,
source: serviceNodeId,
target: vpsNodeId,
data,
deletable: false,
},
data,
)
}
export function isMembershipEdge(edge: Edge<TopologyEdgeData>): boolean {
return edge.data?.relation === 'membership'
}
@@ -2,6 +2,7 @@ import { memo } from 'react'
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
type Edge,
type EdgeProps,
@@ -30,19 +31,22 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
data,
} = props
const [edgePath, labelX, labelY] = getSmoothStepPath({
const relation = data?.relation ?? 'network'
const title = data?.label?.trim() ?? ''
const protocol = data?.protocol?.trim() ?? ''
const tunnelIps = formatEdgeTunnelIps(data ?? {})
const membership = relation === 'membership'
const pathParams = {
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const relation = data?.relation ?? 'network'
const title = data?.label?.trim() ?? ''
const protocol = data?.protocol?.trim() ?? ''
const tunnelIps = formatEdgeTunnelIps(data ?? {})
}
const [edgePath, labelX, labelY] = membership
? getBezierPath(pathParams)
: getSmoothStepPath(pathParams)
return (
<>
@@ -56,6 +60,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
strokeWidth: selected ? 2.5 : 1.5,
}}
/>
{membership ? null : (
<EdgeLabelRenderer>
<div
className={cn(
@@ -88,6 +93,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
) : null}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
@@ -4,10 +4,14 @@ import { Textarea } from '@cfdm/ui/components/textarea'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge'
import {
SHAPE_KIND_OPTIONS,
lbModeBadgeVariant,
lbModeLabel,
type GroupNodeData,
type NoteNodeData,
type ServiceNodeData,
type ShapeKind,
type ShapeNodeData,
type TopologyNodeType,
@@ -17,13 +21,18 @@ export type EditableElement =
| { kind: 'shape'; id: string; data: ShapeNodeData }
| { kind: 'note'; id: string; data: NoteNodeData }
| { kind: 'group'; id: string; data: GroupNodeData }
| { kind: 'service'; id: string; data: ServiceNodeData }
interface ElementEditSheetProps {
element: EditableElement | null
open: boolean
locked?: boolean
onOpenChange: (open: boolean) => void
onSave: (id: string, type: TopologyNodeType, data: ShapeNodeData | NoteNodeData | GroupNodeData) => void
onSave: (
id: string,
type: TopologyNodeType,
data: ShapeNodeData | NoteNodeData | GroupNodeData | ServiceNodeData,
) => void
}
export function ElementEditSheet({
@@ -49,6 +58,10 @@ export function ElementEditSheet({
setText(element.data.text ?? '')
setLabel('')
setNotes('')
} else if (element.kind === 'service') {
setLabel(element.data.label ?? '')
setNotes('')
setText('')
} else {
setLabel(element.data.label ?? '')
setNotes(element.data.notes ?? '')
@@ -61,7 +74,9 @@ export function ElementEditSheet({
? 'Параметры блока'
: element?.kind === 'note'
? 'Параметры заметки'
: 'Параметры группы'
: element?.kind === 'service'
? 'Параметры сервиса CFDM'
: 'Параметры группы'
return (
<FormSheet
@@ -71,7 +86,9 @@ export function ElementEditSheet({
description={
locked
? 'Схема заблокирована — только просмотр'
: 'Изменения сохранятся на схеме'
: element?.kind === 'service'
? 'Имя можно изменить. FQDN и тип резервирования — из CFDM.'
: 'Изменения сохранятся на схеме'
}
submitLabel="Сохранить"
submitDisabled={locked || !element}
@@ -81,6 +98,11 @@ export function ElementEditSheet({
onSave(element.id, 'shape', { kind, label: label.trim() || 'Блок' })
} else if (element.kind === 'note') {
onSave(element.id, 'note', { text: text.trim() || 'Заметка' })
} else if (element.kind === 'service') {
onSave(element.id, 'service', {
...element.data,
label: label.trim() || element.data.label || 'Сервис',
})
} else {
onSave(element.id, 'group', {
label: label.trim() || 'Группа',
@@ -152,6 +174,39 @@ export function ElementEditSheet({
</FormField>
</>
) : null}
{element?.kind === 'service' ? (
<>
<FormField label="Название" htmlFor="service-label">
<Input
id="service-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={80}
/>
</FormField>
{element.data.fqdn ? (
<FormField label="Основной FQDN">
<p className="font-mono text-sm text-foreground">{element.data.fqdn}</p>
</FormField>
) : null}
{element.data.extraFqdns && element.data.extraFqdns.length > 0 ? (
<FormField label="Другие FQDN">
<p className="font-mono text-sm text-muted-foreground">
{element.data.extraFqdns.join(', ')}
</p>
</FormField>
) : null}
{element.data.lbMode ? (
<FormField label="Резервирование">
<Badge variant={lbModeBadgeVariant(element.data.lbMode)} size="sm">
{lbModeLabel(element.data.lbMode)}
</Badge>
</FormField>
) : null}
</>
) : null}
</FormSheet>
)
}
@@ -2,8 +2,9 @@ import type { TopologyFlowNode, TopologyNodeType } from './types'
const GROUP_Z = -1
const CONTENT_Z = 1
const SERVICE_Z = 3
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note'])
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note', 'service'])
export function isAttachableType(type: TopologyNodeType | undefined): boolean {
return type != null && ATTACHABLE.has(type)
@@ -16,10 +17,10 @@ export function normalizeGroupLayers(nodes: TopologyFlowNode[]): TopologyFlowNod
}
// extent: 'parent' блокирует вытаскивание — не используем; parentId достаточно для «прилипания»
const cleared = n.extent != null ? { ...n, extent: undefined } : n
if (cleared.zIndex != null && cleared.zIndex < CONTENT_Z) {
return { ...cleared, zIndex: CONTENT_Z }
if (cleared.type === 'service') {
return { ...cleared, zIndex: SERVICE_Z }
}
return cleared.zIndex == null ? { ...cleared, zIndex: CONTENT_Z } : cleared
return { ...cleared, zIndex: CONTENT_Z }
})
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
applyMembershipHandles,
clusterIdsForService,
pickMembershipHandles,
} from './membership-handles'
import type { TopologyFlowNode } from './types'
import { membershipEdgeData } from './edge-utils'
describe('pickMembershipHandles', () => {
const origin = { x: 0, y: 0 }
it('picks bottom→top when VPS is below', () => {
expect(pickMembershipHandles(origin, { x: 0, y: 100 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
it('picks top→bottom when VPS is above', () => {
expect(pickMembershipHandles(origin, { x: 0, y: -100 })).toEqual({
sourceHandle: 'top',
targetHandle: 'bottom',
})
})
it('picks right→left when VPS is to the right', () => {
expect(pickMembershipHandles(origin, { x: 100, y: 0 })).toEqual({
sourceHandle: 'right',
targetHandle: 'left',
})
})
it('picks left→right when VPS is to the left', () => {
expect(pickMembershipHandles(origin, { x: -100, y: 0 })).toEqual({
sourceHandle: 'left',
targetHandle: 'right',
})
})
it('uses vertical on below-right diagonal', () => {
expect(pickMembershipHandles(origin, { x: 10, y: 20 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
it('uses vertical on below-left diagonal', () => {
expect(pickMembershipHandles(origin, { x: -10, y: 20 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
it('uses vertical on above-right diagonal', () => {
expect(pickMembershipHandles(origin, { x: 10, y: -20 })).toEqual({
sourceHandle: 'top',
targetHandle: 'bottom',
})
})
it('uses vertical on above-left diagonal', () => {
expect(pickMembershipHandles(origin, { x: -10, y: -20 })).toEqual({
sourceHandle: 'top',
targetHandle: 'bottom',
})
})
it('uses vertical when axes are equal', () => {
expect(pickMembershipHandles(origin, { x: 40, y: 40 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
})
describe('clusterIdsForService', () => {
it('includes the service and membership VPS only', () => {
const ids = clusterIdsForService('svc-1', [
{ source: 'svc-1', target: 'vps-a', data: { relation: 'membership' } },
{ source: 'svc-1', target: 'vps-b', data: { relation: 'membership' } },
{ source: 'svc-2', target: 'vps-a', data: { relation: 'membership' } },
{ source: 'vps-a', target: 'vps-c', data: { relation: 'network' } },
])
expect(ids.sort()).toEqual(['svc-1', 'vps-a', 'vps-b'])
})
})
describe('applyMembershipHandles', () => {
it('sets handles from node centers', () => {
const nodes: TopologyFlowNode[] = [
{ id: 'svc', type: 'service', position: { x: 0, y: 0 }, data: { label: 'S', cfdmServiceId: 1, fqdn: '' } },
{ id: 'vps', type: 'vps', position: { x: 0, y: 200 }, data: { vpsId: 'v1' } },
]
const edges = [
{
id: 'e1',
source: 'svc',
target: 'vps',
data: membershipEdgeData(),
},
]
const next = applyMembershipHandles(edges, nodes)
expect(next[0]?.sourceHandle).toBe('bottom')
expect(next[0]?.targetHandle).toBe('top')
})
})
@@ -0,0 +1,61 @@
import type { Edge } from '@xyflow/react'
import { getNodeCenterAbsolute } from './group-utils'
import type { TopologyEdgeData, TopologyFlowNode } from './types'
export type MembershipHandleSide = 'top' | 'right' | 'bottom' | 'left'
export function pickMembershipHandles(
serviceCenter: { x: number; y: number },
vpsCenter: { x: number; y: number },
): { sourceHandle: MembershipHandleSide; targetHandle: MembershipHandleSide } {
const dx = vpsCenter.x - serviceCenter.x
const dy = vpsCenter.y - serviceCenter.y
if (Math.abs(dy) >= Math.abs(dx)) {
if (dy >= 0) return { sourceHandle: 'bottom', targetHandle: 'top' }
return { sourceHandle: 'top', targetHandle: 'bottom' }
}
if (dx >= 0) return { sourceHandle: 'right', targetHandle: 'left' }
return { sourceHandle: 'left', targetHandle: 'right' }
}
export function clusterIdsForService(
serviceNodeId: string,
edges: readonly { source: string; target: string; data?: { relation?: string } }[],
): string[] {
const ids = new Set<string>([serviceNodeId])
for (const edge of edges) {
if (edge.data?.relation !== 'membership') continue
if (edge.source === serviceNodeId) ids.add(edge.target)
}
return [...ids]
}
export function applyMembershipHandles(
edges: Edge<TopologyEdgeData>[],
nodes: TopologyFlowNode[],
): Edge<TopologyEdgeData>[] {
let changed = false
const next = edges.map((edge) => {
if (edge.data?.relation !== 'membership') return edge
const source = nodes.find((n) => n.id === edge.source)
const target = nodes.find((n) => n.id === edge.target)
if (!source || !target) return edge
const handles = pickMembershipHandles(
getNodeCenterAbsolute(source, nodes),
getNodeCenterAbsolute(target, nodes),
)
if (
edge.sourceHandle === handles.sourceHandle &&
edge.targetHandle === handles.targetHandle
) {
return edge
}
changed = true
return {
...edge,
sourceHandle: handles.sourceHandle,
targetHandle: handles.targetHandle,
}
})
return changed ? next : edges
}
@@ -3,10 +3,12 @@ import { VpsNode } from './nodes/vps-node'
import { ShapeNode } from './nodes/shape-node'
import { NoteNode } from './nodes/note-node'
import { GroupNode } from './nodes/group-node'
import { ServiceNode } from './nodes/service-node'
export const topologyNodeTypes = {
vps: VpsNode,
shape: ShapeNode,
note: NoteNode,
group: GroupNode,
service: ServiceNode,
} satisfies NodeTypes
@@ -1,7 +1,8 @@
import { memo } from 'react'
import { type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils'
import type { GroupNodeData } from '../types'
import { Badge } from '@/components/reui/badge'
import { lbModeBadgeVariant, lbModeLabel, type GroupNodeData } from '../types'
function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeData }) {
return (
@@ -13,8 +14,15 @@ function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeDat
>
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
<div className="flex flex-col gap-0.5 px-3 py-2">
<div className="text-xs font-medium text-foreground">
{data.label || 'Группа'}
<div className="flex items-center gap-1.5">
<div className="min-w-0 truncate text-xs font-medium text-foreground">
{data.label || 'Группа'}
</div>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.notes ? (
<div className="text-[10px] text-muted-foreground whitespace-pre-wrap">
@@ -0,0 +1,15 @@
import { Handle, Position } from '@xyflow/react'
const HANDLE_CLASS = '!size-2.5 !border-background !bg-muted-foreground'
/** Четыре стороны; ConnectionMode.Loose позволяет стартовать и заканчивать на любой. */
export function NodeSideHandles() {
return (
<>
<Handle type="source" position={Position.Top} id="top" className={HANDLE_CLASS} />
<Handle type="source" position={Position.Right} id="right" className={HANDLE_CLASS} />
<Handle type="source" position={Position.Bottom} id="bottom" className={HANDLE_CLASS} />
<Handle type="source" position={Position.Left} id="left" className={HANDLE_CLASS} />
</>
)
}
@@ -0,0 +1,42 @@
import { memo } from 'react'
import { type NodeProps } from '@xyflow/react'
import { NetworkIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
import { lbModeBadgeVariant, lbModeLabel, type ServiceNodeData } from '../types'
import { NodeSideHandles } from './node-side-handles'
function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNodeData }) {
return (
<div
className={cn(
'relative min-w-[200px] max-w-[280px] rounded-lg border bg-background px-3 py-2 shadow-sm',
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border',
)}
>
<NodeSideHandles />
<div className="flex items-start gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<NetworkIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{data.label || 'Сервис'}</span>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.fqdn ? (
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{data.fqdn}
</div>
) : null}
</div>
</div>
</div>
)
}
export const ServiceNode = memo(ServiceNodeComponent)
@@ -1,14 +1,17 @@
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { memo, useMemo } from 'react'
import { type NodeProps } from '@xyflow/react'
import { ServerIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
import { CountryFlag } from '@/components/country-flag'
import { StatusBadge } from '@/components/status-badge'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { resolveCountryCode, vpsStatusLabel } from '@/lib/format'
import type { Vps } from '@/types/entities'
import { aggregateCfdmServices, servicesForVps } from '../cfdm-services'
import { type VpsNodeData, vpsSpecsLine } from '../types'
import { NodeSideHandles } from './node-side-handles'
function formatRate(vps: Vps): string | null {
if (vps.tariffType === 'monthly' && vps.monthlyRate != null) {
@@ -31,6 +34,10 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
const rate = vps ? formatRate(vps) : null
const countryCode = resolveCountryCode(vps?.country)
const hasFlag = Boolean(countryCode)
const serviceChips = useMemo(
() => servicesForVps(aggregateCfdmServices(snapshot?.vpsDomains ?? []), data.vpsId),
[snapshot?.vpsDomains, data.vpsId],
)
return (
<div
@@ -50,11 +57,7 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
/>
</div>
) : null}
<Handle
type="target"
position={Position.Left}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
<NodeSideHandles />
{vps?.ip ? (
<div className="mb-1 font-mono text-[10px] text-muted-foreground">{vps.ip}</div>
) : null}
@@ -92,13 +95,17 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
{[vps.country, vps.city, vps.datacenter].filter(Boolean).join(' · ')}
</div>
) : null}
{serviceChips.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{serviceChips.map((s) => (
<Badge key={s.serviceId} variant="secondary" size="xs">
{s.name}
</Badge>
))}
</div>
) : null}
</div>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
</div>
)
}
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more