Compare commits
50
Commits
5c43d88f1a
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c5774df9 | ||
|
|
85ac5b15cb | ||
|
|
8ae07628c6 | ||
|
|
a4a8b9562d | ||
|
|
148ef8efb7 | ||
|
|
06d767fde7 | ||
|
|
a26647a52d | ||
|
|
d0e262c41d | ||
|
|
5e29ff7ec3 | ||
|
|
a185ffa659 | ||
|
|
f38694da63 | ||
|
|
80af888cdb | ||
|
|
0b9b1cad54 | ||
|
|
273883f657 | ||
|
|
78f413324a | ||
|
|
01a771407d | ||
|
|
e37f69ebf0 | ||
|
|
a70955f90d | ||
|
|
166abb03b1 | ||
|
|
9483e1122d | ||
|
|
e3403bcb56 | ||
|
|
b5300b9082 | ||
|
|
10b20b9d55 | ||
|
|
b3712af0c0 | ||
|
|
b00fb9345f | ||
|
|
e6ddb24964 | ||
|
|
a64d8b9755 | ||
|
|
5a61ce33b4 | ||
|
|
4dc5609f17 | ||
|
|
29325179f4 | ||
|
|
573ca097aa | ||
|
|
6e401533c7 | ||
|
|
adea0ab070 | ||
|
|
f53ca1ed7d | ||
|
|
d7bcd0d4ec | ||
|
|
ecd053b832 | ||
|
|
72aad059bd | ||
|
|
a9e3cce113 | ||
|
|
c844874b0a | ||
|
|
b03318800f | ||
|
|
7480b9dc11 | ||
|
|
235ae03489 | ||
|
|
60a22b9450 | ||
|
|
efebcaf16d | ||
|
|
9254b8dc22 | ||
|
|
a5f83c6ecb | ||
|
|
5847188dc7 | ||
|
|
ca7f37f980 | ||
|
|
d0853a7fd1 | ||
|
|
8ec1ccfecc |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"reui_pro": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +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
|
||||
@@ -3,6 +3,8 @@ name: Docker
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Релиз: тег vX.Y.Z → образ ghcr.io/…:X.Y.Z и :X.Y
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -20,7 +22,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -34,12 +36,14 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -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 мин); `GET /cc/vendor` — скрипт
|
||||
- **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
|
||||
|
||||
@@ -1,95 +1,60 @@
|
||||
# VPS Tracker
|
||||
|
||||
**VPS Tracker** — это веб-приложение для учёта виртуальных серверов, хостинг-провайдеров, счетов и балансов. Всё работает в браузере: данные хранятся у вас на компьютере или сервере, можно подключить синхронизацию с BILLmanager.
|
||||
**VPS Tracker** — self-hosted учёт виртуальных серверов: где они стоят, сколько стоят, когда продлевать, сколько денег осталось у хостера и доступны ли с них нужные сервисы. Данные синхронизируются с биллингом хостеров (BILLmanager и др.), хранятся в одном SQLite-файле у вас на сервере, интерфейс работает в браузере и на телефоне.
|
||||
|
||||
Ниже описано, как **запустить готовую программу в Linux** с помощью Docker. Отдельно устанавливать Node.js не нужно.
|
||||

|
||||
|
||||
> Все скриншоты сделаны на демо-данных: хостеры вымышленные, IP из документационных диапазонов (RFC 5737). Как пересоздать — [docs/screenshots/README.md](docs/screenshots/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Главное
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 🖥️ **Инвентарь VPS** | IP/IPv6, DNS, локация с флагом, ресурсы, ОС, проект, окружение, статус, произвольные поля, массовые действия |
|
||||
| 🔄 **Синхронизация с хостерами** | BILLmanager 6, 4VPS, Маклауд, VDSina, Veesp, RuVDS: серверы, платежи, баланс, тарифы — вручную или по расписанию |
|
||||
| 💰 **Деньги под контролем** | Платежи, журнал баланса, расход в месяц, «запас дней» до нуля, календарь продлений, отчёты и CSV-экспорт; мультивалютность с курсами ЦБ |
|
||||
| 🩺 **Здоровье инвентаря** | Uptime-проверки по TCP, истекающая оплата, низкий баланс, расхождения API и журнала — всё на дашборде |
|
||||
| 🌍 **Блокировки и GeoIP** | Проверка доступности сервисов (YouTube, ChatGPT, Discord…) и GeoIP-страны прямо с VPS или MikroTik одной командой |
|
||||
| 🗺️ **Схема инфраструктуры** | Визуальный редактор: VPS, связи (сеть, туннель, VPN, зависимость), группы и сервисы CFDM |
|
||||
| 🔔 **Уведомления** | Telegram (в т.ч. топики и свой Bot API) и webhook: оплата, низкий баланс, дайджест синка, новые тарифы, VPS упал/поднялся |
|
||||
| 👥 **Команда и пространства** | SSO через auth-portal (JWT), личные и общие пространства с ролями, шаринг VPS между пространствами, журнал изменений |
|
||||
| 💾 **Бэкапы** | Выгрузка/загрузка JSON или файла БД из интерфейса; данные — один файл `vps-tracker.db` |
|
||||
|
||||
Подробно по каждому разделу — **[docs/features.md](docs/features.md)**. Переменные окружения, роли и права — **[docs/configuration.md](docs/configuration.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Скриншоты
|
||||
|
||||
Дашборд со сводкой по VPS, балансам и графикам:
|
||||
|
||||

|
||||
|
||||
Список виртуальных серверов (IP-адреса на скриншоте замаскированы):
|
||||
|
||||

|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| **VPS** — инвентарь с фильтрами, видами и массовыми действиями | **Карточка VPS** — сеть, ресурсы, финансы, заметки, синк |
|
||||
|  |  |
|
||||
| **Схема** — редактор топологии с узлами VPS и типами связей | **Блокировки** — доступность сервисов с каждого VPS |
|
||||
|  |  |
|
||||
| **GeoIP** — какую страну видят GeoIP-базы и сервисы | **Аккаунты** — баланс по API, готовность к синку, пороги |
|
||||
|  |  |
|
||||
| **Платежи** — пополнения и прямые оплаты, итоги по валютам | **Отчёты** — расходы по проектам и хостерам, CSV |
|
||||
|  |  |
|
||||
| **Продления** — календарь ближайших оплат | **Уведомления** — Telegram и webhook |
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/mobile-dashboard.png" width="260" alt="Дашборд на телефоне">
|
||||
|
||||
<img src="docs/screenshots/mobile-vps.png" width="260" alt="Список VPS на телефоне">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
## Быстрый старт (Docker)
|
||||
|
||||
1. [Скриншоты](#скриншоты)
|
||||
2. [Что понадобится](#req)
|
||||
3. [Самый простой способ: готовый образ](#quick)
|
||||
4. [Запуск через Docker Compose](#compose)
|
||||
5. [Production: Traefik + Cloudflare HTTPS](#traefik)
|
||||
6. [Сборка образа у себя](#build-local)
|
||||
7. [Сохранение данных и резервная копия](#data)
|
||||
8. [Другой порт и доступ по сети](#port)
|
||||
9. [Обновление программы](#update)
|
||||
10. [Для разработчиков](#dev)
|
||||
|
||||
---
|
||||
|
||||
<span id="req"></span>
|
||||
|
||||
## Что понадобится
|
||||
|
||||
- Компьютер или сервер с **Linux** (подойдут Ubuntu, Debian, Fedora и другие дистрибутивы).
|
||||
- Установленные **Docker** и плагин **Compose**. Официальные инструкции: [Установка Docker Engine](https://docs.docker.com/engine/install/) и обзор [Compose](https://docs.docker.com/compose/).
|
||||
|
||||
Проверка в терминале:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span id="quick"></span>
|
||||
|
||||
## Самый простой способ: готовый образ
|
||||
|
||||
Подойдёт, если у вас есть только терминал и вы хотите быстро поднять сервис.
|
||||
|
||||
### Шаг 1. Скачать образ
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/denozordec/vps-tracker:latest
|
||||
```
|
||||
|
||||
> **Если запросили логин:** пакет на GitHub может быть закрытым. Войдите в аккаунт GitHub и создайте [Personal Access Token](https://github.com/settings/tokens) с правом `read:packages`, затем выполните:
|
||||
>
|
||||
> ```bash
|
||||
> echo ВАШ_ТОКЕН | docker login ghcr.io -u ВАШ_ЛОГИН_GITHUB --password-stdin
|
||||
> ```
|
||||
>
|
||||
> Либо откройте на GitHub страницу пакета **vps-tracker** → **Package settings** и сделайте пакет **public** — тогда `docker pull` без входа.
|
||||
|
||||
### Шаг 2. Папка для данных
|
||||
|
||||
Все записи приложения лежат в одном файле базы. Его нужно хранить **вне** контейнера, иначе при удалении контейнера данные пропадут.
|
||||
Нужны Linux-сервер (или любая машина) с [Docker](https://docs.docker.com/engine/install/). Node.js ставить не нужно.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/vps-tracker-data
|
||||
```
|
||||
|
||||
Можно выбрать любой каталог вместо `~/vps-tracker-data` — главное, запомнить путь для команды ниже.
|
||||
|
||||
### Шаг 3. Запуск
|
||||
|
||||
**Вариант А — в фоне** (удобно на сервере, контейнер переживёт закрытие терминала):
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name vps-tracker \
|
||||
--restart unless-stopped \
|
||||
@@ -98,42 +63,24 @@ docker run -d \
|
||||
ghcr.io/denozordec/vps-tracker:latest
|
||||
```
|
||||
|
||||
**Вариант Б — в переднем плане** (логи видны в терминале, остановка — `Ctrl+C`):
|
||||
Откройте **http://localhost:3001** (или `http://IP_СЕРВЕРА:3001`). База появится в `~/vps-tracker-data/vps-tracker.db`.
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-p 3001:3001 \
|
||||
-v ~/vps-tracker-data:/app/data \
|
||||
ghcr.io/denozordec/vps-tracker:latest
|
||||
```
|
||||
> **`docker pull` просит логин?** Пакет на GitHub может быть закрытым: войдите через [Personal Access Token](https://github.com/settings/tokens) с правом `read:packages` — `echo ТОКЕН | docker login ghcr.io -u ЛОГИН --password-stdin` — или сделайте пакет public в его настройках.
|
||||
|
||||
### Шаг 4. Открыть в браузере
|
||||
Первые шаги в интерфейсе:
|
||||
|
||||
На той же машине откройте:
|
||||
|
||||
**http://localhost:3001**
|
||||
|
||||
Если заходите с другого компьютера в сети — подставьте IP сервера: `http://192.168.1.10:3001` (ваш адрес может отличаться). На сервере может понадобиться открыть порт в брандмауэре, например для Ubuntu с UFW:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 3001/tcp
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
Остановка фонового контейнера:
|
||||
|
||||
```bash
|
||||
docker stop vps-tracker
|
||||
docker rm vps-tracker
|
||||
```
|
||||
1. **Хостеры** → добавьте хостера; для синка укажите тип API и URL биллинга.
|
||||
2. **Аккаунты хостеров** → добавьте аккаунт с логином/токеном API и нажмите «Синхронизировать» — серверы, платежи и баланс подтянутся сами.
|
||||
3. **Настройки → Общие** → базовая валюта и URL курсов (например `https://www.cbr-xml-daily.ru/daily_json.js`).
|
||||
4. **Настройки → Синхронизация / Уведомления** → автосинк по расписанию и Telegram.
|
||||
|
||||
---
|
||||
|
||||
<span id="compose"></span>
|
||||
## Варианты установки
|
||||
|
||||
## Запуск через Docker Compose
|
||||
### Docker Compose
|
||||
|
||||
Имеет смысл, если вы **склонировали этот репозиторий**: в [docker-compose.yml](docker-compose.yml) указан готовый образ **ghcr.io/denozordec/vps-tracker** — при `up` он подтягивается с GitHub Container Registry (локальная сборка не нужна).
|
||||
В [docker-compose.yml](docker-compose.yml) уже указан образ с GHCR, данные — в `./data`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/denozordec/vps-tracker.git
|
||||
@@ -141,140 +88,79 @@ cd vps-tracker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**То же через Docker CLI** (без Compose) — эквивалент [docker-compose.yml](docker-compose.yml): том `./data`, порт **3001**, переменная **PORT=3001**.
|
||||
### Production: Traefik + HTTPS (Cloudflare DNS-01)
|
||||
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
docker pull ghcr.io/denozordec/vps-tracker:latest
|
||||
docker run -d \
|
||||
--name vps-tracker \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3001 \
|
||||
-e PORT=3001 \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
ghcr.io/denozordec/vps-tracker:latest
|
||||
```
|
||||
|
||||
В PowerShell из каталога проекта подставьте путь к данным так: `-v "${PWD}/data:/app/data"`.
|
||||
|
||||
Данные по умолчанию сохраняются в папку `data` внутри каталога проекта на диске.
|
||||
|
||||
Остановка:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span id="traefik"></span>
|
||||
|
||||
## Production: Traefik + Cloudflare HTTPS (Linux)
|
||||
|
||||
На VPS с доменом в Cloudflare: Traefik (Let's Encrypt **DNS-01**) + контейнер VPS Tracker за HTTPS, без публикации порта `3001` наружу.
|
||||
|
||||
Пошаговая инструкция, compose и `.env`: **[docs/deploy-traefik.md](docs/deploy-traefik.md)**
|
||||
Файлы: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml), [`deploy/env.traefik.example`](deploy/env.traefik.example).
|
||||
|
||||
Кратко:
|
||||
Готовый compose с Traefik и Let's Encrypt, без публикации порта `3001` наружу, плюс короткий домен для launcher-скриптов блокировок/GeoIP:
|
||||
**[docs/deploy-traefik.md](docs/deploy-traefik.md)** · [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) · [`deploy/env.traefik.example`](deploy/env.traefik.example)
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/vps-tracker/data && cd /opt/vps-tracker
|
||||
curl -fsSL -o docker-compose.yml \
|
||||
https://raw.githubusercontent.com/denozordec/vps-tracker/main/deploy/docker-compose.traefik.yml
|
||||
curl -fsSL -o .env \
|
||||
https://raw.githubusercontent.com/denozordec/vps-tracker/main/deploy/env.traefik.example
|
||||
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/denozordec/vps-tracker/main/deploy/docker-compose.traefik.yml
|
||||
curl -fsSL -o .env https://raw.githubusercontent.com/denozordec/vps-tracker/main/deploy/env.traefik.example
|
||||
nano .env # CF_DNS_API_TOKEN, LETSENCRYPT_EMAIL, VPS_DOMAIN
|
||||
docker compose pull && docker compose up -d
|
||||
curl -fsS https://ВАШ_ДОМЕН/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span id="build-local"></span>
|
||||
|
||||
## Сборка образа у себя
|
||||
|
||||
Если нужен свой образ из исходников (после `git clone`):
|
||||
### Свой образ из исходников
|
||||
|
||||
```bash
|
||||
cd vps-tracker
|
||||
docker build -t vps-tracker:local .
|
||||
mkdir -p ./data
|
||||
docker run -d \
|
||||
--name vps-tracker \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3001 \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
vps-tracker:local
|
||||
docker run -d --name vps-tracker --restart unless-stopped \
|
||||
-p 3001:3001 -v "$(pwd)/data:/app/data" vps-tracker:local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span id="data"></span>
|
||||
## Данные, бэкап и обновление
|
||||
|
||||
## Сохранение данных и резервная копия
|
||||
- **Всё хранится в одном файле** `vps-tracker.db` (SQLite) в смонтированной папке (`/app/data` в контейнере). Скопировать его — и есть бэкап.
|
||||
- **Из интерфейса:** *Настройки → Общие* — выгрузка/загрузка JSON-бэкапа и файла БД (при включённой авторизации — только глобальный админ).
|
||||
- **Старые базы** обновляются автоматически при старте: недостающие колонки и таблицы добавляются, данные не теряются.
|
||||
- **Журналы чистятся сами:** уведомления и журнал синка старше 90 дней, uptime-проверки старше 30 дней.
|
||||
|
||||
Файл базы в примерах выше лежит на хосте:
|
||||
|
||||
| Способ запуска | Где лежит база на диске |
|
||||
|----------------|-------------------------|
|
||||
| `docker run` с `-v ~/vps-tracker-data:/app/data` | `~/vps-tracker-data/vps-tracker.db` |
|
||||
| Docker Compose из репозитория | `./data/vps-tracker.db` в папке проекта |
|
||||
|
||||
Скопируйте файл `vps-tracker.db` в безопасное место — это и есть резервная копия. Пока приложение не запущено, можно просто скопировать файл обратно для восстановления.
|
||||
|
||||
---
|
||||
|
||||
<span id="port"></span>
|
||||
|
||||
## Другой порт и доступ по сети
|
||||
|
||||
Внутри контейнера приложение слушает порт **3001**. Сменить внешний порт (например, **8080** снаружи, 3001 внутри):
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name vps-tracker \
|
||||
--restart unless-stopped \
|
||||
-p 8080:3001 \
|
||||
-v ~/vps-tracker-data:/app/data \
|
||||
ghcr.io/denozordec/vps-tracker:latest
|
||||
```
|
||||
|
||||
Тогда в браузере: **http://localhost:8080**
|
||||
|
||||
Чтобы сменить и внутренний порт (редко нужно), добавьте `-e PORT=8080` и согласуйте с `-p`, например `-p 8080:8080` и `-e PORT=8080`.
|
||||
|
||||
---
|
||||
|
||||
<span id="update"></span>
|
||||
|
||||
## Обновление программы
|
||||
|
||||
Если используете образ с GitHub:
|
||||
Обновление:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/denozordec/vps-tracker:latest
|
||||
docker stop vps-tracker
|
||||
docker rm vps-tracker
|
||||
docker stop vps-tracker && docker rm vps-tracker
|
||||
# и снова docker run из «Быстрого старта» с тем же -v …:/app/data
|
||||
```
|
||||
|
||||
Затем снова выполните команду `docker run` из раздела [«Самый простой способ»](#quick) (том `-v ...:/app/data` оставьте тот же — данные сохранятся).
|
||||
С Compose: `git pull && docker compose up -d` (`pull_policy: always` подтянет свежий образ).
|
||||
|
||||
При Docker Compose из репозитория:
|
||||
Версии: каждый релиз — git-тег `vX.Y.Z` и образ `ghcr.io/denozordec/vps-tracker:X.Y.Z` (и `:X.Y`); `latest` — текущий `main`. Чтобы не обновляться неожиданно, закрепите версию: `ghcr.io/denozordec/vps-tracker:1.0.0`. Текущая версия видна в меню пользователя и в ответе `GET /health`.
|
||||
|
||||
```bash
|
||||
cd vps-tracker
|
||||
git pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Файл Compose настроен с `pull_policy: always`, поэтому при каждом `up` запрашивается актуальный образ с GHCR.
|
||||
Другой порт снаружи: `-p 8080:3001` → `http://localhost:8080`. Сменить порт внутри контейнера: `-e PORT=8080 -p 8080:8080`.
|
||||
|
||||
---
|
||||
|
||||
<span id="dev"></span>
|
||||
## Документация
|
||||
|
||||
| Документ | О чём |
|
||||
|---|---|
|
||||
| [docs/features.md](docs/features.md) | Все разделы интерфейса и как ими пользоваться |
|
||||
| [docs/configuration.md](docs/configuration.md) | Переменные окружения, авторизация, роли, синк, уведомления, планировщик |
|
||||
| [docs/deploy-traefik.md](docs/deploy-traefik.md) | Production за Traefik с HTTPS |
|
||||
| [docs/screenshots/README.md](docs/screenshots/README.md) | Демо-данные и пересъёмка скриншотов |
|
||||
| [apps/api/src/services/billmanager/profiles/README.md](apps/api/src/services/billmanager/profiles/README.md) | Как добавить профиль нового BILLmanager-хостера |
|
||||
| [AGENTS.md](AGENTS.md) | Архитектура и карта кода (для разработчиков и ИИ-агентов) |
|
||||
|
||||
---
|
||||
|
||||
## Для разработчиков
|
||||
|
||||
Локальный запуск без Docker: `npm install` и `npm run dev`. Подробности по коду, API и синхронизации — в [AGENTS.md](AGENTS.md).
|
||||
Monorepo на **pnpm**: `apps/web` (Vite + React 19 + TanStack Router/Query + shadcn/ui + ReUI), `apps/api` (Fastify 5), `packages/db` (Drizzle + better-sqlite3), `packages/shared` (Zod-контракты).
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter api dev # API на :3001 (база — apps/api/data/vps-tracker.db или DB_PATH)
|
||||
pnpm --filter web dev # фронтенд с hot reload
|
||||
pnpm --filter api test # тесты API (Vitest)
|
||||
pnpm --filter web test # тесты фронтенда
|
||||
pnpm --filter web build # production-сборка фронтенда
|
||||
```
|
||||
|
||||
Демо-данные для разработки: `cd apps/api && DB_PATH=../../data/demo.db VITEST=1 npx tsx scripts/seed-demo.ts --serve` → http://127.0.0.1:3101.
|
||||
|
||||
Подробнее об устройстве кода — [AGENTS.md](AGENTS.md).
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cfdm/api",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @cfdm/shared build && pnpm --filter @cfdm/db build && tsx watch src/index.ts",
|
||||
|
||||
@@ -3,5 +3,7 @@ Vendor pin of https://github.com/vernette/censorcheck
|
||||
- File: `censorcheck.sh`
|
||||
- Commit: `12c5839` (2026-08-11)
|
||||
- License: MIT (see upstream repository)
|
||||
- Keep Unix LF and the `display_help` here-doc intact (`is_installed` lives after it).
|
||||
- Local overlay: `show_progress` пишет бар в stderr даже при `--json`.
|
||||
|
||||
Do not fetch this script from GitHub at runtime — some probe networks block github.com.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,8 +5,14 @@ set -euo pipefail
|
||||
|
||||
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
|
||||
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
|
||||
LAUNCHER_VERSION="1"
|
||||
LAUNCHER_VERSION="6"
|
||||
VENDOR_SHA="12c5839"
|
||||
DAILY_SLUG="vt-censorcheck"
|
||||
DAILY_PATH="/cc"
|
||||
|
||||
OS_ID="unknown"
|
||||
OS_LIKE=""
|
||||
OS_NAME="unknown"
|
||||
|
||||
trap 'exit 130' INT
|
||||
|
||||
@@ -17,9 +23,137 @@ 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) printf '%s\n' dnsutils bind9-dnsutils ;;
|
||||
dnf:dig|yum:dig) printf '%s\n' bind-utils ;;
|
||||
pacman:dig) printf '%s\n' bind ;;
|
||||
apk:dig) 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 jq
|
||||
require_cmd bash
|
||||
ensure_cmds jq dig column
|
||||
|
||||
detect_public_ip() {
|
||||
local ip=""
|
||||
@@ -33,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
|
||||
@@ -59,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
|
||||
@@ -71,33 +399,33 @@ 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 "Проверяю сайты (последовательно, несколько минут)..."
|
||||
|
||||
set +e
|
||||
RAW_JSON="$(bash "$VENDOR" --mode both --json --no-header --no-dns 2>/tmp/vt-censorcheck-err.$$)"
|
||||
# stdout = JSON; stderr = прогресс-бар (не перехватывать)
|
||||
RAW_JSON="$(bash "$VENDOR" --mode both --json --no-header --no-dns)"
|
||||
CC_EXIT=$?
|
||||
set -e
|
||||
if [ "$CC_EXIT" -ne 0 ]; then
|
||||
log "censorcheck завершился с кодом ${CC_EXIT}" >&2
|
||||
if [ -s /tmp/vt-censorcheck-err.$$ ]; then
|
||||
cat /tmp/vt-censorcheck-err.$$ >&2 || true
|
||||
fi
|
||||
rm -f /tmp/vt-censorcheck-err.$$
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/vt-censorcheck-err.$$
|
||||
|
||||
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"),
|
||||
@@ -122,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
|
||||
|
||||
@@ -131,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
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Screenshots for README/docs via Chrome DevTools Protocol (no extra deps: Node ≥ 22 has WebSocket).
|
||||
* Start the demo first (see seed-demo.ts --serve), then:
|
||||
*
|
||||
* node apps/api/scripts/docs-screenshots.mjs [outDir=docs/screenshots]
|
||||
*
|
||||
* Env: BASE (default http://127.0.0.1:3101), CHROME (path to chrome/msedge).
|
||||
* Waits for real time (not virtual time) so Recharts animations finish before the shot.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const outDir = process.argv[2] ?? 'docs/screenshots'
|
||||
const BASE = process.env.BASE ?? 'http://127.0.0.1:3101'
|
||||
const CHROME =
|
||||
process.env.CHROME ??
|
||||
[
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].find(existsSync)
|
||||
const PORT = 9333
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const getJson = async (path) => (await fetch(BASE + path)).json()
|
||||
const vps = await getJson('/api/vps')
|
||||
const projects = await getJson('/api/projects')
|
||||
const vpsId = (dns) => vps.find((v) => v.dns === dns)?.id
|
||||
const mobile = { width: 390, height: 844, mobile: true }
|
||||
|
||||
const jobs = [
|
||||
{ name: 'dashboard', path: '/dashboard' },
|
||||
{ name: 'dashboard-charts', path: '/dashboard', scrollY: 700 },
|
||||
{ name: 'vps', path: '/vps' },
|
||||
{ name: 'vps-detail', path: `/vps/${vpsId('web-1.example.net')}` },
|
||||
{ name: 'vps-detail-finance', path: `/vps/${vpsId('monitor.example.net')}`, clickText: 'Финансы' },
|
||||
{ name: 'blocking', path: '/blocking' },
|
||||
{ name: 'geo', path: '/geo' },
|
||||
{ name: 'topology', path: '/topology', waitMs: 4500 },
|
||||
{ name: 'tariffs', path: '/tariffs' },
|
||||
{ name: 'providers', path: '/providers' },
|
||||
{ name: 'accounts', path: '/accounts' },
|
||||
{ name: 'projects', path: '/projects' },
|
||||
{ name: 'project-detail', path: `/projects/${projects.find((p) => p.name === 'VPN')?.id}` },
|
||||
{ name: 'payments', path: '/payments' },
|
||||
{ name: 'balance', path: '/balance' },
|
||||
{ name: 'reports', path: '/reports' },
|
||||
{ name: 'resources', path: '/resources' },
|
||||
{ name: 'renewals', path: '/renewals' },
|
||||
{ name: 'settings', path: '/settings' },
|
||||
{ name: 'settings-sync', path: '/settings/sync' },
|
||||
{ name: 'settings-notifications', path: '/settings/notifications' },
|
||||
{ name: 'settings-integrations', path: '/settings/integrations' },
|
||||
{ name: 'sync-journal', path: '/sync-journal' },
|
||||
{ name: 'audit', path: '/audit' },
|
||||
{ name: 'mobile-dashboard', path: '/dashboard', ...mobile },
|
||||
{ name: 'mobile-vps', path: '/vps', ...mobile },
|
||||
]
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
const chrome = spawn(
|
||||
CHROME,
|
||||
[
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--hide-scrollbars',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
`--user-data-dir=${mkdtempSync(join(tmpdir(), 'vt-shots-'))}`,
|
||||
'about:blank',
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
)
|
||||
|
||||
let target
|
||||
for (let i = 0; i < 50 && !target; i++) {
|
||||
await sleep(200)
|
||||
try {
|
||||
target = (await getJsonAbs(`http://127.0.0.1:${PORT}/json`)).find((t) => t.type === 'page')
|
||||
} catch {}
|
||||
}
|
||||
async function getJsonAbs(url) {
|
||||
return (await fetch(url)).json()
|
||||
}
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((r) => ws.addEventListener('open', r))
|
||||
let seq = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', (e) => {
|
||||
const m = JSON.parse(e.data)
|
||||
if (m.id && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((r) => {
|
||||
const id = ++seq
|
||||
pending.set(id, r)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
const evaluate = async (expression) =>
|
||||
(await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result?.result
|
||||
?.value
|
||||
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
for (const job of jobs) {
|
||||
await send('Emulation.setDeviceMetricsOverride', {
|
||||
width: job.width ?? 1440,
|
||||
height: job.height ?? 900,
|
||||
deviceScaleFactor: job.mobile ? 2 : 1,
|
||||
mobile: Boolean(job.mobile),
|
||||
})
|
||||
await send('Page.navigate', { url: BASE + job.path })
|
||||
await sleep(job.waitMs ?? 3500)
|
||||
if (job.clickText) {
|
||||
// Base UI tabs react to real pointer input, so click via CDP at the element's centre.
|
||||
for (let i = 0; i < 20 && !(await evaluate(`document.querySelectorAll('[role=tab]').length`)); i++)
|
||||
await sleep(500)
|
||||
const pos = await evaluate(`(() => {
|
||||
const el = [...document.querySelectorAll('button,[role=tab],a')]
|
||||
.find((e) => (e.innerText || '').trim().startsWith(${JSON.stringify(job.clickText)}))
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })()`)
|
||||
if (!pos) console.warn(job.name, 'not found:', job.clickText)
|
||||
else
|
||||
for (const type of ['mousePressed', 'mouseReleased'])
|
||||
await send('Input.dispatchMouseEvent', { type, x: pos.x, y: pos.y, button: 'left', clickCount: 1 })
|
||||
await sleep(1800)
|
||||
}
|
||||
if (job.scrollY) {
|
||||
await evaluate(`window.scrollBy(0, ${job.scrollY})`)
|
||||
await sleep(1200)
|
||||
}
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' })
|
||||
writeFileSync(join(outDir, `${job.name}.png`), Buffer.from(shot.result.data, 'base64'))
|
||||
console.log('ok', job.name)
|
||||
}
|
||||
ws.close()
|
||||
chrome.kill()
|
||||
process.exit(0)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Demo data for screenshots/docs. Fictional hosters, IPs from documentation ranges (RFC 5737).
|
||||
* Needs an EMPTY database file. VITEST=1 keeps src/index.ts from starting the real server + scheduler.
|
||||
*
|
||||
* cd apps/api
|
||||
* DB_PATH=../../data/demo.db VITEST=1 LOG_LEVEL=warn npx tsx scripts/seed-demo.ts --serve
|
||||
*
|
||||
* With --serve the seeded app keeps listening on PORT (default 3101) for scripts/docs-screenshots.mjs.
|
||||
*/
|
||||
import { getSqlite } from '@cfdm/db'
|
||||
import { buildApp } from '../src/index.js'
|
||||
import { mintIngestToken } from '../src/services/censorcheck/ingest-token.js'
|
||||
import { stopScheduler } from '../src/services/scheduler.js'
|
||||
|
||||
const SECRET = process.env.CENSORCHECK_INGEST_SECRET || 'demo-secret'
|
||||
process.env.CENSORCHECK_INGEST_SECRET = SECRET
|
||||
process.env.CENSORCHECK_RATE_LIMIT = '0'
|
||||
|
||||
const app = await buildApp()
|
||||
const sqlite = getSqlite()
|
||||
|
||||
async function api<T = { id: string }>(method: string, url: string, payload?: unknown, headers = {}): Promise<T> {
|
||||
const res = await app.inject({ method: method as 'POST', url, payload: payload as object, headers })
|
||||
if (res.statusCode >= 300) throw new Error(`${method} ${url} → ${res.statusCode}: ${res.body}`)
|
||||
return res.json() as T
|
||||
}
|
||||
|
||||
const day = (offset: number) => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + offset)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// --- settings
|
||||
await api('PUT', '/api/settings/settings-main', {
|
||||
baseCurrency: 'RUB',
|
||||
ratesUrl: 'https://www.cbr-xml-daily.ru/daily_json.js',
|
||||
autoConvert: true,
|
||||
syncEnabled: false,
|
||||
notifyPaymentExpiryEnabled: true,
|
||||
notifyLowBalanceEnabled: true,
|
||||
notifySyncDigestEnabled: true,
|
||||
notifyVpsDownEnabled: true,
|
||||
telegramChatId: '-1001234567890',
|
||||
})
|
||||
|
||||
// --- providers & accounts
|
||||
const providers = [
|
||||
{ key: 'nord', name: 'Nordhost', website: 'https://nordhost.example', baseCurrency: 'EUR', eurRate: '98.5', usdRate: '91.2', apiType: 'billmanager', apiBaseUrl: 'https://my.nordhost.example/billmgr' },
|
||||
{ key: 'aurora', name: 'Aurora Cloud', website: 'https://aurora-cloud.example', baseCurrency: 'RUB', apiType: 'billmanager', apiBaseUrl: 'https://billing.aurora-cloud.example/billmgr' },
|
||||
{ key: 'baltic', name: 'Baltic VDS', website: 'https://balticvds.example', baseCurrency: 'RUB', apiType: 'none', apiBaseUrl: '' },
|
||||
{ key: 'pixel', name: 'Pixel Hosting', website: 'https://pixelhost.example', baseCurrency: 'USD', usdRate: '91.2', apiType: 'none', apiBaseUrl: '' },
|
||||
]
|
||||
const prov: Record<string, string> = {}
|
||||
for (const p of providers) prov[p.key] = (await api('POST', '/api/providers', p)).id
|
||||
|
||||
const accounts = [
|
||||
{ key: 'nord', providerId: prov.nord, name: 'Nordhost — основной', currency: 'EUR', billingMode: 'monthly', apiCredentials: 'demo:demo', balanceAlertBelow: 20, balance: 46.8, enough: day(38) },
|
||||
{ key: 'aurora', providerId: prov.aurora, name: 'Aurora — проекты', currency: 'RUB', billingMode: 'daily', apiCredentials: 'demo:demo', balanceAlertBelow: 1500, balance: 1120, enough: day(9) },
|
||||
{ key: 'baltic', providerId: prov.baltic, name: 'Baltic VDS', currency: 'RUB', billingMode: 'monthly', balance: null, enough: '' },
|
||||
{ key: 'pixel', providerId: prov.pixel, name: 'Pixel — US', currency: 'USD', billingMode: 'monthly', balance: null, enough: '' },
|
||||
]
|
||||
const acc: Record<string, string> = {}
|
||||
for (const a of accounts) {
|
||||
const { key, balance, enough, ...body } = a
|
||||
acc[key] = (await api('POST', '/api/provider-accounts', body)).id
|
||||
if (balance != null) {
|
||||
sqlite
|
||||
.prepare(`UPDATE provider_accounts SET balance_api = ?, balance_currency = ?, balance_updated_at = ?, enoughmoneyto = ? WHERE id = ?`)
|
||||
.run(balance, a.currency, new Date(Date.now() - 40 * 60_000).toISOString(), enough, acc[key])
|
||||
}
|
||||
}
|
||||
|
||||
// --- projects
|
||||
const projects = [
|
||||
{ name: 'Сайт и API', color: '#2563eb' },
|
||||
{ name: 'VPN', color: '#16a34a' },
|
||||
{ name: 'Мониторинг', color: '#d97706' },
|
||||
{ name: 'Staging', color: '#9333ea' },
|
||||
]
|
||||
for (const p of projects) await api('POST', '/api/projects', p)
|
||||
|
||||
// --- VPS
|
||||
type V = Record<string, unknown> & { key: string }
|
||||
const vpsList: V[] = [
|
||||
{ key: 'web1', dns: 'web-1.example.net', ip: '203.0.113.10', ipv6: '2001:db8:10::1', providerId: prov.nord, providerAccountId: acc.nord, country: 'Нидерланды', city: 'Амстердам', datacenter: 'AMS-1', os: 'Debian 12', vcpu: 4, ramGb: 8, diskGb: 120, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'Frontend + API', tariffType: 'monthly', currency: 'EUR', monthlyRate: 14.9, paidUntil: day(4), monitoringEnabled: true, backupEnabled: true },
|
||||
{ key: 'web2', dns: 'web-2.example.net', ip: '203.0.113.11', providerId: prov.nord, providerAccountId: acc.nord, country: 'Германия', city: 'Франкфурт', datacenter: 'FRA-2', os: 'Debian 12', vcpu: 4, ramGb: 8, diskGb: 120, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'Frontend + API (HA)', tariffType: 'monthly', currency: 'EUR', monthlyRate: 14.9, paidUntil: day(19), monitoringEnabled: true, backupEnabled: true },
|
||||
{ key: 'db1', dns: 'db-1.example.net', ip: '203.0.113.20', providerId: prov.nord, providerAccountId: acc.nord, country: 'Нидерланды', city: 'Амстердам', datacenter: 'AMS-1', os: 'Ubuntu 24.04', vcpu: 8, ramGb: 32, diskGb: 480, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'PostgreSQL', tariffType: 'monthly', currency: 'EUR', monthlyRate: 39, paidUntil: day(19), monitoringEnabled: true, backupEnabled: true },
|
||||
{ key: 'vpn1', dns: 'vpn-fi.example.net', ip: '198.51.100.31', providerId: prov.aurora, providerAccountId: acc.aurora, country: 'Финляндия', city: 'Хельсинки', datacenter: 'HEL', os: 'Ubuntu 22.04', vcpu: 1, ramGb: 1, diskGb: 15, diskType: 'SSD', virtualization: 'KVM', project: 'VPN', environment: 'production', purpose: 'WireGuard', tariffType: 'daily', currency: 'RUB', dailyRate: 9.5, paidUntil: '', monitoringEnabled: true },
|
||||
{ key: 'vpn2', dns: 'vpn-de.example.net', ip: '198.51.100.32', providerId: prov.aurora, providerAccountId: acc.aurora, country: 'Германия', city: 'Нюрнберг', datacenter: 'NUE', os: 'Ubuntu 22.04', vcpu: 1, ramGb: 2, diskGb: 20, diskType: 'SSD', virtualization: 'KVM', project: 'VPN', environment: 'production', purpose: 'WireGuard + Xray', tariffType: 'daily', currency: 'RUB', dailyRate: 12, paidUntil: '', monitoringEnabled: true },
|
||||
{ key: 'vpn3', dns: 'vpn-kz.example.net', ip: '198.51.100.33', providerId: prov.aurora, providerAccountId: acc.aurora, country: 'Казахстан', city: 'Алматы', datacenter: 'ALA', os: 'Debian 12', vcpu: 1, ramGb: 1, diskGb: 10, diskType: 'SSD', virtualization: 'KVM', project: 'VPN', environment: 'production', purpose: 'Xray', tariffType: 'daily', currency: 'RUB', dailyRate: 7.2, paidUntil: '', monitoringEnabled: true },
|
||||
{ key: 'mon', dns: 'monitor.example.net', ip: '192.0.2.40', providerId: prov.baltic, providerAccountId: acc.baltic, country: 'Россия', city: 'Москва', datacenter: 'MSK-DC3', os: 'Rocky Linux 9', vcpu: 2, ramGb: 4, diskGb: 60, diskType: 'SSD', virtualization: 'KVM', project: 'Мониторинг', environment: 'production', purpose: 'Grafana + Prometheus', tariffType: 'monthly', currency: 'RUB', monthlyRate: 690, paidUntil: day(2), monitoringEnabled: true, backupEnabled: true },
|
||||
{ key: 'stage', dns: 'stage.example.net', ip: '192.0.2.41', providerId: prov.baltic, providerAccountId: acc.baltic, country: 'Россия', city: 'Санкт-Петербург', datacenter: 'SPB-1', os: 'Debian 12', vcpu: 2, ramGb: 4, diskGb: 40, diskType: 'SSD', virtualization: 'KVM', project: 'Staging', environment: 'staging', purpose: 'Staging', tariffType: 'monthly', currency: 'RUB', monthlyRate: 450, paidUntil: day(26), monitoringEnabled: false },
|
||||
{ key: 'us1', dns: 'edge-us.example.net', ip: '192.0.2.50', providerId: prov.pixel, providerAccountId: acc.pixel, country: 'США', city: 'Нью-Йорк', datacenter: 'NYC-3', os: 'Ubuntu 24.04', vcpu: 2, ramGb: 2, diskGb: 50, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'Edge / CDN origin', tariffType: 'monthly', currency: 'USD', monthlyRate: 12, paidUntil: day(11), monitoringEnabled: true },
|
||||
{ key: 'old', dns: 'legacy.example.net', ip: '192.0.2.60', providerId: prov.pixel, providerAccountId: acc.pixel, country: 'США', city: 'Даллас', datacenter: 'DAL-1', os: 'CentOS 7', vcpu: 1, ramGb: 1, diskGb: 25, diskType: 'HDD', virtualization: 'OpenVZ', project: 'Staging', environment: 'staging', purpose: 'Старый сервер', status: 'paused', tariffType: 'monthly', currency: 'USD', monthlyRate: 5, paidUntil: day(-12) },
|
||||
]
|
||||
const vps: Record<string, string> = {}
|
||||
for (const { key, ...body } of vpsList) vps[key] = (await api('POST', '/api/vps', body)).id
|
||||
|
||||
// --- payments (6 months) and ledger
|
||||
for (let m = 5; m >= 0; m--) {
|
||||
await api('POST', '/api/payments', { type: 'provider_balance_topup', date: day(-m * 30 - 3), amount: 60 + (m % 2) * 15, currency: 'EUR', providerAccountId: acc.nord, note: 'Пополнение картой' })
|
||||
await api('POST', '/api/payments', { type: 'provider_balance_topup', date: day(-m * 30 - 7), amount: 900 + m * 50, currency: 'RUB', providerAccountId: acc.aurora, note: 'СБП' })
|
||||
await api('POST', '/api/payments', { type: 'direct_vps_payment', date: day(-m * 30 - 1), amount: 690, currency: 'RUB', providerAccountId: acc.baltic, vpsId: vps.mon, note: 'Продление monitor' })
|
||||
await api('POST', '/api/payments', { type: 'direct_vps_payment', date: day(-m * 30 - 10), amount: 12, currency: 'USD', providerAccountId: acc.pixel, vpsId: vps.us1, note: 'Продление edge-us' })
|
||||
}
|
||||
await api('POST', '/api/payments', { type: 'direct_vps_payment', date: day(-20), amount: 450, currency: 'RUB', providerAccountId: acc.baltic, vpsId: vps.stage, note: 'Продление staging' })
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await api('POST', '/api/balance-ledger', { type: 'daily_debit', date: day(-i), amount: 28.7, currency: 'RUB', direction: 'debit', providerAccountId: acc.aurora, note: 'Списание за VPN-серверы' })
|
||||
}
|
||||
await api('POST', '/api/balance-ledger', { type: 'provider_balance_topup', date: day(-7), amount: 1200, currency: 'RUB', direction: 'credit', providerAccountId: acc.aurora, note: 'Пополнение' })
|
||||
|
||||
// --- tariffs from "sync"
|
||||
const tariffs = [
|
||||
['AMS-1', 'Нидерланды', 'KVM-S', 2, 4, 60, '7.90 EUR'], ['AMS-1', 'Нидерланды', 'KVM-M', 4, 8, 120, '14.90 EUR'],
|
||||
['AMS-1', 'Нидерланды', 'KVM-L', 8, 32, 480, '39.00 EUR'], ['FRA-2', 'Германия', 'KVM-S', 2, 4, 60, '8.50 EUR'],
|
||||
['FRA-2', 'Германия', 'KVM-M', 4, 8, 120, '14.90 EUR'],
|
||||
] as const
|
||||
const now = new Date().toISOString()
|
||||
tariffs.forEach(([dc, country, name, cpu, ram, disk, price], i) =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO active_tariffs (id, spaceId, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||
VALUES (?, 'space-main', ?, ?, ?, ?, ?, ?, '', ?, ?, ?, 'NVMe', 'KVM', '1 Гбит/с', ?, ?, 'AMD EPYC', 1, ?, ?)`,
|
||||
)
|
||||
.run(`tariff-demo-${i}`, acc.nord, prov.nord, String(100 + i), dc, dc, name, cpu, ram, disk, dc, country, price, now),
|
||||
)
|
||||
;[
|
||||
['VPN-1', 1, 1, 10, '7.20 ₽/день'], ['VPN-2', 1, 2, 20, '12.00 ₽/день'], ['Cloud-4', 4, 8, 80, '39.00 ₽/день'],
|
||||
].forEach(([name, cpu, ram, disk, price], i) =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO active_tariffs (id, spaceId, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||
VALUES (?, 'space-main', ?, ?, ?, 'HEL', 'Хельсинки', ?, '', ?, ?, ?, 'SSD', 'KVM', '1 Гбит/с', 'Хельсинки', 'Финляндия', 'Intel Xeon', 1, ?, ?)`,
|
||||
)
|
||||
.run(`tariff-demo-a${i}`, acc.aurora, prov.aurora, String(200 + i), name, cpu, ram, disk, price, now),
|
||||
)
|
||||
|
||||
// --- sync journal
|
||||
const syncRows: [string, number, string, number, number, string | null][] = [
|
||||
[acc.nord, 35, 'ok', 3, 1, null], [acc.aurora, 36, 'ok', 3, 0, null],
|
||||
[acc.nord, 95, 'ok', 3, 0, null], [acc.aurora, 96, 'error', 0, 0, 'HTTP 502 Bad Gateway'],
|
||||
[acc.nord, 155, 'ok', 3, 0, null], [acc.aurora, 156, 'ok', 3, 1, null],
|
||||
]
|
||||
syncRows.forEach(([accountId, minAgo, status, vpsCount, paymentsCount, error], i) => {
|
||||
const started = new Date(Date.now() - minAgo * 60_000)
|
||||
sqlite
|
||||
.prepare(`INSERT INTO sync_log (id, spaceId, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary) VALUES (?, 'space-main', ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(`sync-demo-${i}`, accountId, started.toISOString(), new Date(started.getTime() + 4200).toISOString(), status, vpsCount, paymentsCount, error,
|
||||
JSON.stringify(status === 'ok' ? { added: [], updated: i === 0 ? [{ id: vps.web1, label: 'web-1.example.net', fields: ['paidUntil'] }] : [], paymentsAdded: paymentsCount, vpsCount, paymentsCount, tariffsCount: 0 } : { error }))
|
||||
})
|
||||
|
||||
// --- uptime history
|
||||
const health: Record<string, 'up' | 'down'> = { web1: 'up', web2: 'up', db1: 'up', vpn1: 'up', vpn2: 'down', vpn3: 'up', mon: 'up', us1: 'up' }
|
||||
for (const [key, status] of Object.entries(health)) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const st = i === 0 ? status : 'up'
|
||||
sqlite
|
||||
.prepare(`INSERT INTO vps_health_checks (id, spaceId, vpsId, checkedAt, status, latencyMs, error) VALUES (?, 'space-main', ?, ?, ?, ?, ?)`)
|
||||
.run(`hc-demo-${key}-${i}`, vps[key], new Date(Date.now() - i * 5 * 60_000).toISOString(), st, st === 'up' ? 20 + ((i * 7) % 40) : null, st === 'down' ? 'timeout' : null)
|
||||
}
|
||||
sqlite.prepare(`UPDATE vps SET last_health_status = ?, last_health_checked_at = ? WHERE id = ?`).run(status, now, vps[key])
|
||||
}
|
||||
|
||||
// --- notifications journal
|
||||
;[
|
||||
['payment_expiry', 'telegram', 'sent', 'Истекает оплата (ближайшие 7 дней):\n\n• monitor.example.net (Baltic VDS) — до ' + day(2)],
|
||||
['vps_down', 'telegram', 'sent', 'VPS недоступны:\n\n• vpn-de.example.net'],
|
||||
['low_balance', 'telegram', 'sent', 'Низкий баланс:\n\n• Aurora — проекты: 1120 RUB (порог 1500)'],
|
||||
['sync_digest', 'webhook', 'sent', 'Синхронизация VPS:\n\n✓ Nordhost — основной: изм. 1, платежи +1'],
|
||||
].forEach(([event, channel, status, message], i) =>
|
||||
sqlite
|
||||
.prepare(`INSERT INTO notification_log (id, spaceId, event, channel, status, fingerprint, message, payload, createdAt) VALUES (?, 'space-main', ?, ?, ?, '', ?, NULL, ?)`)
|
||||
.run(`nlog-demo-${i}`, event, channel, status, message, new Date(Date.now() - (i + 1) * 47 * 60_000).toISOString()),
|
||||
)
|
||||
|
||||
// --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API, with a week of history
|
||||
const HOURS_AGO = [166, 142, 118, 94, 70, 48, 30, 18, 9, 4, 0] // points on the snapshot timeline
|
||||
const CC_DPI = [
|
||||
'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',
|
||||
]
|
||||
const CC_GEO = [
|
||||
'spotify.com', 'netflix.com', 'patreon.com', 'swagger.io', 'snyk.io', 'mongodb.com', 'autodesk.com',
|
||||
'graylog.org', 'redis.io', 'copilot.microsoft.com',
|
||||
]
|
||||
const RU_DPI = new Set(['youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com', 'linkedin.com', 'rutracker.org', 'flibusta.is', 'rezka.ag', 'amnezia.org', 'getoutline.org'])
|
||||
/** HTTP code a probe sees: 200 ok, 403 geoblock, -1 DPI block, 0 timeout. step = index in HOURS_AGO. */
|
||||
function ccCode(key: string, service: string, step: number): number {
|
||||
if (key === 'mon') return RU_DPI.has(service) ? (service === 'youtube.com' && step < 4 ? 200 : -1) : CC_GEO.includes(service) && ['netflix.com', 'copilot.microsoft.com'].includes(service) ? 403 : 200
|
||||
if (key === 'vpn3') {
|
||||
if (['spotify.com', 'netflix.com', 'patreon.com', 'copilot.microsoft.com'].includes(service)) return 403
|
||||
if (['instagram.com', 'facebook.com'].includes(service) && step >= 6) return -1
|
||||
return 200
|
||||
}
|
||||
if (key === 'vpn2' && service === 'discord.com' && step % 3 === 1) return 0
|
||||
if (key === 'vpn1' && service === 'netflix.com') return 302
|
||||
return 200
|
||||
}
|
||||
const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` })
|
||||
const ipOf = (key: string) => vpsList.find((v) => v.key === key)!.ip as string
|
||||
const backdate = (table: string, runId: string, hoursAgo: number) => {
|
||||
const at = new Date(Date.now() - hoursAgo * 3600_000 - Math.floor(Math.random() * 20) * 60_000).toISOString()
|
||||
sqlite.prepare(`UPDATE ${table} SET createdAt = ?, completedAt = ? WHERE runId = ?`).run(at, at, runId)
|
||||
}
|
||||
let n = 0
|
||||
const uuid = (kind: number) => `00000000-0000-4000-${kind}000-${String(++n).padStart(12, '0')}`
|
||||
for (const key of ['web1', 'vpn1', 'vpn2', 'vpn3', 'mon', 'us1']) {
|
||||
for (const [step, hoursAgo] of HOURS_AGO.entries()) {
|
||||
const runId = uuid(8)
|
||||
await api('POST', '/api/integrations/censorcheck/runs', {
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
probe: { publicIp: ipOf(key) },
|
||||
launcherVersion: '1',
|
||||
censorcheck: { version: '12c5839', mode: 'both' },
|
||||
results: [...CC_DPI, ...CC_GEO].map((service) => ({
|
||||
service,
|
||||
raw: { https: { ipv4: { status: ccCode(key, service, step) } } },
|
||||
})),
|
||||
}, auth())
|
||||
backdate('censorcheck_runs', runId, hoursAgo)
|
||||
}
|
||||
}
|
||||
|
||||
// Keys from IPREGION_*_SERVICES so the matrix columns line up with the real vernette/ipregion list
|
||||
const geoServices: [string, string][] = [
|
||||
['maxmind.com', 'primary'], ['rdap.db.ripe.net', 'primary'], ['ipinfo.io', 'primary'], ['cloudflare.com', 'primary'],
|
||||
['ipregistry.co', 'primary'], ['ipapi.co', 'primary'], ['ifconfig.co', 'primary'], ['ip2location.io', 'primary'],
|
||||
['iplocation.com', 'primary'], ['country.is', 'primary'], ['ipwho.is', 'primary'], ['ip-api.com', 'primary'],
|
||||
['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'], ['spotify', 'custom'],
|
||||
['apple', 'custom'], ['steam', 'custom'], ['tiktok', 'custom'],
|
||||
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'], ['netflix cdn', 'cdn'],
|
||||
]
|
||||
const geo: Record<string, string> = { web1: 'NL', vpn1: 'FI', vpn2: 'DE', vpn3: 'KZ', mon: 'RU', us1: 'US' }
|
||||
for (const [key, cc] of Object.entries(geo)) {
|
||||
for (const [step, hoursAgo] of HOURS_AGO.entries()) {
|
||||
const runId = uuid(9)
|
||||
await api('POST', '/api/integrations/ipregion/runs', {
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
probe: { publicIp: ipOf(key) },
|
||||
launcherVersion: '1',
|
||||
ipregion: { version: '7d1c25c' },
|
||||
results: geoServices.map(([service, group], i) => ({
|
||||
service,
|
||||
group,
|
||||
// KZ node: some bases and services still place it in RU — the mismatch the matrix highlights
|
||||
ipv4:
|
||||
key === 'vpn3' && (i === 4 || i === 9 || i === 14 || (step >= 7 && i === 12))
|
||||
? 'RU'
|
||||
: key === 'vpn1' && service === 'youtube'
|
||||
? 'SE'
|
||||
: key === 'us1' && service === 'tiktok'
|
||||
? 'N/A'
|
||||
: cc,
|
||||
ipv6: key === 'web1' ? cc : null,
|
||||
})),
|
||||
}, auth())
|
||||
backdate('ipregion_runs', runId, hoursAgo)
|
||||
}
|
||||
}
|
||||
|
||||
// --- topology diagram
|
||||
const node = (id: string, x: number, y: number, key: string) => ({
|
||||
id,
|
||||
type: 'vps',
|
||||
position: { x, y },
|
||||
data: { vpsId: vps[key] },
|
||||
})
|
||||
const edge = (source: string, target: string, relation: string, extra: Record<string, unknown> = {}) => ({
|
||||
id: `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
sourceHandle: 'bottom',
|
||||
targetHandle: 'top',
|
||||
type: 'topology',
|
||||
data: { relation, direction: 'forward', lineStyle: 'solid', ...extra },
|
||||
})
|
||||
await api('POST', '/api/topology', {
|
||||
name: 'Production',
|
||||
document: {
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodes: [
|
||||
{ id: 'users', type: 'shape', position: { x: 470, y: 0 }, data: { kind: 'ellipse', label: 'Пользователи' } },
|
||||
node('us1', 470, 130, 'us1'),
|
||||
node('web1', 220, 290, 'web1'),
|
||||
node('web2', 720, 290, 'web2'),
|
||||
node('db1', 470, 460, 'db1'),
|
||||
node('mon', 970, 460, 'mon'),
|
||||
node('vpn1', 120, 640, 'vpn1'),
|
||||
node('vpn2', 470, 640, 'vpn2'),
|
||||
node('vpn3', 820, 640, 'vpn3'),
|
||||
{ id: 'note', type: 'note', position: { x: 1120, y: 640 }, data: { text: 'VPN-узлы для команды: WireGuard + Xray' } },
|
||||
],
|
||||
edges: [
|
||||
edge('users', 'us1', 'network', { label: 'HTTPS' }),
|
||||
edge('us1', 'web1', 'network'),
|
||||
edge('us1', 'web2', 'network'),
|
||||
edge('web1', 'db1', 'dependency', { label: 'PostgreSQL' }),
|
||||
edge('web2', 'db1', 'dependency'),
|
||||
{ ...edge('mon', 'db1', 'dependency', { lineStyle: 'dashed', label: 'метрики' }), sourceHandle: 'left', targetHandle: 'right' },
|
||||
{ ...edge('vpn1', 'vpn2', 'tunnel', { direction: 'bidirectional' }), sourceHandle: 'right', targetHandle: 'left' },
|
||||
{ ...edge('vpn2', 'vpn3', 'tunnel', { direction: 'bidirectional' }), sourceHandle: 'right', targetHandle: 'left' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// PUT /api/settings restarts the scheduler; its uptime checks against documentation IPs would flip
|
||||
// every monitored VPS to "down", so the demo runs without it.
|
||||
stopScheduler()
|
||||
console.log('demo seeded:', Object.keys(vps).length, 'VPS')
|
||||
if (process.argv.includes('--serve')) {
|
||||
const port = Number(process.env.PORT ?? 3101)
|
||||
await app.listen({ port, host: '127.0.0.1' })
|
||||
console.log(`demo UI: http://127.0.0.1:${port}/dashboard`)
|
||||
} else {
|
||||
await app.close()
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import cors from '@fastify/cors'
|
||||
import sensible from '@fastify/sensible'
|
||||
import staticPlugin from '@fastify/static'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -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'
|
||||
@@ -37,6 +38,11 @@ import { portalUsersRoutes } from './routes/portal-users.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** Версия из apps/api/package.json (src/ и dist/ лежат на одном уровне с ним). */
|
||||
export const APP_VERSION: string = (
|
||||
JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
).version
|
||||
|
||||
export interface BuildAppOptions {
|
||||
dbPath?: string
|
||||
staticDir?: string
|
||||
@@ -66,7 +72,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(authPlugin)
|
||||
await app.register(spacePlugin)
|
||||
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
app.get('/health', async () => ({ ok: true, version: APP_VERSION }))
|
||||
await app.register(launcherRoutes)
|
||||
|
||||
await app.register(spacesRoutes)
|
||||
@@ -89,6 +95,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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -154,10 +154,68 @@ describe('censorcheck ingest + reads', () => {
|
||||
await post(ingestPayload())
|
||||
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
|
||||
expect(list.statusCode).toBe(200)
|
||||
const items = list.json().items as Array<{ id: string }>
|
||||
const items = list.json().items as Array<{ id: string; results?: unknown[] }>
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.results).toHaveLength(1)
|
||||
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -26,6 +26,20 @@ describe('GET /cc launcher', () => {
|
||||
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')
|
||||
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__')
|
||||
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
|
||||
})
|
||||
@@ -35,5 +49,210 @@ describe('GET /cc launcher', () => {
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.body).toContain('#!/usr/bin/env bash')
|
||||
expect(res.body).toContain('SCRIPT_NAME')
|
||||
expect(res.body).toContain('is_installed()')
|
||||
expect(res.body).toContain('cat <<EOF')
|
||||
expect(res.body).toContain('JSON на stdout не ломаем')
|
||||
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/)
|
||||
})
|
||||
})
|
||||
+115
-22
@@ -15,6 +15,10 @@ export function censorcheckPublicUrl(env: NodeJS.ProcessEnv = process.env): stri
|
||||
return `https://${host}`
|
||||
}
|
||||
|
||||
function unixText(body: string): string {
|
||||
return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'public'): void {
|
||||
void reply
|
||||
.header('Content-Type', 'text/plain; charset=utf-8')
|
||||
@@ -22,7 +26,79 @@ function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'publi
|
||||
'Cache-Control',
|
||||
cache === 'no-store' ? 'no-store, no-cache, must-revalidate' : 'public, max-age=3600',
|
||||
)
|
||||
.send(body)
|
||||
.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) => {
|
||||
@@ -34,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'))
|
||||
})
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,18 @@ describe('projects routes', () => {
|
||||
expect(body[0]?.color).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('reuses a Cyrillic project regardless of case and suggests it', async () => {
|
||||
projectsRepository.create({ name: 'Сайт и API' })
|
||||
for (const project of ['Сайт и API', 'сайт и api']) {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/vps', payload: { ip: '10.0.0.1', project } })
|
||||
expect(res.statusCode).toBe(201)
|
||||
}
|
||||
const list = (await app.inject({ url: '/api/projects' })).json() as { name: string }[]
|
||||
expect(list.map((p) => p.name)).toEqual(['Сайт и API'])
|
||||
const sug = (await app.inject({ url: `/api/projects/suggest?q=${encodeURIComponent('сай')}` })).json() as { name: string }[]
|
||||
expect(sug.map((p) => p.name)).toEqual(['Сайт и API'])
|
||||
})
|
||||
|
||||
it('creates project with color and notes', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -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}` } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('/health', () => {
|
||||
beforeEach(() => resetTestDb())
|
||||
afterEach(() => closeDb())
|
||||
|
||||
it('returns the release version from package.json', async () => {
|
||||
const { version } = (await import('../../package.json', { with: { type: 'json' } })).default
|
||||
const app = await buildApp()
|
||||
const res = await app.inject({ url: '/health' })
|
||||
expect(res.json()).toEqual({ ok: true, version })
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 <1>: <html>502 & 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} & 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)
|
||||
})
|
||||
})
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('; ') || 'Не удалось отправить' }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cfdm/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,13 +18,15 @@
|
||||
"@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",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -37,6 +39,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"simple-icons": "^16.28.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { format, isWeekend } from "date-fns"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import {
|
||||
EMPTY_ENTRY,
|
||||
formatMinutes,
|
||||
type EntryKind,
|
||||
type ITimeEntry,
|
||||
type ITimesheetRow,
|
||||
} from "./data"
|
||||
import { TrendingUp, TrendingDown } from "lucide-react"
|
||||
|
||||
const entryToneClass: Record<EntryKind, string> = {
|
||||
billable: "bg-emerald-500",
|
||||
internal: "bg-sky-500",
|
||||
support: "bg-amber-500",
|
||||
leave: "bg-zinc-400",
|
||||
empty: "bg-transparent",
|
||||
}
|
||||
|
||||
const entryLabel: Record<EntryKind, string> = {
|
||||
billable: "Client",
|
||||
internal: "Internal",
|
||||
support: "Support",
|
||||
leave: "Leave",
|
||||
empty: "Open",
|
||||
}
|
||||
|
||||
function getDayHeaderDateLabel(day: Date) {
|
||||
return format(day, "EEE, MMM d")
|
||||
}
|
||||
|
||||
function getEntryHelperLabel(entry: ITimeEntry, weekend: boolean) {
|
||||
if (entry.minutes === 0) {
|
||||
return weekend ? "Off" : "Open"
|
||||
}
|
||||
|
||||
return entryLabel[entry.kind]
|
||||
}
|
||||
|
||||
function getEntryTooltipCopy(entry: ITimeEntry, weekend: boolean) {
|
||||
if (entry.minutes === 0) {
|
||||
return weekend
|
||||
? "No weekend hours were logged for this day."
|
||||
: "No time has been logged for this work day yet."
|
||||
}
|
||||
|
||||
return entry.note ?? `${entryLabel[entry.kind]} time entry.`
|
||||
}
|
||||
|
||||
function PersonCell({ row }: { row: ITimesheetRow }) {
|
||||
const { person } = row
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
{person.avatar ? (
|
||||
<AvatarImage src={person.avatar} alt={person.name} />
|
||||
) : null}
|
||||
<AvatarFallback>{person.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col leading-tight">
|
||||
<div className="text-foreground truncate text-sm font-medium">
|
||||
{person.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate pt-0.5 text-xs"
|
||||
title={person.role}
|
||||
>
|
||||
{person.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DayCell({
|
||||
entry,
|
||||
weekend,
|
||||
dayLabel,
|
||||
}: {
|
||||
entry: ITimeEntry
|
||||
weekend: boolean
|
||||
dayLabel: string
|
||||
}) {
|
||||
const minutes = entry.minutes
|
||||
const progress =
|
||||
minutes > 0 ? Math.max(18, Math.min(100, (minutes / 480) * 100)) : 0
|
||||
const helperLabel = getEntryHelperLabel(entry, weekend)
|
||||
const tooltipCopy = getEntryTooltipCopy(entry, weekend)
|
||||
const ariaLabel =
|
||||
minutes > 0
|
||||
? `${dayLabel}: ${formatMinutes(minutes)} logged as ${helperLabel.toLowerCase()}. ${tooltipCopy}`
|
||||
: `${dayLabel}: ${tooltipCopy}`
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/60 focus-visible:ring-ring focus-visible:ring-offset-background inline-flex w-full cursor-pointer justify-center rounded-md px-1.5 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex w-full min-w-[76px] flex-col items-center gap-1.5 text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm leading-none tabular-nums",
|
||||
minutes > 0
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground",
|
||||
weekend && minutes === 0 && "opacity-80"
|
||||
)}
|
||||
>
|
||||
{minutes > 0 ? formatMinutes(minutes) : "-"}
|
||||
</span>
|
||||
|
||||
<span className="bg-border block h-1 w-full max-w-[56px] overflow-hidden rounded-full">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full",
|
||||
entryToneClass[entry.kind]
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground text-[11px] leading-none">
|
||||
{helperLabel}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
{/* Content */}
|
||||
<TooltipContent side="top" className="max-w-[220px] p-3">
|
||||
<div className="flex flex-col gap-1.5 text-left">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{dayLabel}</span>
|
||||
<span className="text-xs tabular-nums opacity-80">
|
||||
{minutes > 0 ? formatMinutes(minutes) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed opacity-80">{tooltipCopy}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function TotalCell({ row }: { row: ITimesheetRow }) {
|
||||
const toneClass =
|
||||
row.utilizationState === "on-target"
|
||||
? "text-emerald-600"
|
||||
: row.utilizationState === "overtime"
|
||||
? "text-sky-600"
|
||||
: "text-amber-600"
|
||||
const isPositiveTrend = row.utilizationState !== "under-target"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<span className="text-foreground text-sm font-semibold tabular-nums">
|
||||
{formatMinutes(row.totalMinutes)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs tabular-nums",
|
||||
toneClass
|
||||
)}
|
||||
>
|
||||
{isPositiveTrend ? (
|
||||
<TrendingUp className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
{row.targetCoverage}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createTimesheetColumns({
|
||||
visibleDays,
|
||||
}: {
|
||||
visibleDays: Date[]
|
||||
}): ColumnDef<DataGridFeatures, ITimesheetRow>[] {
|
||||
const dayColumns: ColumnDef<DataGridFeatures, ITimesheetRow>[] =
|
||||
visibleDays.map((day) => {
|
||||
const dayKey = format(day, "yyyy-MM-dd")
|
||||
const weekend = isWeekend(day)
|
||||
const dateLabel = getDayHeaderDateLabel(day)
|
||||
|
||||
return {
|
||||
accessorFn: (row) => row.entries[dayKey]?.minutes ?? 0,
|
||||
id: dayKey,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title={dateLabel}
|
||||
column={column}
|
||||
className="w-full justify-center text-center text-xs font-normal"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DayCell
|
||||
entry={row.original.entries[dayKey] ?? EMPTY_ENTRY}
|
||||
weekend={weekend}
|
||||
dayLabel={format(day, "EEEE, MMM d")}
|
||||
/>
|
||||
),
|
||||
size: 112,
|
||||
enableSorting: false,
|
||||
enablePinning: false,
|
||||
meta: {
|
||||
headerClassName: "text-center!",
|
||||
cellClassName: "",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
accessorFn: (row) => row.person.name,
|
||||
id: "person",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="People" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <PersonCell row={row.original} />,
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enablePinning: false,
|
||||
},
|
||||
...dayColumns,
|
||||
{
|
||||
accessorFn: (row) => row.totalMinutes,
|
||||
id: "total",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Total"
|
||||
column={column}
|
||||
className="w-full justify-end text-right"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <TotalCell row={row.original} />,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
enablePinning: false,
|
||||
meta: {
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import {
|
||||
DataGrid,
|
||||
dataGridFeatures,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { useTable, type PaginationState } from "@tanstack/react-table"
|
||||
import {
|
||||
addWeeks,
|
||||
eachDayOfInterval,
|
||||
endOfWeek,
|
||||
format,
|
||||
parseISO,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { ButtonGroup } from "@cfdm/ui/components/button-group"
|
||||
import { Calendar } from "@cfdm/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@cfdm/ui/components/select"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import { createTimesheetColumns } from "./columns"
|
||||
import {
|
||||
BILLABLE_STATUS_OPTIONS,
|
||||
DEFAULT_WEEK_START,
|
||||
EMPTY_ENTRY,
|
||||
TEAM_OPTIONS,
|
||||
TIMESHEET_PEOPLE,
|
||||
TRACKED_TIME_OPTIONS,
|
||||
type BillableStatusFilter,
|
||||
type ITimesheetPerson,
|
||||
type ITimesheetRow,
|
||||
type TeamFilter,
|
||||
type TrackedTimeFilter,
|
||||
} from "./data"
|
||||
import { ChevronLeftIcon, CalendarIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function normalizeWeekStart(date: Date) {
|
||||
return startOfWeek(date, { weekStartsOn: 1 })
|
||||
}
|
||||
|
||||
function getDateKey(date: Date) {
|
||||
return format(date, "yyyy-MM-dd")
|
||||
}
|
||||
|
||||
function getTrackedTimeState(
|
||||
totalMinutes: number,
|
||||
targetMinutes: number
|
||||
): Exclude<TrackedTimeFilter, "any"> {
|
||||
if (targetMinutes <= 0) return "on-target"
|
||||
|
||||
const ratio = totalMinutes / targetMinutes
|
||||
|
||||
if (ratio > 1.08) return "overtime"
|
||||
if (ratio >= 0.85) return "on-target"
|
||||
return "under-target"
|
||||
}
|
||||
|
||||
function getBillableState(
|
||||
totalMinutes: number,
|
||||
billableMinutes: number
|
||||
): Exclude<BillableStatusFilter, "any"> {
|
||||
if (totalMinutes <= 0 || billableMinutes <= 0) return "internal-only"
|
||||
|
||||
const ratio = billableMinutes / totalMinutes
|
||||
return ratio >= 0.75 ? "mostly-billable" : "mixed"
|
||||
}
|
||||
|
||||
function createTimesheetRow(
|
||||
person: ITimesheetPerson,
|
||||
visibleDays: Date[]
|
||||
): ITimesheetRow {
|
||||
const entries = Object.fromEntries(
|
||||
visibleDays.map((day) => {
|
||||
const key = getDateKey(day)
|
||||
return [key, person.entries[key] ?? EMPTY_ENTRY]
|
||||
})
|
||||
)
|
||||
|
||||
const totalMinutes = Object.values(entries).reduce(
|
||||
(sum, entry) => sum + entry.minutes,
|
||||
0
|
||||
)
|
||||
|
||||
const billableMinutes = Object.values(entries).reduce(
|
||||
(sum, entry) => sum + (entry.kind === "billable" ? entry.minutes : 0),
|
||||
0
|
||||
)
|
||||
|
||||
const activeDays = Object.values(entries).filter(
|
||||
(entry) => entry.minutes > 0
|
||||
).length
|
||||
|
||||
const billablePercent =
|
||||
totalMinutes > 0 ? Math.round((billableMinutes / totalMinutes) * 100) : 0
|
||||
|
||||
const varianceMinutes = totalMinutes - person.weeklyTargetMinutes
|
||||
|
||||
const targetCoverage =
|
||||
person.weeklyTargetMinutes > 0
|
||||
? Math.round((totalMinutes / person.weeklyTargetMinutes) * 100)
|
||||
: 0
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
person,
|
||||
entries,
|
||||
totalMinutes,
|
||||
billableMinutes,
|
||||
billablePercent,
|
||||
activeDays,
|
||||
varianceMinutes,
|
||||
targetCoverage,
|
||||
utilizationState: getTrackedTimeState(
|
||||
totalMinutes,
|
||||
person.weeklyTargetMinutes
|
||||
),
|
||||
billableState: getBillableState(totalMinutes, billableMinutes),
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeLabel(days: Date[]) {
|
||||
if (days.length === 0) return ""
|
||||
|
||||
const start = days[0]
|
||||
const end = days[days.length - 1]
|
||||
|
||||
if (format(start, "MMM") === format(end, "MMM")) {
|
||||
return `${format(start, "MMM d")} - ${format(end, "d")}`
|
||||
}
|
||||
|
||||
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
||||
}
|
||||
|
||||
export function TimesheetGridView() {
|
||||
const [teamFilter, setTeamFilter] = useState<TeamFilter>("everyone")
|
||||
const [trackedTimeFilter, setTrackedTimeFilter] =
|
||||
useState<TrackedTimeFilter>("any")
|
||||
const [billableStatusFilter, setBillableStatusFilter] =
|
||||
useState<BillableStatusFilter>("any")
|
||||
const [weekStart, setWeekStart] = useState<Date>(
|
||||
normalizeWeekStart(parseISO(DEFAULT_WEEK_START))
|
||||
)
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const weekDays = useMemo(
|
||||
() =>
|
||||
eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: endOfWeek(weekStart, { weekStartsOn: 1 }),
|
||||
}),
|
||||
[weekStart]
|
||||
)
|
||||
|
||||
const visibleDays = weekDays
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
return TIMESHEET_PEOPLE.map((person) =>
|
||||
createTimesheetRow(person, visibleDays)
|
||||
)
|
||||
.filter((row) =>
|
||||
teamFilter === "everyone" ? true : row.person.team === teamFilter
|
||||
)
|
||||
.filter((row) =>
|
||||
trackedTimeFilter === "any"
|
||||
? true
|
||||
: row.utilizationState === trackedTimeFilter
|
||||
)
|
||||
.filter((row) =>
|
||||
billableStatusFilter === "any"
|
||||
? true
|
||||
: row.billableState === billableStatusFilter
|
||||
)
|
||||
}, [billableStatusFilter, teamFilter, trackedTimeFilter, visibleDays])
|
||||
|
||||
const columns = useMemo(
|
||||
() => createTimesheetColumns({ visibleDays }),
|
||||
[visibleDays]
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredRows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
})
|
||||
|
||||
const handleWeekPick = useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (!date) return
|
||||
setWeekStart(normalizeWeekStart(date))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const shiftWeek = useCallback(
|
||||
(delta: number) => {
|
||||
setWeekStart((current) => addWeeks(current, delta))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const emptyMessage = "No timesheets match the current filters."
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredRows.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{
|
||||
cellBorder: true,
|
||||
columnsPinnable: false,
|
||||
dense: true,
|
||||
}}
|
||||
// customize: edge cells borrow the frame's own px token so the first and
|
||||
// last columns line up with the FrameHeader instead of the 8px cell default
|
||||
tableClassNames={{
|
||||
edgeCell:
|
||||
"first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)",
|
||||
}}
|
||||
>
|
||||
<Frame dense className="w-full">
|
||||
<FrameHeader className="flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle className="text-balance">Timesheets</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">
|
||||
Weekly team timesheets
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="w-full sm:w-auto">
|
||||
New entry
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
{/* customize: px stays on the frame header token; py-2.5 gives the filter row more breathing room */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={teamFilter}
|
||||
onValueChange={(value) => {
|
||||
setTeamFilter(value as TeamFilter)
|
||||
resetPagination()
|
||||
}}
|
||||
items={TEAM_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TEAM_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={trackedTimeFilter}
|
||||
onValueChange={(value) => {
|
||||
setTrackedTimeFilter(value as TrackedTimeFilter)
|
||||
resetPagination()
|
||||
}}
|
||||
items={TRACKED_TIME_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TRACKED_TIME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={billableStatusFilter}
|
||||
onValueChange={(value) => {
|
||||
setBillableStatusFilter(value as BillableStatusFilter)
|
||||
resetPagination()
|
||||
}}
|
||||
items={BILLABLE_STATUS_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{BILLABLE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Previous week"
|
||||
onClick={() => shiftWeek(-1)}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-[168px] justify-between font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatRangeLabel(visibleDays)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={weekStart}
|
||||
onSelect={handleWeekPick}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Next week"
|
||||
onClick={() => shiftWeek(1)}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { addDays, format, parseISO } from "date-fns"
|
||||
|
||||
export type TeamFilter =
|
||||
| "everyone"
|
||||
| "client-delivery"
|
||||
| "product"
|
||||
| "operations"
|
||||
| "support"
|
||||
|
||||
export type TrackedTimeFilter =
|
||||
| "any"
|
||||
| "under-target"
|
||||
| "on-target"
|
||||
| "overtime"
|
||||
|
||||
export type BillableStatusFilter =
|
||||
| "any"
|
||||
| "mostly-billable"
|
||||
| "mixed"
|
||||
| "internal-only"
|
||||
|
||||
export type EntryKind = "billable" | "internal" | "support" | "leave" | "empty"
|
||||
|
||||
export interface ITimeEntry {
|
||||
minutes: number
|
||||
kind: EntryKind
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface ITimesheetPerson {
|
||||
id: string
|
||||
name: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
role: string
|
||||
team: Exclude<TeamFilter, "everyone">
|
||||
teamLabel: string
|
||||
weeklyTargetMinutes: number
|
||||
entries: Record<string, ITimeEntry>
|
||||
}
|
||||
|
||||
export interface ITimesheetRow {
|
||||
id: string
|
||||
person: ITimesheetPerson
|
||||
entries: Record<string, ITimeEntry>
|
||||
totalMinutes: number
|
||||
billableMinutes: number
|
||||
billablePercent: number
|
||||
activeDays: number
|
||||
varianceMinutes: number
|
||||
targetCoverage: number
|
||||
utilizationState: Exclude<TrackedTimeFilter, "any">
|
||||
billableState: Exclude<BillableStatusFilter, "any">
|
||||
}
|
||||
|
||||
export const TEAM_OPTIONS: { value: TeamFilter; label: string }[] = [
|
||||
{ value: "everyone", label: "People" },
|
||||
{ value: "client-delivery", label: "Client delivery" },
|
||||
{ value: "product", label: "Product" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "support", label: "Support" },
|
||||
]
|
||||
|
||||
export const TRACKED_TIME_OPTIONS: {
|
||||
value: TrackedTimeFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Tracked time" },
|
||||
{ value: "under-target", label: "Under target" },
|
||||
{ value: "on-target", label: "On target" },
|
||||
{ value: "overtime", label: "Over target" },
|
||||
]
|
||||
|
||||
export const BILLABLE_STATUS_OPTIONS: {
|
||||
value: BillableStatusFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Billable status" },
|
||||
{ value: "mostly-billable", label: "Mostly billable" },
|
||||
{ value: "mixed", label: "Mixed" },
|
||||
{ value: "internal-only", label: "Internal only" },
|
||||
]
|
||||
|
||||
export const DEFAULT_WEEK_START = "2026-03-23"
|
||||
|
||||
export const EMPTY_ENTRY: ITimeEntry = {
|
||||
minutes: 0,
|
||||
kind: "empty",
|
||||
}
|
||||
|
||||
function hours(
|
||||
value: number,
|
||||
kind: EntryKind = "billable",
|
||||
note?: string
|
||||
): ITimeEntry {
|
||||
return {
|
||||
minutes: Math.round(value * 60),
|
||||
kind,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
function buildWeekEntries(weekStart: string, items: ITimeEntry[]) {
|
||||
const startDate = parseISO(weekStart)
|
||||
|
||||
return Object.fromEntries(
|
||||
items.map((item, index) => [
|
||||
format(addDays(startDate, index), "yyyy-MM-dd"),
|
||||
item,
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function mergeEntries(...weeks: Array<Record<string, ITimeEntry>>) {
|
||||
return Object.assign({}, ...weeks)
|
||||
}
|
||||
|
||||
export function formatMinutes(minutes: number) {
|
||||
if (minutes <= 0) return "0h"
|
||||
|
||||
const hoursPart = Math.floor(minutes / 60)
|
||||
const minutesPart = minutes % 60
|
||||
|
||||
if (minutesPart === 0) return `${hoursPart}h`
|
||||
|
||||
return `${hoursPart}h ${minutesPart}m`
|
||||
}
|
||||
|
||||
export const TIMESHEET_PEOPLE: ITimesheetPerson[] = [
|
||||
{
|
||||
id: "amara-ortiz",
|
||||
name: "Amara Ortiz",
|
||||
initials: "AO",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Client delivery lead",
|
||||
team: "client-delivery",
|
||||
teamLabel: "Client delivery",
|
||||
weeklyTargetMinutes: 32 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(6, "billable", "Sprint planning with the Aurora account"),
|
||||
hours(6.5, "billable", "Client workshop and follow-up notes"),
|
||||
hours(5, "billable", "Delivery handoff edits"),
|
||||
hours(6, "billable", "Roadmap review with success team"),
|
||||
hours(4, "billable", "Executive recap deck"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(6.5, "billable", "Migration kickoff with Atlas Health"),
|
||||
hours(5.5, "internal", "Weekly staffing and margin review"),
|
||||
hours(7, "billable", "Pilot implementation sync"),
|
||||
hours(6, "billable", "Partner escalation planning"),
|
||||
hours(4.5, "billable", "Renewal prep"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(7, "billable", "Client leadership review"),
|
||||
hours(6.5, "billable", "Design QA handoff"),
|
||||
hours(6.5, "billable", "Retention plan workshop"),
|
||||
hours(6, "billable", "Launch review"),
|
||||
hours(5, "billable", "Weekly closeout"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "theo-mercer",
|
||||
name: "Theo Mercer",
|
||||
initials: "TM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Platform engineer",
|
||||
team: "product",
|
||||
teamLabel: "Product",
|
||||
weeklyTargetMinutes: 40 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(8, "billable", "Registry licensing rollout"),
|
||||
hours(8, "billable", "Private install token support"),
|
||||
hours(7.5, "internal", "Infra refactor"),
|
||||
hours(8, "billable", "Partner sandbox fixes"),
|
||||
hours(6.5, "billable", "Observability cleanup"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(8, "billable", "Gateway failover validation"),
|
||||
hours(8, "billable", "Webhook retries"),
|
||||
hours(7.5, "billable", "Tenant sync fixes"),
|
||||
hours(8, "billable", "SSO edge cases"),
|
||||
hours(8, "billable", "Metrics rollout"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(8, "billable", "Multi-region smoke tests"),
|
||||
hours(8, "billable", "Registry cache hardening"),
|
||||
hours(8, "billable", "Usage ledger fixes"),
|
||||
hours(7.5, "billable", "Provisioning automation"),
|
||||
hours(8, "billable", "Ops docs"),
|
||||
EMPTY_ENTRY,
|
||||
hours(2, "internal", "Weekend incident follow-up"),
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lena-hoffman",
|
||||
name: "Lena Hoffman",
|
||||
initials: "LH",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
role: "Content systems editor",
|
||||
team: "operations",
|
||||
teamLabel: "Operations",
|
||||
weeklyTargetMinutes: 30 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(4, "internal", "Pattern taxonomy review"),
|
||||
hours(4.5, "internal", "Docs cleanup"),
|
||||
hours(5, "billable", "Client knowledge base rewrite"),
|
||||
hours(5, "internal", "Release notes"),
|
||||
hours(4, "billable", "Customer enablement assets"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(5, "internal", "Publishing QA"),
|
||||
hours(4.5, "billable", "Implementation guide updates"),
|
||||
hours(5, "internal", "Docs audit"),
|
||||
hours(3.5, "billable", "Admin walkthrough copy"),
|
||||
hours(4, "billable", "Email setup checklist"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(6, "billable", "Playbook rewrite"),
|
||||
hours(5.5, "internal", "Navigation audit"),
|
||||
hours(5, "internal", "New registry docs"),
|
||||
hours(4.5, "billable", "Workspace onboarding copy"),
|
||||
hours(4, "billable", "Support macros"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "iris-calder",
|
||||
name: "Iris Calder",
|
||||
initials: "IC",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
role: "Design systems lead",
|
||||
team: "product",
|
||||
teamLabel: "Product",
|
||||
weeklyTargetMinutes: 32 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(6, "billable", "Dashboard refinements"),
|
||||
hours(6, "billable", "Billing table polish"),
|
||||
hours(6.5, "billable", "Settings review"),
|
||||
hours(5.5, "internal", "Library maintenance"),
|
||||
hours(4, "billable", "Handoff QA"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(6, "billable", "Timesheet explorations"),
|
||||
hours(6.5, "billable", "Usage analytics QA"),
|
||||
hours(6, "internal", "Pattern inventory"),
|
||||
hours(6.5, "billable", "Workspace theming"),
|
||||
hours(4, "billable", "Review fixes"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(6.5, "billable", "Sidebar migration"),
|
||||
hours(7, "billable", "Table density pass"),
|
||||
hours(6.5, "billable", "Forms package polish"),
|
||||
hours(6.5, "billable", "Release candidate QA"),
|
||||
hours(4.5, "billable", "Documentation review"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "samir-vale",
|
||||
name: "Samir Vale",
|
||||
initials: "SV",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
role: "Partner success manager",
|
||||
team: "support",
|
||||
teamLabel: "Support",
|
||||
weeklyTargetMinutes: 35 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(7, "support", "High-touch client office hours"),
|
||||
hours(7, "support", "Renewal planning"),
|
||||
hours(6, "billable", "Implementation steering"),
|
||||
hours(7, "support", "Escalation review"),
|
||||
hours(6.5, "billable", "Adoption workshop"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(7, "support", "Go-live triage"),
|
||||
hours(7.5, "support", "Partner QBR prep"),
|
||||
hours(6.5, "billable", "Migration playbook review"),
|
||||
hours(8, "billable", "Expansion scoping"),
|
||||
hours(7, "support", "Weekly care plan"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(8, "support", "Enterprise rollout watch"),
|
||||
hours(7.5, "support", "Escalation retro"),
|
||||
hours(7, "billable", "Success handoff"),
|
||||
hours(7.5, "billable", "Client roadmap recap"),
|
||||
hours(7, "support", "Support coverage"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "nina-flores",
|
||||
name: "Nina Flores",
|
||||
initials: "NF",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
||||
role: "Launch producer",
|
||||
team: "client-delivery",
|
||||
teamLabel: "Client delivery",
|
||||
weeklyTargetMinutes: 28 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(5, "billable", "Client kickoff logistics"),
|
||||
hours(4.5, "billable", "Launch checklist review"),
|
||||
hours(5, "billable", "Support routing"),
|
||||
hours(4.5, "internal", "Team planning"),
|
||||
hours(3.5, "billable", "Go-live notes"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(5, "billable", "Workspace provisioning"),
|
||||
hours(5.5, "billable", "Rollout communications"),
|
||||
hours(5.5, "billable", "Checklist QA"),
|
||||
hours(4, "internal", "Process retro"),
|
||||
hours(4, "billable", "Launch follow-up"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(5.5, "billable", "Calendar + onboarding flow"),
|
||||
hours(5.5, "billable", "Billing handoff"),
|
||||
hours(6, "billable", "Activation reporting"),
|
||||
hours(4.5, "internal", "Ops planning"),
|
||||
hours(4, "billable", "Weekly close"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "owen-hart",
|
||||
name: "Owen Hart",
|
||||
initials: "OH",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1504593811423-6dd665756598?w=96&h=96&dpr=2&q=80",
|
||||
role: "QA automation engineer",
|
||||
team: "product",
|
||||
teamLabel: "Product",
|
||||
weeklyTargetMinutes: 40 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(8, "billable", "Regression pack"),
|
||||
hours(8, "billable", "Smoke tests"),
|
||||
hours(7, "billable", "Accessibility fixes"),
|
||||
hours(7.5, "support", "Release support"),
|
||||
hours(6, "billable", "Cross-browser QA"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(8, "billable", "Release candidate QA"),
|
||||
hours(8, "billable", "Import workflow pass"),
|
||||
hours(8, "billable", "Nested table checks"),
|
||||
hours(6, "support", "Launch support"),
|
||||
hours(5, "billable", "Timesheet regressions"),
|
||||
hours(2, "support", "Weekend support"),
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(8, "billable", "Billing QA"),
|
||||
hours(8, "billable", "Seat management checks"),
|
||||
hours(7.5, "billable", "Advanced filters"),
|
||||
hours(8, "billable", "Regression triage"),
|
||||
hours(7, "billable", "Accessibility sweep"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cleo-warner",
|
||||
name: "Cleo Warner",
|
||||
initials: "CW",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
role: "Brand editor",
|
||||
team: "operations",
|
||||
teamLabel: "Operations",
|
||||
weeklyTargetMinutes: 26 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(4, "internal", "Voice and tone QA"),
|
||||
hours(4.5, "internal", "Landing copy reviews"),
|
||||
hours(4, "internal", "Release notes"),
|
||||
hours(3.5, "billable", "Case study edits"),
|
||||
hours(4, "billable", "Partner launch copy"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(4.5, "internal", "Meta description pass"),
|
||||
hours(5, "internal", "Copy QA"),
|
||||
hours(4, "billable", "Customer stories"),
|
||||
hours(3.5, "billable", "Support snippets"),
|
||||
hours(4, "internal", "Release copy"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(5, "internal", "Brand polish"),
|
||||
hours(5, "billable", "Launch assets"),
|
||||
hours(4.5, "internal", "Publishing queue"),
|
||||
hours(4, "billable", "Email copy"),
|
||||
hours(4, "billable", "Closeout edits"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "jules-park",
|
||||
name: "Jules Park",
|
||||
initials: "JP",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
|
||||
role: "Revenue operations analyst",
|
||||
team: "operations",
|
||||
teamLabel: "Operations",
|
||||
weeklyTargetMinutes: 20 * 60,
|
||||
entries: mergeEntries(
|
||||
buildWeekEntries("2026-03-16", [
|
||||
hours(3.5, "internal", "Forecast model updates"),
|
||||
hours(4, "billable", "Pricing QA"),
|
||||
hours(4, "internal", "Usage audit"),
|
||||
hours(3, "billable", "Renewal scorecard"),
|
||||
hours(3, "internal", "Pipeline review"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-23", [
|
||||
hours(4, "internal", "ARR reconciliation"),
|
||||
hours(4.5, "billable", "Expansion model"),
|
||||
hours(3.5, "billable", "Usage reporting"),
|
||||
hours(3, "internal", "Margin review"),
|
||||
hours(3, "billable", "Team closeout"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
]),
|
||||
buildWeekEntries("2026-03-30", [
|
||||
hours(4, "internal", "Pipeline hygiene"),
|
||||
hours(4.5, "billable", "Budget forecast"),
|
||||
hours(4, "internal", "Seat utilization audit"),
|
||||
hours(3.5, "billable", "MRR roll-up"),
|
||||
hours(3, "internal", "Planning prep"),
|
||||
EMPTY_ENTRY,
|
||||
EMPTY_ENTRY,
|
||||
])
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TimesheetGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Timesheet data grid
|
||||
</h1>
|
||||
<TimesheetGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
import { filterCensorcheckRuns, groupRunsByService, collectServiceColumns, collectProbeColumns, shortHostLabel } from './blocking-filters'
|
||||
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', () => {
|
||||
@@ -95,5 +126,50 @@ describe('groupRunsByService', () => {
|
||||
const groups = groupRunsByService([run()])
|
||||
expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com'])
|
||||
expect(groups[1]?.probes[0]?.status).toBe('blocked')
|
||||
expect(groups[1]?.probes[0]?.httpStatus).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectServiceColumns', () => {
|
||||
it('ставит канонические сервисы первыми и custom в конец', () => {
|
||||
const cols = collectServiceColumns([
|
||||
run({
|
||||
results: [
|
||||
...(run().results ?? []),
|
||||
{
|
||||
id: 'r3',
|
||||
runId: 'ccrun-1',
|
||||
serviceKey: 'custom.example',
|
||||
serviceLabel: 'custom.example',
|
||||
category: 'custom',
|
||||
status: 'available',
|
||||
httpStatus: 200,
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
expect(cols[0]?.key).toBe('youtube.com')
|
||||
expect(cols.map((c) => c.key)).toContain('netflix.com')
|
||||
expect(cols.at(-1)?.key).toBe('custom.example')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectProbeColumns', () => {
|
||||
it('берёт dns как короткий header', () => {
|
||||
expect(collectProbeColumns([run()])[0]).toMatchObject({
|
||||
key: 'ccrun-1',
|
||||
label: 'edge.example',
|
||||
title: 'edge.example.com',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortHostLabel', () => {
|
||||
it('отрезает типичный TLD', () => {
|
||||
expect(shortHostLabel('youtube.com')).toBe('youtube')
|
||||
expect(shortHostLabel('api.telegram.org')).toBe('telegram')
|
||||
expect(shortHostLabel('redirector.googlevideo.com')).toBe('googlevideo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { getActiveFilters } from '@/components/reui-kit'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { runSearchText, type CensorcheckRunDto } from './types'
|
||||
import {
|
||||
CENSORCHECK_DPI_HOSTS,
|
||||
CENSORCHECK_GEOBLOCK_HOSTS,
|
||||
inferCensorcheckCategory,
|
||||
} from '@cfdm/shared/contracts/censorcheck'
|
||||
import {
|
||||
runSearchText,
|
||||
type CensorcheckResultDto,
|
||||
type CensorcheckRunDto,
|
||||
} from './types'
|
||||
|
||||
export function filterCensorcheckRuns(
|
||||
runs: CensorcheckRunDto[],
|
||||
@@ -30,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
|
||||
@@ -68,6 +77,7 @@ export type BlockingServiceRow = {
|
||||
dns: string
|
||||
country: string
|
||||
status: string
|
||||
httpStatus: number | null
|
||||
createdAt: string
|
||||
vpsId: string | null
|
||||
}>
|
||||
@@ -85,6 +95,7 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo
|
||||
dns: run.vps?.dns ?? '',
|
||||
country: run.vps?.country ?? '',
|
||||
status: result.status,
|
||||
httpStatus: result.httpStatus,
|
||||
createdAt: run.createdAt,
|
||||
vpsId: run.matchedVpsId,
|
||||
}
|
||||
@@ -103,3 +114,76 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey))
|
||||
}
|
||||
|
||||
export type MatrixColumn = {
|
||||
key: string
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Короткие подписи колонок матрицы: полный хост остаётся в подсказке и карточке прогона. */
|
||||
const HOST_ALIASES: Record<string, string> = {
|
||||
'redirector.googlevideo.com': 'googlevideo',
|
||||
'api.telegram.org': 'telegram',
|
||||
'play.google.com': 'google play',
|
||||
'copilot.microsoft.com': 'copilot',
|
||||
}
|
||||
|
||||
export function shortHostLabel(value: string): string {
|
||||
const host = (value.trim().split('/')[0] ?? value).toLowerCase()
|
||||
return HOST_ALIASES[host] ?? host.replace(/\.(com|org|net|io|ag|is)$/i, '')
|
||||
}
|
||||
|
||||
const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
|
||||
|
||||
export function collectServiceColumns(runs: CensorcheckRunDto[]): MatrixColumn[] {
|
||||
const canonicalSet = new Set<string>(CANONICAL_SERVICES)
|
||||
const extras = new Set<string>()
|
||||
for (const run of runs) {
|
||||
for (const result of run.results ?? []) {
|
||||
if (!canonicalSet.has(result.serviceKey)) extras.add(result.serviceKey)
|
||||
}
|
||||
}
|
||||
const keys = [
|
||||
...CANONICAL_SERVICES,
|
||||
...[...extras].sort((a, b) => a.localeCompare(b)),
|
||||
]
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
label: shortHostLabel(key),
|
||||
title: key,
|
||||
}))
|
||||
}
|
||||
|
||||
export function collectProbeColumns(runs: CensorcheckRunDto[]): MatrixColumn[] {
|
||||
return runs.map((run) => {
|
||||
const title = run.vps?.dns || run.probePublicIp
|
||||
return {
|
||||
key: run.id,
|
||||
label: shortHostLabel(title),
|
||||
title,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resultByService(
|
||||
run: CensorcheckRunDto,
|
||||
serviceKey: string,
|
||||
): CensorcheckResultDto | undefined {
|
||||
return (run.results ?? []).find((row) => row.serviceKey === serviceKey)
|
||||
}
|
||||
|
||||
export function serviceMatrixRows(runs: CensorcheckRunDto[]): BlockingServiceRow[] {
|
||||
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.key,
|
||||
category: inferCensorcheckCategory(col.key),
|
||||
probes: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,78 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import { ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
import {
|
||||
CENSORCHECK_STATUS_LABELS,
|
||||
formatCheckedAt,
|
||||
formatVpsResources,
|
||||
type CensorcheckRunDto,
|
||||
} from './types'
|
||||
import type { BlockingServiceRow } from './blocking-filters'
|
||||
collectProbeColumns,
|
||||
collectServiceColumns,
|
||||
resultByService,
|
||||
type BlockingServiceRow,
|
||||
} from './blocking-filters'
|
||||
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
|
||||
import { StatusMatrixCell } from './status-matrix-cell'
|
||||
import { runHosterLabel, type CensorcheckRunDto } from './types'
|
||||
|
||||
function SummaryBadges({ run }: { run: CensorcheckRunDto }) {
|
||||
const { summary } = run
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{summary.available > 0 ? (
|
||||
<Badge variant="success" size="sm">{summary.available} ок</Badge>
|
||||
) : null}
|
||||
{summary.blocked > 0 ? (
|
||||
<Badge variant="destructive" size="sm">{summary.blocked} блок</Badge>
|
||||
) : null}
|
||||
{summary.denied > 0 ? (
|
||||
<Badge variant="destructive" size="sm">{summary.denied} отказ</Badge>
|
||||
) : null}
|
||||
{summary.timeout > 0 ? (
|
||||
<Badge variant="warning" size="sm">{summary.timeout} timeout</Badge>
|
||||
) : null}
|
||||
{summary.error > 0 ? (
|
||||
<Badge variant="outline" size="sm">{summary.error} err</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
/** 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 = {
|
||||
tableWidth: 'auto' as const,
|
||||
horizontalScroll: true,
|
||||
}
|
||||
|
||||
function NestedList({
|
||||
rows,
|
||||
}: {
|
||||
rows: Array<{ key: string; primary: string; secondary?: string; status: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/30 flex flex-col gap-1 px-4 py-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{row.primary}</span>
|
||||
{row.secondary ? (
|
||||
<span className="text-muted-foreground truncate text-xs">{row.secondary}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={row.status}
|
||||
label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
|
||||
|
||||
const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
|
||||
{
|
||||
function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
|
||||
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"
|
||||
@@ -85,133 +53,191 @@ const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
|
||||
) : (
|
||||
<span className="font-medium">Unknown VPS</span>
|
||||
)
|
||||
return dataGridCellStack(link, ip)
|
||||
return dataGridCellWithIcon(
|
||||
<CountryFlag country={row.vps?.country} className="size-5" />,
|
||||
dataGridCellStack(link, secondary),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dns',
|
||||
header: 'DNS',
|
||||
icon: GlobeIcon,
|
||||
sortValue: (row) => row.vps?.dns ?? '',
|
||||
cell: (row) => row.vps?.dns || '—',
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
header: 'Хостер',
|
||||
sortValue: (row) => row.vps?.providerName ?? '',
|
||||
cell: (row) => row.vps?.providerName || '—',
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
header: 'Страна',
|
||||
icon: MapPinIcon,
|
||||
sortValue: (row) => row.vps?.country ?? '',
|
||||
cell: (row) =>
|
||||
row.vps?.country
|
||||
? dataGridCellWithFlag(<CountryFlag country={row.vps.country} />, row.vps.country)
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'resources',
|
||||
header: 'Ресурсы',
|
||||
sortValue: (row) => row.vps?.vcpu ?? 0,
|
||||
cell: (row) =>
|
||||
row.vps
|
||||
? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb)
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
header: 'Сводка',
|
||||
cell: (row) => <SummaryBadges run={row} />,
|
||||
},
|
||||
{
|
||||
key: 'checked',
|
||||
header: 'Проверено',
|
||||
sortValue: (row) => row.createdAt,
|
||||
cell: (row) => formatCheckedAt(row.createdAt),
|
||||
},
|
||||
]
|
||||
|
||||
const serviceColumns: DataGridColumn<BlockingServiceRow>[] = [
|
||||
{
|
||||
key: 'service',
|
||||
header: 'Сервис',
|
||||
icon: ShieldAlertIcon,
|
||||
sortValue: (row) => row.serviceKey,
|
||||
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
|
||||
},
|
||||
{
|
||||
key: 'probes',
|
||||
header: 'Пробы',
|
||||
sortValue: (row) => row.probes.length,
|
||||
sortingFn: 'basic',
|
||||
cell: (row) => row.probes.length,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function BlockingVpsGrid({
|
||||
runs,
|
||||
onRowClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
runs: CensorcheckRunDto[]
|
||||
onRowClick: (run: CensorcheckRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||
const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => {
|
||||
return [
|
||||
vpsIdentityColumn(),
|
||||
{
|
||||
key: 'availability',
|
||||
header: 'Доступно',
|
||||
size: 150,
|
||||
minSize: 130,
|
||||
sortValue: (row) => (row.summary.total ? row.summary.available / row.summary.total : 0),
|
||||
sortingFn: 'basic',
|
||||
cell: (row) => {
|
||||
const { available, total, blocked } = row.summary
|
||||
const pct = total ? Math.round((available / total) * 100) : 0
|
||||
// Литералы целиком — Tailwind не видит классы, собранные шаблонной строкой.
|
||||
const bar =
|
||||
blocked > 0
|
||||
? '**:data-[slot=progress-indicator]:bg-destructive'
|
||||
: pct < 100
|
||||
? '**:data-[slot=progress-indicator]:bg-warning'
|
||||
: '**:data-[slot=progress-indicator]:bg-success'
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{available}
|
||||
<span className="text-muted-foreground text-xs font-normal"> из {total}</span>
|
||||
</span>
|
||||
<Progress
|
||||
value={pct}
|
||||
aria-label={`Доступно ${available} из ${total}`}
|
||||
className={bar}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
...serviceCols.map(
|
||||
(svc): DataGridColumn<CensorcheckRunDto> => ({
|
||||
key: `svc:${svc.key}`,
|
||||
header: svc.label,
|
||||
headerTitle: svc.label,
|
||||
icon: resolveServiceIcon(svc.key),
|
||||
className: MATRIX_CELL,
|
||||
headerClassName: MATRIX_CELL,
|
||||
size: 72,
|
||||
minSize: 64,
|
||||
sortable: true,
|
||||
sortValue: (row) => resultByService(row, svc.key)?.status ?? '',
|
||||
cell: (row) => {
|
||||
const item = resultByService(row, svc.key)
|
||||
return (
|
||||
<StatusMatrixCell
|
||||
status={item?.status}
|
||||
serviceLabel={svc.title}
|
||||
vpsLabel={row.vps?.dns || row.probePublicIp}
|
||||
httpStatus={item?.httpStatus}
|
||||
checkedAt={row.createdAt}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [serviceCols])
|
||||
|
||||
return (
|
||||
<ExpandableResourceGrid
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
<FrameDataGrid
|
||||
title="Текущие пробы"
|
||||
description="Доступность сервисов с каждого VPS"
|
||||
toolbar={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={runs}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={runs.length > 10}
|
||||
pinLeftColumnIds={['vps']}
|
||||
{...BLOCKING_MATRIX_GRID}
|
||||
emptyTitle="Нет проверок"
|
||||
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
|
||||
emptyAction={emptyAction}
|
||||
onRowClick={onRowClick}
|
||||
getRowCanExpand={(row) => (row.results?.length ?? 0) > 0}
|
||||
expandedContent={(row) => (
|
||||
<NestedList
|
||||
rows={(row.results ?? []).map((item) => ({
|
||||
key: item.id,
|
||||
primary: item.serviceLabel,
|
||||
secondary: item.category,
|
||||
status: item.status,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlockingServiceGrid({
|
||||
groups,
|
||||
runs,
|
||||
onProbeClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
groups: BlockingServiceRow[]
|
||||
runs: CensorcheckRunDto[]
|
||||
onProbeClick: (run: CensorcheckRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
||||
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
||||
|
||||
const columns = useMemo((): DataGridColumn<BlockingServiceRow>[] => {
|
||||
return [
|
||||
{
|
||||
key: 'service',
|
||||
header: 'Сервис',
|
||||
headerTitle: 'Сервис',
|
||||
icon: ShieldAlertIcon,
|
||||
enableHiding: false,
|
||||
enablePinning: true,
|
||||
size: 180,
|
||||
minSize: 140,
|
||||
sortValue: (row) => row.serviceKey,
|
||||
cell: (row) =>
|
||||
dataGridCellWithIcon(
|
||||
<ServiceGlyph serviceKey={row.serviceKey} />,
|
||||
dataGridCellStack(row.serviceLabel, row.category),
|
||||
),
|
||||
},
|
||||
...probeCols.map(
|
||||
(probe): DataGridColumn<BlockingServiceRow> => ({
|
||||
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)?.status ?? '',
|
||||
cell: (row) => {
|
||||
const item = row.probes.find((probeRow) => probeRow.runId === probe.key)
|
||||
const run = runById.get(probe.key)
|
||||
return (
|
||||
<StatusMatrixCell
|
||||
status={item?.status}
|
||||
serviceLabel={row.serviceKey}
|
||||
vpsLabel={probe.title}
|
||||
httpStatus={item?.httpStatus}
|
||||
checkedAt={item?.createdAt}
|
||||
onSelect={run ? () => onProbeClick(run) : undefined}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [onProbeClick, probeCols, runById])
|
||||
|
||||
return (
|
||||
<ExpandableResourceGrid
|
||||
columns={columnDefFromDataGrid(serviceColumns)}
|
||||
<FrameDataGrid
|
||||
title="Текущие пробы"
|
||||
description="Доступность сервисов с каждого VPS"
|
||||
toolbar={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={groups.length > 10}
|
||||
pinLeftColumnIds={['service']}
|
||||
{...BLOCKING_MATRIX_GRID}
|
||||
emptyTitle="Нет сервисов"
|
||||
emptyAction={emptyAction}
|
||||
getRowCanExpand={(row) => row.probes.length > 0}
|
||||
expandedContent={(row) => (
|
||||
<NestedList
|
||||
rows={row.probes.map((probe) => ({
|
||||
key: `${probe.runId}-${probe.probePublicIp}`,
|
||||
primary: probe.dns || probe.probePublicIp,
|
||||
secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`,
|
||||
status: probe.status,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -26,11 +28,21 @@ import {
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
|
||||
import { BlockingSnapshotScrubber } from './blocking-snapshot-scrubber'
|
||||
import {
|
||||
collectSnapshotTicks,
|
||||
latestRunsAsOf,
|
||||
mergeCensorcheckRuns,
|
||||
resolveSnapshotIndex,
|
||||
} from './blocking-snapshots'
|
||||
import { CheckRunSheet } from './check-run-sheet'
|
||||
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
|
||||
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'
|
||||
@@ -110,36 +122,110 @@ export function BlockingPage() {
|
||||
const [group, setGroup] = useState<GroupMode>('vps')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
|
||||
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(null)
|
||||
|
||||
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
|
||||
const historyQuery = useQuery({
|
||||
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
|
||||
enabled: tab === 'history',
|
||||
})
|
||||
const historyQuery = useQuery(censorcheckHistoryQueryOptions({ limit: 200 }, spaceId))
|
||||
|
||||
const runs = currentQuery.data?.items ?? []
|
||||
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
|
||||
const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered])
|
||||
const allRuns = useMemo(
|
||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||
[currentQuery.data?.items, historyQuery.data?.items],
|
||||
)
|
||||
const ticks = useMemo(
|
||||
() =>
|
||||
collectSnapshotTicks(allRuns, (run) => {
|
||||
const s = run.summary
|
||||
if (s.blocked > 0) return 'er'
|
||||
return s.denied + s.timeout + s.error > 0 ? 'wn' : 'ok'
|
||||
}),
|
||||
[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(
|
||||
() => filterCensorcheckRuns(snapshotRuns, filters),
|
||||
[snapshotRuns, filters],
|
||||
)
|
||||
const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
|
||||
|
||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||
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>
|
||||
</>
|
||||
)
|
||||
|
||||
const matrixToolbar = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статус блокировок"
|
||||
description="Проверки DPI и геоблокировок с VPS через censorcheck."
|
||||
description="Проверки DPI и геоблокировок с VPS или MikroTik 7.22+."
|
||||
actions={copyLauncher}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
@@ -175,7 +261,7 @@ export function BlockingPage() {
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'current', label: 'Текущие', count: filtered.length },
|
||||
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
|
||||
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
||||
]}
|
||||
value={tab}
|
||||
@@ -183,33 +269,13 @@ export function BlockingPage() {
|
||||
/>
|
||||
|
||||
{tab === 'current' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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>
|
||||
<div className="flex min-w-0 w-full flex-col gap-3">
|
||||
<BlockingSnapshotScrubber
|
||||
ticks={ticks}
|
||||
index={resolvedIndex}
|
||||
onIndexChange={setSnapshotIndex}
|
||||
issueLabel="с ограничениями"
|
||||
/>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={currentQuery.isLoading}
|
||||
@@ -218,19 +284,26 @@ 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 />}
|
||||
>
|
||||
{(rows) =>
|
||||
group === 'vps' ? (
|
||||
<BlockingVpsGrid
|
||||
toolbar={matrixToolbar}
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<BlockingServiceGrid groups={serviceGroups} emptyAction={copyLauncher} />
|
||||
<BlockingServiceGrid
|
||||
toolbar={matrixToolbar}
|
||||
groups={serviceGroups}
|
||||
runs={rows}
|
||||
onProbeClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
@@ -250,7 +323,7 @@ export function BlockingPage() {
|
||||
onRowClick={setSelected}
|
||||
emptyState={{
|
||||
title: 'История пуста',
|
||||
description: `На VPS выполните: ${LAUNCHER_CMD}`,
|
||||
description: `На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`,
|
||||
action: copyLauncher,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, HistoryIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import { tickIndexAtOrBefore, type BlockingSnapshotTick, type SnapshotTone } from './blocking-snapshots'
|
||||
|
||||
const HOUR = 60 * 60 * 1000
|
||||
const PRESETS = [
|
||||
{ id: 'now', label: 'Сейчас', ms: 0 },
|
||||
{ id: '1h', label: '−1 ч', ms: HOUR },
|
||||
{ id: '6h', label: '−6 ч', ms: 6 * HOUR },
|
||||
{ id: '24h', label: '−24 ч', ms: 24 * HOUR },
|
||||
{ id: '7d', label: '−7 д', ms: 7 * 24 * HOUR },
|
||||
] as const
|
||||
|
||||
/** Верхняя часть капсулы — доля прогонов часа с проблемой, цветом худшего результата. */
|
||||
const TONE_FILL: Record<SnapshotTone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
}
|
||||
const TONE_TEXT: Record<SnapshotTone, string> = { ok: 'text-success', wn: 'text-warning', er: 'text-destructive' }
|
||||
|
||||
function formatTickTime(iso: string, withDate = true): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('ru-RU', {
|
||||
...(withDate ? { day: 'numeric', month: 'short' } : {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Шкала времени по точкам измерений (час = точка). Лента капсул по образцу ReUI chart-34
|
||||
* (Cron Run History): высота — число проб, цвет — худший результат часа. Timeline из ReUI
|
||||
* статичен, поэтому навигация собрана из Button / ToggleGroup / Tooltip.
|
||||
*/
|
||||
export function BlockingSnapshotScrubber({
|
||||
ticks,
|
||||
index,
|
||||
onIndexChange,
|
||||
describeTick,
|
||||
issueLabel = 'с проблемами',
|
||||
}: {
|
||||
ticks: BlockingSnapshotTick[]
|
||||
index: number
|
||||
onIndexChange: (index: number) => void
|
||||
/** Подпись под временем выбранной точки, напр. «4 пробы · 2 блокировки». */
|
||||
describeTick?: (tick: BlockingSnapshotTick) => string
|
||||
issueLabel?: string
|
||||
}) {
|
||||
if (ticks.length === 0) return null
|
||||
|
||||
const lastIndex = ticks.length - 1
|
||||
const selected = ticks[index] ?? ticks[lastIndex]!
|
||||
const isLatest = index >= lastIndex
|
||||
const maxCount = Math.max(...ticks.map((t) => t.count), 1)
|
||||
const latestMs = new Date(ticks[lastIndex]!.asOf).getTime()
|
||||
const go = (next: number) => onIndexChange(Math.min(lastIndex, Math.max(0, next)))
|
||||
|
||||
const presetIndex = (ms: number) => (ms === 0 ? lastIndex : tickIndexAtOrBefore(ticks, latestMs - ms))
|
||||
// Подсвечиваем только «Сейчас»: «−N ч» — переход к ближайшей точке, не режим.
|
||||
const activePreset = isLatest ? 'now' : undefined
|
||||
|
||||
const onStripKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') go(index - 1)
|
||||
else if (event.key === 'ArrowRight') go(index + 1)
|
||||
else if (event.key === 'Home') go(0)
|
||||
else if (event.key === 'End') go(lastIndex)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel className="flex flex-col gap-3 px-(--frame-panel-header-px) py-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<IconTile variant="soft" size="sm" className={TONE_TEXT[selected.tone]} aria-hidden="true">
|
||||
<HistoryIcon />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-px">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLatest ? 'Сейчас' : 'Снимок'} · {formatTickTime(selected.asOf)}
|
||||
<Badge size="sm" variant={isLatest ? 'success-light' : 'outline'}>
|
||||
{isLatest ? 'Актуально' : `${lastIndex - index} назад`}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{describeTick
|
||||
? describeTick(selected)
|
||||
: `${selected.count} проб · ${selected.issues} ${issueLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={activePreset ? [activePreset] : []}
|
||||
onValueChange={(next) => {
|
||||
const preset = PRESETS.find((p) => p.id === next[0])
|
||||
if (!preset) return
|
||||
const target = presetIndex(preset.ms)
|
||||
go(target < 0 ? 0 : target)
|
||||
}}
|
||||
aria-label="Быстрый переход по времени"
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<ToggleGroupItem key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => go(index - 1)}
|
||||
disabled={index <= 0}
|
||||
aria-label="Предыдущее измерение"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => go(index + 1)}
|
||||
disabled={isLatest}
|
||||
aria-label="Следующее измерение"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Точки измерений: ← → для перехода"
|
||||
tabIndex={0}
|
||||
onKeyDown={onStripKey}
|
||||
className="focus-visible:ring-ring/50 flex h-10 items-end justify-between gap-1 rounded-md outline-none focus-visible:ring-2"
|
||||
>
|
||||
{ticks.map((tick, i) => (
|
||||
<Tooltip key={tick.key}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`${formatTickTime(tick.asOf)}: ${tick.count} проб, ${tick.issues} ${issueLabel}`}
|
||||
aria-pressed={i === index}
|
||||
onClick={() => go(i)}
|
||||
className={cn(
|
||||
'bg-success/60 hover:bg-success/80 relative flex min-w-1.5 max-w-16 flex-1 flex-col overflow-hidden rounded-sm transition-colors',
|
||||
i === index && 'ring-foreground ring-offset-background ring-2 ring-offset-1',
|
||||
)}
|
||||
style={{ height: `${30 + (70 * tick.count) / maxCount}%` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{tick.issues > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('block w-full', TONE_FILL[tick.tone])}
|
||||
style={{ height: `${Math.max(12, (100 * tick.issues) / tick.count)}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTickTime(tick.asOf)} · {tick.count} проб · {tick.issues} {issueLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex justify-between text-[11px] tabular-nums">
|
||||
<span>{formatTickTime(ticks[0]!.asOf)}</span>
|
||||
{ticks.length > 1 ? <span>{formatTickTime(ticks[lastIndex]!.asOf)}</span> : null}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collectSnapshotTicks,
|
||||
latestRunsAsOf,
|
||||
mergeCensorcheckRuns,
|
||||
resolveSnapshotIndex,
|
||||
tickIndexAtOrBefore,
|
||||
} from './blocking-snapshots'
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
|
||||
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
|
||||
id: 'ccrun-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',
|
||||
censorcheckVersion: '1',
|
||||
summary: {
|
||||
total: 1,
|
||||
available: 1,
|
||||
redirected: 0,
|
||||
denied: 0,
|
||||
blocked: 0,
|
||||
timeout: 0,
|
||||
error: 0,
|
||||
},
|
||||
createdAt: '2026-08-22T10:00:00.000Z',
|
||||
completedAt: '2026-08-22T10:00:00.000Z',
|
||||
observedSourceIp: '203.0.113.10',
|
||||
vps: null,
|
||||
results: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('mergeCensorcheckRuns', () => {
|
||||
it('дедуплицирует по id, current побеждает', () => {
|
||||
const older = run({ id: 'a', summary: { ...run().summary, blocked: 0 } })
|
||||
const newer = run({ id: 'a', summary: { ...run().summary, blocked: 3 } })
|
||||
const merged = mergeCensorcheckRuns([newer], [older, run({ id: 'b' })])
|
||||
expect(merged).toHaveLength(2)
|
||||
expect(merged.find((item) => item.id === 'a')?.summary.blocked).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectSnapshotTicks', () => {
|
||||
it('группирует по часу и берёт max createdAt', () => {
|
||||
const ticks = collectSnapshotTicks(
|
||||
[
|
||||
run({ id: '1', createdAt: '2026-08-20T08:05:00.000Z' }),
|
||||
run({ id: '2', createdAt: '2026-08-20T08:40:00.000Z', summary: { ...run().summary, blocked: 2 } }),
|
||||
run({ id: '3', createdAt: '2026-08-20T18:00:00.000Z' }),
|
||||
],
|
||||
(r) => (r.summary.blocked > 0 ? 'er' : 'ok'),
|
||||
)
|
||||
expect(ticks.map((tick) => tick.key)).toEqual(['2026-08-20T08', '2026-08-20T18'])
|
||||
expect(ticks[0]?.count).toBe(2)
|
||||
expect(ticks[0]?.asOf).toBe('2026-08-20T08:40:00.000Z')
|
||||
expect(ticks[0]?.tone).toBe('er')
|
||||
expect(ticks[0]?.issues).toBe(1)
|
||||
expect(ticks[1]?.tone).toBe('ok')
|
||||
})
|
||||
|
||||
it('tickIndexAtOrBefore находит точку для пресета «−N ч»', () => {
|
||||
const ticks = collectSnapshotTicks([
|
||||
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
|
||||
run({ id: '2', createdAt: '2026-08-20T12:00:00.000Z' }),
|
||||
])
|
||||
expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T10:00:00.000Z'))).toBe(0)
|
||||
expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T07:00:00.000Z'))).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestRunsAsOf', () => {
|
||||
it('берёт последний прогон каждого IP на момент T', () => {
|
||||
const rows = [
|
||||
run({
|
||||
id: 'old',
|
||||
probePublicIp: '1.1.1.1',
|
||||
createdAt: '2026-08-20T10:00:00.000Z',
|
||||
summary: { ...run().summary, blocked: 1 },
|
||||
}),
|
||||
run({
|
||||
id: 'mid',
|
||||
probePublicIp: '1.1.1.1',
|
||||
createdAt: '2026-08-21T10:00:00.000Z',
|
||||
summary: { ...run().summary, blocked: 2 },
|
||||
}),
|
||||
run({
|
||||
id: 'new',
|
||||
probePublicIp: '1.1.1.1',
|
||||
createdAt: '2026-08-22T10:00:00.000Z',
|
||||
summary: { ...run().summary, blocked: 3 },
|
||||
}),
|
||||
run({
|
||||
id: 'other',
|
||||
probePublicIp: '2.2.2.2',
|
||||
createdAt: '2026-08-20T12:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
const asOf = latestRunsAsOf(rows, '2026-08-21T10:00:00.000Z')
|
||||
expect(asOf.map((item) => item.id).sort()).toEqual(['mid', 'other'])
|
||||
expect(asOf.find((item) => item.id === 'mid')?.summary.blocked).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSnapshotIndex', () => {
|
||||
it('без выбора — последний тик', () => {
|
||||
expect(resolveSnapshotIndex(5, null)).toBe(4)
|
||||
expect(resolveSnapshotIndex(0, null)).toBe(0)
|
||||
expect(resolveSnapshotIndex(3, 9)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
export type SnapshotRun = {
|
||||
id: string
|
||||
createdAt: string
|
||||
probePublicIp: string
|
||||
}
|
||||
|
||||
export type SnapshotTone = 'ok' | 'wn' | 'er'
|
||||
|
||||
export type BlockingSnapshotTick = {
|
||||
/** Час измерения `YYYY-MM-DDTHH` (UTC) — точка на шкале времени. */
|
||||
key: string
|
||||
asOf: string
|
||||
count: number
|
||||
/** Худший результат среди прогонов этого часа — цвет столбика на шкале. */
|
||||
tone: SnapshotTone
|
||||
/** Сколько прогонов часа с проблемой (блокировка / расхождение). */
|
||||
issues: number
|
||||
}
|
||||
|
||||
const TONE_RANK: Record<SnapshotTone, number> = { ok: 0, wn: 1, er: 2 }
|
||||
|
||||
/** Ключ точки шкалы: прогоны группируются по часу — несколько проверок за день видны отдельно. */
|
||||
export function snapshotHourKey(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso.slice(0, 13)
|
||||
return date.toISOString().slice(0, 13)
|
||||
}
|
||||
|
||||
export function snapshotDayKey(iso: string): string {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(iso)) return iso.slice(0, 10)
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso.slice(0, 10)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function formatSnapshotTickLabel(dayKey: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return dayKey
|
||||
const date = new Date(`${dayKey}T12:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime())) return dayKey
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
||||
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<T extends SnapshotRun>(
|
||||
runs: T[],
|
||||
toneOf: (run: T) => SnapshotTone = () => 'ok',
|
||||
): BlockingSnapshotTick[] {
|
||||
const byHour = new Map<string, T[]>()
|
||||
for (const run of runs) {
|
||||
const key = snapshotHourKey(run.createdAt)
|
||||
const list = byHour.get(key)
|
||||
if (list) list.push(run)
|
||||
else byHour.set(key, [run])
|
||||
}
|
||||
|
||||
return [...byHour.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, list]) => {
|
||||
const tones = list.map(toneOf)
|
||||
return {
|
||||
key,
|
||||
count: list.length,
|
||||
tone: tones.reduce<SnapshotTone>((worst, t) => (TONE_RANK[t] > TONE_RANK[worst] ? t : worst), 'ok'),
|
||||
issues: tones.filter((t) => t !== 'ok').length,
|
||||
asOf: list.reduce(
|
||||
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
|
||||
list[0]!.createdAt,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Индекс последней точки не позже `atMs` (для пресетов «−1 ч», «−24 ч»); -1 — раньше первой точки. */
|
||||
export function tickIndexAtOrBefore(ticks: BlockingSnapshotTick[], atMs: number): number {
|
||||
let found = -1
|
||||
ticks.forEach((tick, i) => {
|
||||
if (new Date(tick.asOf).getTime() <= atMs) found = i
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
/** Latest run per probe IP at or before `asOf`. */
|
||||
export function latestRunsAsOf<T extends SnapshotRun>(
|
||||
runs: T[],
|
||||
asOf: string,
|
||||
): T[] {
|
||||
if (!asOf) return []
|
||||
const byIp = new Map<string, T>()
|
||||
for (const run of runs) {
|
||||
if (run.createdAt > asOf) continue
|
||||
const previous = byIp.get(run.probePublicIp)
|
||||
if (
|
||||
!previous ||
|
||||
run.createdAt > previous.createdAt ||
|
||||
(run.createdAt === previous.createdAt && run.id > previous.id)
|
||||
) {
|
||||
byIp.set(run.probePublicIp, run)
|
||||
}
|
||||
}
|
||||
return [...byIp.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
}
|
||||
|
||||
export function resolveSnapshotIndex(
|
||||
tickCount: number,
|
||||
selected: number | null,
|
||||
): number {
|
||||
if (tickCount <= 0) return 0
|
||||
if (selected == null) return tickCount - 1
|
||||
return Math.min(Math.max(selected, 0), tickCount - 1)
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CENSORCHECK_DPI_HOSTS,
|
||||
CENSORCHECK_GEOBLOCK_HOSTS,
|
||||
} from '@cfdm/shared/contracts/censorcheck'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
|
||||
import { resolveServiceIcon } from './service-icons'
|
||||
|
||||
describe('resolveServiceIcon', () => {
|
||||
it('резолвит все DPI и geo хосты без fallback Globe', () => {
|
||||
const hosts = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
|
||||
for (const host of hosts) {
|
||||
expect(resolveServiceIcon(host), host).not.toBe(GlobeIcon)
|
||||
}
|
||||
})
|
||||
|
||||
it('unknown / custom → Globe', () => {
|
||||
expect(resolveServiceIcon('unknown.example')).toBe(GlobeIcon)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
BookOpenIcon,
|
||||
BoxIcon,
|
||||
BugIcon,
|
||||
ClapperboardIcon,
|
||||
DownloadIcon,
|
||||
FileJsonIcon,
|
||||
GlobeIcon,
|
||||
HeartIcon,
|
||||
KeyRoundIcon,
|
||||
LinkedinIcon,
|
||||
MailIcon,
|
||||
ScrollTextIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
VideoIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
siDigitalocean,
|
||||
siDiscord,
|
||||
siFacebook,
|
||||
siGoogleplay,
|
||||
siInstagram,
|
||||
siMongodb,
|
||||
siNetflix,
|
||||
siRedis,
|
||||
siSpotify,
|
||||
siTelegram,
|
||||
siX,
|
||||
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 }>> = {
|
||||
'discord.com': brandComponent(siDiscord),
|
||||
'youtube.com': brandComponent(siYoutube),
|
||||
'instagram.com': brandComponent(siInstagram),
|
||||
'facebook.com': brandComponent(siFacebook),
|
||||
'x.com': brandComponent(siX),
|
||||
'api.telegram.org': brandComponent(siTelegram),
|
||||
'spotify.com': brandComponent(siSpotify),
|
||||
'netflix.com': brandComponent(siNetflix),
|
||||
'mongodb.com': brandComponent(siMongodb),
|
||||
'redis.io': brandComponent(siRedis),
|
||||
'digitalocean.com': brandComponent(siDigitalocean),
|
||||
'play.google.com': brandComponent(siGoogleplay),
|
||||
}
|
||||
|
||||
const GENERIC_ICONS: Record<string, LucideIcon> = {
|
||||
'linkedin.com': LinkedinIcon,
|
||||
'redirector.googlevideo.com': VideoIcon,
|
||||
'rutracker.org': DownloadIcon,
|
||||
'amnezia.org': ShieldIcon,
|
||||
'getoutline.org': KeyRoundIcon,
|
||||
'mailfence.com': MailIcon,
|
||||
'flibusta.is': BookOpenIcon,
|
||||
'rezka.ag': ClapperboardIcon,
|
||||
'patreon.com': HeartIcon,
|
||||
'swagger.io': FileJsonIcon,
|
||||
'snyk.io': BugIcon,
|
||||
'autodesk.com': BoxIcon,
|
||||
'graylog.org': ScrollTextIcon,
|
||||
'copilot.microsoft.com': SparklesIcon,
|
||||
}
|
||||
|
||||
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)} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
CornerUpRightIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { MATRIX_STATUS } from './status-matrix-cell'
|
||||
|
||||
describe('MATRIX_STATUS', () => {
|
||||
it('статусы — lucide-иконки, не текст ОК/Блок', () => {
|
||||
expect(MATRIX_STATUS.available.icon).toBe(CheckIcon)
|
||||
expect(MATRIX_STATUS.available.tone).toBe('text-success')
|
||||
expect(MATRIX_STATUS.blocked.icon).toBe(XIcon)
|
||||
expect(MATRIX_STATUS.blocked.tone).toBe('text-destructive')
|
||||
expect(MATRIX_STATUS.denied.icon).toBe(BanIcon)
|
||||
expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon)
|
||||
expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon)
|
||||
expect(MATRIX_STATUS.error.icon).toBe(CircleAlertIcon)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
CornerUpRightIcon,
|
||||
MinusIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
|
||||
|
||||
/**
|
||||
* Статус-тайл ячейки матрицы: ReUI IconTile `soft` перекрашивается одним text-* классом
|
||||
* (так велит API IconTile — без ad-hoc bg). Preview: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export const MATRIX_STATUS: Record<string, { icon: LucideIcon; tone: string }> = {
|
||||
available: { icon: CheckIcon, tone: 'text-success' },
|
||||
blocked: { icon: XIcon, tone: 'text-destructive' },
|
||||
denied: { icon: BanIcon, tone: 'text-destructive' },
|
||||
timeout: { icon: ClockIcon, tone: 'text-warning' },
|
||||
redirected: { icon: CornerUpRightIcon, tone: 'text-info' },
|
||||
error: { icon: CircleAlertIcon, tone: 'text-warning' },
|
||||
}
|
||||
|
||||
export function StatusMatrixCell({
|
||||
status,
|
||||
serviceLabel,
|
||||
vpsLabel,
|
||||
httpStatus,
|
||||
checkedAt,
|
||||
onSelect,
|
||||
}: {
|
||||
status?: string | null
|
||||
serviceLabel: string
|
||||
vpsLabel: string
|
||||
httpStatus?: number | null
|
||||
checkedAt?: string
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||
const mapped = status ? MATRIX_STATUS[status] : undefined
|
||||
const Icon = mapped?.icon ?? MinusIcon
|
||||
const tone = mapped?.tone ?? 'text-muted-foreground'
|
||||
const tip = [
|
||||
serviceLabel,
|
||||
vpsLabel,
|
||||
full,
|
||||
httpStatus != null ? `HTTP ${httpStatus}` : null,
|
||||
checkedAt ? formatCheckedAt(checkedAt) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const badge = (
|
||||
<IconTile variant={mapped ? 'soft' : 'outline'} size="xs" radius="full" className={tone} aria-label={full}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex"
|
||||
onClick={(event) => {
|
||||
if (!onSelect) return
|
||||
event.stopPropagation()
|
||||
onSelect()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{badge}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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-8 shrink-0" 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>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactNode, ComponentType } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface DataGridColumn<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
icon?: LucideIcon
|
||||
icon?: LucideIcon | ComponentType<{ className?: string }>
|
||||
sortable?: boolean
|
||||
sortValue?: (row: T) => string | number
|
||||
/** TanStack sortingFn; для числовых sortValue — `'basic'`. */
|
||||
/** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */
|
||||
sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime'
|
||||
headerTitle?: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
enableHiding?: boolean
|
||||
size?: number
|
||||
minSize?: number
|
||||
maxSize?: number
|
||||
enablePinning?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Используйте DataGridColumn */
|
||||
|
||||
@@ -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,111 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
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,
|
||||
expected,
|
||||
}: {
|
||||
/** Ожидаемая страна (большинство сервисов в строке) — отличие подсвечивается как расхождение. */
|
||||
expected?: string | null
|
||||
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 mismatch = status === 'ok' && Boolean(badgeIso) && Boolean(expected) && badgeIso !== expected
|
||||
const variant =
|
||||
status === 'ok' && badgeIso
|
||||
? mismatch
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
: status
|
||||
? (STATUS_VARIANT[status] ?? 'outline')
|
||||
: 'outline'
|
||||
const tip = [
|
||||
mismatch ? `Расхождение: большинство сервисов видят ${expected}` : null,
|
||||
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} className="gap-1 tabular-nums">
|
||||
{status === 'ok' && badgeIso ? <CountryFlag code={badgeIso} className="size-3.5" /> : null}
|
||||
{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,194 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
collectServiceColumns,
|
||||
countryAgreement,
|
||||
filterIpregionRuns,
|
||||
hasGeoDisagreement,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasGeoDisagreement', () => {
|
||||
it('сервис, видящий другую страну, — расхождение даже при совпадении с инвентарём', () => {
|
||||
const base = run().results![0]!
|
||||
const agree = run({ results: [base, { ...base, id: 'r3', serviceKey: 'ipinfo.io' }] })
|
||||
expect(hasGeoDisagreement(agree)).toBe(false)
|
||||
expect(countryAgreement(agree)).toEqual({ iso: 'NL', same: 2, total: 2 })
|
||||
const split = run({ results: [base, { ...base, id: 'r3' }, { ...base, id: 'r4', countryIpv4: 'RU' }] })
|
||||
expect(countryAgreement(split)).toEqual({ iso: 'NL', same: 2, total: 3 })
|
||||
expect(hasGeoDisagreement(split)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** Сколько ok-ответов совпадают с большинством: «совпадает N из M» в колонке «Страна». */
|
||||
export function countryAgreement(run: IpregionRunDto): { iso: string | null; same: number; total: number } {
|
||||
const iso = majorityCountry(run)
|
||||
let same = 0
|
||||
let total = 0
|
||||
for (const row of run.results ?? []) {
|
||||
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(row.countryIpv6)
|
||||
if (row.status !== 'ok' || !code) continue
|
||||
total += 1
|
||||
if (code === iso) same += 1
|
||||
}
|
||||
return { iso, same, total }
|
||||
}
|
||||
|
||||
/** Расхождение для KPI и шкалы: страна ≠ инвентарю или хоть один сервис видит другую страну. */
|
||||
export function hasGeoDisagreement(run: IpregionRunDto): boolean {
|
||||
if (isGeoMismatch(run)) return true
|
||||
const { same, total } = countryAgreement(run)
|
||||
return same < total
|
||||
}
|
||||
|
||||
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,245 @@
|
||||
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 { CountryFlag } from '@/components/country-flag'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
import {
|
||||
collectProbeColumns,
|
||||
collectServiceColumns,
|
||||
countryAgreement,
|
||||
majorityCountry,
|
||||
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 dataGridCellWithIcon(
|
||||
<CountryFlag country={row.vps?.country} className="size-5" />,
|
||||
dataGridCellStack(link, secondary),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function GeoVpsGrid({
|
||||
runs,
|
||||
onRowClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
runs: IpregionRunDto[]
|
||||
onRowClick: (run: IpregionRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
|
||||
return [
|
||||
vpsIdentityColumn(),
|
||||
{
|
||||
key: 'consensus',
|
||||
header: 'Страна',
|
||||
size: 170,
|
||||
minSize: 150,
|
||||
sortValue: (row) => majorityCountry(row) ?? '',
|
||||
cell: (row) => {
|
||||
const { iso, same, total } = countryAgreement(row)
|
||||
const pct = total ? Math.round((same / total) * 100) : 0
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium tabular-nums">
|
||||
{iso ? <CountryFlag code={iso} /> : null}
|
||||
{iso ?? '—'}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{same}/{total}
|
||||
</span>
|
||||
</span>
|
||||
<Progress
|
||||
value={pct}
|
||||
aria-label={`Совпадает ${same} из ${total}`}
|
||||
className={pct < 100 ? '**:data-[slot=progress-indicator]:bg-warning' : '**:data-[slot=progress-indicator]:bg-success'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
...serviceCols.map(
|
||||
(svc): DataGridColumn<IpregionRunDto> => ({
|
||||
key: `svc:${svc.key}`,
|
||||
header: svc.label,
|
||||
headerTitle: svc.label,
|
||||
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
|
||||
expected={majorityCountry(row)}
|
||||
status={item?.status}
|
||||
countryIpv4={item?.countryIpv4}
|
||||
countryIpv6={item?.countryIpv6}
|
||||
serviceLabel={svc.title}
|
||||
vpsLabel={row.vps?.dns || row.probePublicIp}
|
||||
checkedAt={row.createdAt}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [serviceCols])
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Страны по сервисам"
|
||||
description="Какую страну видят GeoIP-базы и сервисы"
|
||||
toolbar={toolbar}
|
||||
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,
|
||||
toolbar,
|
||||
}: {
|
||||
groups: GeoServiceRow[]
|
||||
runs: IpregionRunDto[]
|
||||
onProbeClick: (run: IpregionRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: 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
|
||||
title="Страны по сервисам"
|
||||
description="Какую страну видят GeoIP-базы и сервисы"
|
||||
toolbar={toolbar}
|
||||
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,341 @@
|
||||
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,
|
||||
hasGeoDisagreement,
|
||||
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, (run) => (hasGeoDisagreement(run) ? 'wn' : 'ok')),
|
||||
[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(hasGeoDisagreement).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>
|
||||
</>
|
||||
)
|
||||
|
||||
const matrixToolbar = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
|
||||
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}
|
||||
issueLabel="с расхождениями"
|
||||
/>
|
||||
<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
|
||||
toolbar={matrixToolbar}
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<GeoServiceGrid
|
||||
toolbar={matrixToolbar}
|
||||
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)} />
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<SpaceScopedMain>{children}</SpaceScopedMain>
|
||||
<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">
|
||||
<PageCrumbContext.Provider value={setPageCrumb}>
|
||||
<SpaceScopedMain>{children}</SpaceScopedMain>
|
||||
</PageCrumbContext.Provider>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
@@ -268,11 +258,71 @@ 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()
|
||||
return (
|
||||
<div key={spaceId ?? 'default'} className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div key={spaceId ?? 'default'} className="flex min-w-0 flex-1 flex-col gap-4 md:gap-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -221,6 +221,12 @@ export function NavUser() {
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground py-1.5 text-xs font-normal tabular-nums">
|
||||
VPS Tracker v{__APP_VERSION__}
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -1,76 +1,37 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import { Activity, ArrowRight } 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'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
|
||||
type MonitorMetric = {
|
||||
type Tone = 'er' | 'wn' | 'ok'
|
||||
|
||||
/** Одна проверка монитора: строка в списке проблем (если не ok) и плитка с полосой. */
|
||||
type MonitorCheck = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
tone: Tone
|
||||
title: string
|
||||
text: string
|
||||
to: '/sync-journal' | '/settings/notifications' | '/accounts' | '/vps' | '/dashboard'
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
// text-warning/text-success на белом фоне не проходят контраст — *-foreground в светлой теме (как в KPI).
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
er: 'text-destructive',
|
||||
wn: 'text-warning-foreground dark:text-warning',
|
||||
ok: 'text-success-foreground dark:text-success',
|
||||
}
|
||||
const TONE_BG: Record<Tone, string> = { er: 'bg-destructive', wn: 'bg-warning', ok: 'bg-success' }
|
||||
|
||||
type SyncStatusRow = {
|
||||
accountId?: string
|
||||
@@ -123,7 +84,7 @@ export function countRecentFailedNotifications(
|
||||
}).length
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
||||
/** Центр состояния в шапке — та же разметка, что «Внимание» в макете и EvoBGP. */
|
||||
export function SystemMonitorPopover() {
|
||||
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
|
||||
@@ -140,156 +101,195 @@ export function SystemMonitorPopover() {
|
||||
|
||||
const stats = statsQ.data
|
||||
const vps = snapQ.data?.vps ?? []
|
||||
const downCount = vps.filter((v) => {
|
||||
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
|
||||
return status === 'down'
|
||||
}).length
|
||||
const monitored = vps.filter((v) => (v as { monitoringEnabled?: boolean }).monitoringEnabled).length
|
||||
const downCount = vps.filter((v) => (v as { lastHealthStatus?: string }).lastHealthStatus === 'down').length
|
||||
const issuesCount = stats?.issuesCount ?? 0
|
||||
const runwayDays = stats?.minRunwayDays
|
||||
const runwayLow = runwayDays != null && runwayDays < 14
|
||||
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
|
||||
const staleSync =
|
||||
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||
const staleSync = (stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
||||
const recentSyncFailed = failedSyncCount > 0
|
||||
const syncAlert = staleSync || recentSyncFailed
|
||||
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
|
||||
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||
const updatedAt = Math.max(statsQ.dataUpdatedAt, snapQ.dataUpdatedAt)
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'sync',
|
||||
label: 'Синк',
|
||||
value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK',
|
||||
unit: '',
|
||||
percent: syncAlert ? 35 : 100,
|
||||
icon: <RefreshCw aria-hidden />,
|
||||
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
|
||||
alert: syncAlert,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Уведомления',
|
||||
value: String(failedNotifications),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedNotifications * 15),
|
||||
icon: <Bell aria-hidden />,
|
||||
tone: failedNotifications > 0 ? 'warning' : 'success',
|
||||
alert: failedNotifications > 0,
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
label: 'Запас дней',
|
||||
value: runwayDays != null ? String(runwayDays) : '—',
|
||||
unit: runwayDays != null ? 'дн' : '',
|
||||
percent:
|
||||
runwayDays == null
|
||||
? 0
|
||||
: Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||
icon: <Wallet aria-hidden />,
|
||||
tone: runwayLow || lowBalance ? 'warning' : 'success',
|
||||
alert: runwayLow || lowBalance,
|
||||
},
|
||||
{
|
||||
id: 'vps-down',
|
||||
label: 'VPS down',
|
||||
value: String(downCount),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, downCount * 25),
|
||||
icon: <Server aria-hidden />,
|
||||
tone: downCount > 0 ? 'destructive' : 'success',
|
||||
alert: downCount > 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
downCount,
|
||||
failedNotifications,
|
||||
failedSyncCount,
|
||||
lowBalance,
|
||||
recentSyncFailed,
|
||||
runwayDays,
|
||||
runwayLow,
|
||||
syncAlert,
|
||||
],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || !apiOk
|
||||
const checks: MonitorCheck[] = [
|
||||
{
|
||||
id: 'sync',
|
||||
label: 'Синк',
|
||||
value: failedSyncCount > 0 ? String(failedSyncCount) : staleSync ? 'устарел' : 'OK',
|
||||
percent: failedSyncCount > 0 || staleSync ? 35 : 100,
|
||||
tone: failedSyncCount > 0 ? 'er' : staleSync ? 'wn' : 'ok',
|
||||
title: failedSyncCount > 0 ? 'Ошибка синхронизации' : 'Синк давно не выполнялся',
|
||||
text:
|
||||
failedSyncCount > 0
|
||||
? `${failedSyncCount} акк. с ошибкой последнего синка`
|
||||
: 'Нет успешного синка за 24 ч',
|
||||
to: '/sync-journal',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Уведомления',
|
||||
value: `${failedNotifications} шт.`,
|
||||
percent: Math.min(100, failedNotifications * 15),
|
||||
tone: failedNotifications > 0 ? 'wn' : 'ok',
|
||||
title: 'Уведомления не доставлены',
|
||||
text: `${failedNotifications} за 24 ч — проверьте Telegram / webhook`,
|
||||
to: '/settings/notifications',
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
label: 'Запас дней',
|
||||
value: runwayDays != null ? `${runwayDays} дн` : '—',
|
||||
percent: runwayDays == null ? 0 : Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||
tone: runwayLow || lowBalance ? 'wn' : 'ok',
|
||||
title: lowBalance ? 'Низкий баланс аккаунта' : 'Мало денег у хостера',
|
||||
text: runwayDays != null ? `Хватит примерно на ${runwayDays} дн` : 'Баланс ниже порога',
|
||||
to: '/accounts',
|
||||
},
|
||||
{
|
||||
id: 'vps-down',
|
||||
label: 'VPS down',
|
||||
value: monitored ? `${downCount} из ${monitored}` : String(downCount),
|
||||
percent: monitored ? Math.round(((monitored - downCount) / monitored) * 100) : 100,
|
||||
tone: downCount > 0 ? 'er' : 'ok',
|
||||
title: 'VPS недоступны',
|
||||
text: `${downCount} не отвечают на uptime-проверку`,
|
||||
to: '/vps',
|
||||
},
|
||||
]
|
||||
const problems: Omit<MonitorCheck, 'label' | 'value' | 'percent'>[] = [
|
||||
...(apiOk
|
||||
? []
|
||||
: [{ id: 'api', tone: 'er' as const, title: 'API недоступно', text: 'Нет ответа от сервера', to: '/dashboard' as const }]),
|
||||
...checks.filter((c) => c.tone !== 'ok'),
|
||||
...(issuesCount > 0
|
||||
? [
|
||||
{
|
||||
id: 'inventory',
|
||||
tone: 'wn' as const,
|
||||
title: 'Проблемы инвентаря',
|
||||
text: `${issuesCount} категор. на дашборде`,
|
||||
to: '/dashboard' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const errors = problems.filter((p) => p.tone === 'er').length
|
||||
const warnings = problems.length - errors
|
||||
const okCount = checks.filter((c) => c.tone === 'ok').length
|
||||
const status: Tone = errors > 0 ? 'er' : warnings > 0 ? 'wn' : 'ok'
|
||||
const statusLabel = status === 'er' ? 'Сбой' : status === 'wn' ? 'Внимание' : 'Норма'
|
||||
// Кнопка подсвечена, пока есть что смотреть; в норме — спокойный outline.
|
||||
const alert = status !== 'ok'
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ${errors} сбой, ${warnings} предупреждения`}
|
||||
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',
|
||||
'relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0',
|
||||
alert && 'border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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]"
|
||||
<Activity className={cn('size-4', alert ? 'text-warning' : 'text-success')} aria-hidden />
|
||||
<span
|
||||
className={cn('hidden text-sm font-medium min-[1000px]:inline', alert ? 'text-warning-foreground dark:text-warning' : 'text-success-foreground dark:text-success')}
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
{alert ? 'Внимание' : 'Норма'}
|
||||
</span>
|
||||
{alert ? (
|
||||
<>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{problems.length}
|
||||
</span>
|
||||
<span
|
||||
className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор VPS Tracker</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Монитор VPS Tracker</b>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{updatedAt ? `обновлено ${new Date(updatedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` : 'загрузка…'}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={status === 'er' ? 'destructive-light' : status === 'wn' ? 'warning-light' : 'success-light'}
|
||||
size="sm"
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', t: 'er' as const },
|
||||
{ n: warnings, l: 'предупреждения', t: 'wn' as const },
|
||||
{ n: okCount, l: 'в норме', t: 'ok' as const },
|
||||
].map((x) => (
|
||||
<span
|
||||
key={x.l}
|
||||
className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]"
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', TONE_TEXT[x.t])}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{problems.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{problems.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
to={p.to}
|
||||
className="hover:bg-muted/55 focus-visible:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none"
|
||||
>
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', TONE_BG[p.tone])} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{p.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{p.text}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{checks.map((c) => (
|
||||
<div key={c.id} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||
<span className="text-muted-foreground text-xs leading-snug">{c.label}</span>
|
||||
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', TONE_TEXT[c.tone])}>
|
||||
{c.value}
|
||||
</b>
|
||||
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', TONE_BG[c.tone])} style={{ width: `${c.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
API:{' '}
|
||||
<span className={cn('font-medium', apiOk ? 'text-foreground' : 'text-destructive')}>
|
||||
{apiOk ? 'OK' : '—'}
|
||||
</span>
|
||||
{' · '}
|
||||
Инвентарь:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{issuesCount}</span>
|
||||
{stats?.lastGlobalSyncAt ? (
|
||||
<>
|
||||
{' · '}
|
||||
Синк:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
to="/sync-journal"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm transition-colors"
|
||||
>
|
||||
Журнал синка
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ListFiltersSearch({
|
||||
spellCheck = false,
|
||||
}: ListFiltersSearchProps) {
|
||||
return (
|
||||
<div className={cn('relative w-full', className)}>
|
||||
<div className={cn('relative w-[220px] max-w-full', className)}>
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
@@ -140,30 +140,19 @@ export function ListFiltersBar({
|
||||
showReset,
|
||||
toggles,
|
||||
}: ListFiltersBarProps) {
|
||||
const hasMeta =
|
||||
chips?.length ||
|
||||
(shown != null && total != null) ||
|
||||
resultsSuffix ||
|
||||
(showReset && onReset)
|
||||
|
||||
// Однострочный тулбар для шапки Frame (MikrotikManager v2): контролы → поиск → сброс; чипы — строкой ниже.
|
||||
// «Показано N из M» показываем только когда фильтр что-то скрыл; общий счёт — в футере пагинации.
|
||||
const filtered = shown != null && total != null && shown !== total
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
{controls}
|
||||
{toggles}
|
||||
{search ? <ListFiltersSearch {...search} /> : null}
|
||||
{controls || toggles ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{controls}
|
||||
{toggles}
|
||||
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{hasMeta ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{chips?.length ? <FilterActiveChips chips={chips} /> : null}
|
||||
{shown != null && total != null ? (
|
||||
<FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} />
|
||||
) : resultsSuffix ? (
|
||||
<div className="text-xs text-muted-foreground">{resultsSuffix}</div>
|
||||
) : null}
|
||||
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||
{filtered ? <FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} /> : null}
|
||||
{chips?.length ? (
|
||||
<div className="basis-full">
|
||||
<FilterActiveChips chips={chips} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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,5 +2,5 @@ import type { ReactNode } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
return <div className={cn('flex w-full min-w-0 flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user