docs: обновить README, описать возможности и настройку, переснять скриншоты
- README: главные возможности, галерея, быстрый старт, ссылки на документы - docs/features.md: гид по всем разделам с новыми скриншотами - docs/configuration.md: переменные окружения, права, роли, планировщик - 26 скриншотов нового дизайна на демо-данных (в т.ч. мобильные) - scripts/seed-demo.ts и scripts/docs-screenshots.mjs для пересъёмки Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -1,95 +1,60 @@
|
|||||||
# VPS Tracker
|
# 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. [Скриншоты](#скриншоты)
|
Нужны Linux-сервер (или любая машина) с [Docker](https://docs.docker.com/engine/install/). Node.js ставить не нужно.
|
||||||
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. Папка для данных
|
|
||||||
|
|
||||||
Все записи приложения лежат в одном файле базы. Его нужно хранить **вне** контейнера, иначе при удалении контейнера данные пропадут.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/vps-tracker-data
|
mkdir -p ~/vps-tracker-data
|
||||||
```
|
|
||||||
|
|
||||||
Можно выбрать любой каталог вместо `~/vps-tracker-data` — главное, запомнить путь для команды ниже.
|
|
||||||
|
|
||||||
### Шаг 3. Запуск
|
|
||||||
|
|
||||||
**Вариант А — в фоне** (удобно на сервере, контейнер переживёт закрытие терминала):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name vps-tracker \
|
--name vps-tracker \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
@@ -98,42 +63,24 @@ docker run -d \
|
|||||||
ghcr.io/denozordec/vps-tracker:latest
|
ghcr.io/denozordec/vps-tracker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Вариант Б — в переднем плане** (логи видны в терминале, остановка — `Ctrl+C`):
|
Откройте **http://localhost:3001** (или `http://IP_СЕРВЕРА:3001`). База появится в `~/vps-tracker-data/vps-tracker.db`.
|
||||||
|
|
||||||
```bash
|
> **`docker pull` просит логин?** Пакет на GitHub может быть закрытым: войдите через [Personal Access Token](https://github.com/settings/tokens) с правом `read:packages` — `echo ТОКЕН | docker login ghcr.io -u ЛОГИН --password-stdin` — или сделайте пакет public в его настройках.
|
||||||
docker run --rm \
|
|
||||||
-p 3001:3001 \
|
|
||||||
-v ~/vps-tracker-data:/app/data \
|
|
||||||
ghcr.io/denozordec/vps-tracker:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 4. Открыть в браузере
|
Первые шаги в интерфейсе:
|
||||||
|
|
||||||
На той же машине откройте:
|
1. **Хостеры** → добавьте хостера; для синка укажите тип API и URL биллинга.
|
||||||
|
2. **Аккаунты хостеров** → добавьте аккаунт с логином/токеном API и нажмите «Синхронизировать» — серверы, платежи и баланс подтянутся сами.
|
||||||
**http://localhost:3001**
|
3. **Настройки → Общие** → базовая валюта и URL курсов (например `https://www.cbr-xml-daily.ru/daily_json.js`).
|
||||||
|
4. **Настройки → Синхронизация / Уведомления** → автосинк по расписанию и Telegram.
|
||||||
Если заходите с другого компьютера в сети — подставьте 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<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
|
```bash
|
||||||
git clone https://github.com/denozordec/vps-tracker.git
|
git clone https://github.com/denozordec/vps-tracker.git
|
||||||
@@ -141,140 +88,77 @@ cd vps-tracker
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**То же через Docker CLI** (без Compose) — эквивалент [docker-compose.yml](docker-compose.yml): том `./data`, порт **3001**, переменная **PORT=3001**.
|
### Production: Traefik + HTTPS (Cloudflare DNS-01)
|
||||||
|
|
||||||
```bash
|
Готовый compose с Traefik и Let's Encrypt, без публикации порта `3001` наружу, плюс короткий домен для launcher-скриптов блокировок/GeoIP:
|
||||||
mkdir -p ./data
|
**[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)
|
||||||
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).
|
|
||||||
|
|
||||||
Кратко:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /opt/vps-tracker/data && cd /opt/vps-tracker
|
mkdir -p /opt/vps-tracker/data && cd /opt/vps-tracker
|
||||||
curl -fsSL -o docker-compose.yml \
|
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/denozordec/vps-tracker/main/deploy/docker-compose.traefik.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 .env \
|
|
||||||
https://raw.githubusercontent.com/denozordec/vps-tracker/main/deploy/env.traefik.example
|
|
||||||
nano .env # CF_DNS_API_TOKEN, LETSENCRYPT_EMAIL, VPS_DOMAIN
|
nano .env # CF_DNS_API_TOKEN, LETSENCRYPT_EMAIL, VPS_DOMAIN
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
curl -fsS https://ВАШ_ДОМЕН/health
|
curl -fsS https://ВАШ_ДОМЕН/health
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Свой образ из исходников
|
||||||
|
|
||||||
<span id="build-local"></span>
|
|
||||||
|
|
||||||
## Сборка образа у себя
|
|
||||||
|
|
||||||
Если нужен свой образ из исходников (после `git clone`):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd vps-tracker
|
|
||||||
docker build -t vps-tracker:local .
|
docker build -t vps-tracker:local .
|
||||||
mkdir -p ./data
|
docker run -d --name vps-tracker --restart unless-stopped \
|
||||||
docker run -d \
|
-p 3001:3001 -v "$(pwd)/data:/app/data" vps-tracker:local
|
||||||
--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
|
```bash
|
||||||
docker pull ghcr.io/denozordec/vps-tracker:latest
|
docker pull ghcr.io/denozordec/vps-tracker:latest
|
||||||
docker stop vps-tracker
|
docker stop vps-tracker && docker rm 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 из репозитория:
|
Другой порт снаружи: `-p 8080:3001` → `http://localhost:8080`. Сменить порт внутри контейнера: `-e PORT=8080 -p 8080:8080`.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd vps-tracker
|
|
||||||
git pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Файл Compose настроен с `pull_policy: always`, поэтому при каждом `up` запрашивается актуальный образ с GHCR.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<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).
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
const ccServices: [string, Record<string, number>][] = [
|
||||||
|
['youtube.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
|
||||||
|
['instagram.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
|
||||||
|
['chatgpt.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
|
||||||
|
['discord.com', { vpn1: 200, vpn2: 0, vpn3: 200, web1: 200 }],
|
||||||
|
['x.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
|
||||||
|
['netflix.com', { vpn1: 302, vpn2: 200, vpn3: 403, web1: 200 }],
|
||||||
|
['spotify.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
|
||||||
|
['linkedin.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
|
||||||
|
]
|
||||||
|
const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` })
|
||||||
|
let n = 0
|
||||||
|
for (const key of ['vpn1', 'vpn2', 'vpn3', 'web1']) {
|
||||||
|
const ip = vpsList.find((v) => v.key === key)!.ip as string
|
||||||
|
n++
|
||||||
|
await api('POST', '/api/integrations/censorcheck/runs', {
|
||||||
|
schemaVersion: 1,
|
||||||
|
runId: `00000000-0000-4000-8000-00000000000${n}`,
|
||||||
|
probe: { publicIp: ip },
|
||||||
|
launcherVersion: '1',
|
||||||
|
censorcheck: { version: '12c5839', mode: 'both' },
|
||||||
|
results: ccServices.map(([service, codes]) => ({ service, raw: { https: { ipv4: { status: codes[key] } } } })),
|
||||||
|
}, auth())
|
||||||
|
}
|
||||||
|
const geo: Record<string, string> = { vpn1: 'FI', vpn2: 'DE', vpn3: 'KZ', web1: 'NL', us1: 'US' }
|
||||||
|
// 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'],
|
||||||
|
['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'],
|
||||||
|
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'],
|
||||||
|
]
|
||||||
|
for (const [key, cc] of Object.entries(geo)) {
|
||||||
|
const ip = vpsList.find((v) => v.key === key)!.ip as string
|
||||||
|
n++
|
||||||
|
await api('POST', '/api/integrations/ipregion/runs', {
|
||||||
|
schemaVersion: 1,
|
||||||
|
runId: `00000000-0000-4000-9000-0000000000${String(n).padStart(2, '0')}`,
|
||||||
|
probe: { publicIp: ip },
|
||||||
|
launcherVersion: '1',
|
||||||
|
ipregion: { version: '7d1c25c' },
|
||||||
|
results: geoServices.map(([service, group], i) => ({
|
||||||
|
service,
|
||||||
|
group,
|
||||||
|
// a few services see the KZ node elsewhere — the kind of mismatch the matrix highlights
|
||||||
|
ipv4: key === 'vpn3' && (i === 4 || i === 10) ? 'RU' : key === 'vpn1' && i === 9 ? 'SE' : cc,
|
||||||
|
ipv6: key === 'web1' ? cc : null,
|
||||||
|
})),
|
||||||
|
}, auth())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Настройка VPS Tracker
|
||||||
|
|
||||||
|
Всё, что настраивается переменными окружения, плюс как устроены авторизация, планировщик и хранение данных. Настройки, которые меняются в интерфейсе (валюта, Telegram, интервалы синка), описаны в [features.md](features.md).
|
||||||
|
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
- [Авторизация и права](#авторизация-и-права)
|
||||||
|
- [Пространства](#пространства)
|
||||||
|
- [Планировщик](#планировщик)
|
||||||
|
- [Блокировки и GeoIP](#блокировки-и-geoip)
|
||||||
|
- [Хранение данных и миграции](#хранение-данных-и-миграции)
|
||||||
|
- [Безопасность](#безопасность)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
### Основные
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3001` | Порт HTTP-сервера |
|
||||||
|
| `DB_PATH` | `<cwd>/data/vps-tracker.db` (в Docker — `/app/data/vps-tracker.db`) | Путь к файлу SQLite |
|
||||||
|
| `LOG_LEVEL` | `info` | Уровень логов Fastify (`debug`, `warn`…) |
|
||||||
|
| `NODE_ENV` | — | `production` включает доверие к прокси и требует явных секретов |
|
||||||
|
| `TRUST_PROXY` | `1` в production | Доверять `X-Forwarded-For` (нужно за Traefik/nginx для реального IP клиента) |
|
||||||
|
| `BACKUP_BODY_LIMIT_BYTES` | `104857600` (100 МБ) | Максимальный размер загружаемого бэкапа |
|
||||||
|
|
||||||
|
### Авторизация (auth-portal)
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `AUTH_REQUIRED` | `false` | `true` — все `/api/*` требуют JWT портала |
|
||||||
|
| `AUTH_JWT_SECRET` (или `JWT_SECRET`) | dev-секрет вне production | Секрет подписи JWT (обязателен при `AUTH_REQUIRED=true`, ≥ 8 символов) |
|
||||||
|
| `AUTH_ISSUER` | `https://auth.shnt.top` | Ожидаемый `iss` в токене |
|
||||||
|
| `AUTH_PORTAL_URL` | `http://localhost:5175` | URL портала: вход, каталог пользователей, аудит |
|
||||||
|
| `AUTH_AUDIT_INGEST_SECRET` | — | Секрет для отправки событий журнала изменений в аудит портала |
|
||||||
|
| `VPS_MAIN_SPACE_OWNER_USER_ID` | — | Пользователь портала, которого назначить владельцем «Основного» пространства |
|
||||||
|
|
||||||
|
### Блокировки и GeoIP
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `CENSORCHECK_INGEST_SECRET` | — | Секрет HMAC-токенов, которыми скрипты отправляют результаты (общий для блокировок и GeoIP) |
|
||||||
|
| `CENSORCHECK_PUBLIC_URL` | — | Публичный URL, который подставляется в команды `curl …/cc`, `…/ic` |
|
||||||
|
| `CENSORCHECK_RATE_LIMIT` | включён | `0` — отключить rate limit на приёме результатов |
|
||||||
|
| `VPS_LAUNCHER_DOMAIN` | — | (Traefik-compose) короткий домен для launcher-скриптов |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Авторизация и права
|
||||||
|
|
||||||
|
По умолчанию (`AUTH_REQUIRED=false`) авторизации нет — удобно для домашней установки за VPN. Для команды включите SSO через auth-portal: пользователь входит на портале, приложение проверяет JWT и права.
|
||||||
|
|
||||||
|
Токен должен содержать приложение `vps` в `apps`, иначе — 403. Права имеют вид `vps:<раздел>:<read|write|admin>`; `write` включает `read`, `admin` включает оба.
|
||||||
|
|
||||||
|
| Раздел | Чтение | Запись |
|
||||||
|
|---|---|---|
|
||||||
|
| Дашборд и всё прочее | `vps:dashboard:read` | — |
|
||||||
|
| VPS, проекты, схема, данные, блокировки, GeoIP | `vps:vps:read` | `vps:vps:write` |
|
||||||
|
| Хостеры и аккаунты | `vps:accounts:read` | `vps:accounts:write` |
|
||||||
|
| Платежи, баланс, курсы | `vps:payments:read` | `vps:payments:write` |
|
||||||
|
| Синхронизация | `vps:sync:write` | `vps:sync:write` |
|
||||||
|
| Настройки, журнал изменений, уведомления, бэкап | `vps:settings:admin` | `vps:settings:admin` |
|
||||||
|
|
||||||
|
**Глобальный администратор** — пользователь с `is_admin` в токене или правом `vps:spaces:admin`. Только он может скачивать и восстанавливать бэкап и файл БД и запускать legacy-миграцию: эти операции затрагивают все пространства и содержат учётные данные API.
|
||||||
|
|
||||||
|
Публичные (без JWT) эндпоинты: `/health`, `/ready`, `/api/auth/config`, launcher-скрипты `/cc*`, `/ic*` и приём результатов `/api/integrations/{censorcheck,ipregion}` (защищены своим HMAC-токеном), `/api/integrations/cfdm/*` (токен интеграции).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пространства
|
||||||
|
|
||||||
|
Каждая запись (VPS, хостер, платёж, настройки…) принадлежит пространству. Клиент выбирает пространство заголовком `X-Space-Id`; без него — «Основное».
|
||||||
|
|
||||||
|
| Роль | Может |
|
||||||
|
|---|---|
|
||||||
|
| **viewer** | смотреть |
|
||||||
|
| **member** | менять VPS, проекты, схемы, платежи, баланс, хостеров и аккаунты, запускать синк |
|
||||||
|
| **admin** | + настройки пространства, участники, шаринг VPS |
|
||||||
|
| **owner** | + передача владения, удаление пространства |
|
||||||
|
|
||||||
|
Права портала и роль в пространстве проверяются **обе**: для записи платежа нужны и `vps:payments:write`, и роль не ниже member.
|
||||||
|
|
||||||
|
Синхронизация, уведомления (свой Telegram/webhook), uptime-проверки и CFDM работают отдельно для каждого пространства.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Планировщик
|
||||||
|
|
||||||
|
Запускается вместе с сервером и перезапускается при сохранении настроек.
|
||||||
|
|
||||||
|
| Задача | Интервал | Условие |
|
||||||
|
|---|---|---|
|
||||||
|
| Синк VPS, платежей и баланса | настройка, ≥ 15 мин (по умолчанию 60) | включён автосинк |
|
||||||
|
| Синк тарифов | настройка, ≥ 60 мин (по умолчанию 1440) | включён автосинк |
|
||||||
|
| Напоминания об оплате | настройка, ≥ 15 мин (по умолчанию 60) | всегда |
|
||||||
|
| Uptime-проверки | настройка, ≥ 1 мин (по умолчанию 5) | у VPS включён мониторинг |
|
||||||
|
| Pull доменов из CFDM | 15 мин | интеграция включена, есть URL и токен |
|
||||||
|
| Очистка журналов | раз в сутки и при старте | всегда |
|
||||||
|
|
||||||
|
Таймеры тикают с самым частым интервалом среди пространств, но каждое пространство выполняется по своему. Если предыдущий прогон задачи ещё идёт, тик пропускается. Ручной и плановый синк одного аккаунта не выполняются одновременно (второй получает `409`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блокировки и GeoIP
|
||||||
|
|
||||||
|
1. Задайте `CENSORCHECK_INGEST_SECRET` (длинная случайная строка) и `CENSORCHECK_PUBLIC_URL` (например `https://vt.example.com`).
|
||||||
|
2. За Traefik используйте отдельный короткий хост `VPS_LAUNCHER_DOMAIN` — см. [deploy-traefik.md](deploy-traefik.md).
|
||||||
|
3. На сервере: `curl -fsSL https://vt.example.com/cc | bash` (блокировки) или `…/ic | bash` (GeoIP). Каждый запуск получает свежий токен на 20 минут.
|
||||||
|
|
||||||
|
Скрипты — закреплённые версии [vernette/censorcheck](https://github.com/vernette/censorcheck) и [vernette/ipregion](https://github.com/vernette/ipregion) (MIT) в `apps/api/scripts/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Хранение данных и миграции
|
||||||
|
|
||||||
|
- Одна база SQLite (WAL, внешние ключи включены). Бэкап — копия файла `vps-tracker.db` или выгрузка из интерфейса.
|
||||||
|
- При старте схема **доводится автоматически**: создаются недостающие таблицы и колонки, данные старых версий (например, URL BILLmanager из аккаунтов) переносятся на новое место. Отдельно запускать миграции не нужно.
|
||||||
|
- Ретеншн: `notification_log` и `sync_log` — 90 дней, `vps_health_checks` — 30 дней; синки в статусе `running` старше часа помечаются ошибкой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Включайте `AUTH_REQUIRED=true`, если приложение доступно не только вам; иначе любой с доступом к порту видит и меняет данные.
|
||||||
|
- Учётные данные API хранятся в БД открытым текстом (их нужно отправлять хостеру) и **не отдаются** в API/интерфейс — только признак «задано» и логин. Храните файл БД и бэкапы как секрет.
|
||||||
|
- `/api/rates-proxy` запрашивает только URL курсов, сохранённый в настройках, — произвольные адреса не проксируются.
|
||||||
|
- Запросы к Telegram и webhook ограничены таймаутом 10 секунд.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Возможности VPS Tracker
|
||||||
|
|
||||||
|
Гид по всем разделам интерфейса. Скриншоты — на демо-данных (вымышленные хостеры, IP из RFC 5737).
|
||||||
|
|
||||||
|
**Содержание**
|
||||||
|
|
||||||
|
1. [Дашборд](#дашборд)
|
||||||
|
2. [VPS и карточка сервера](#vps-и-карточка-сервера)
|
||||||
|
3. [Проекты](#проекты)
|
||||||
|
4. [Схема инфраструктуры](#схема-инфраструктуры)
|
||||||
|
5. [Хостеры, аккаунты и синхронизация](#хостеры-аккаунты-и-синхронизация)
|
||||||
|
6. [Активные тарифы](#активные-тарифы)
|
||||||
|
7. [Платежи, баланс и продления](#платежи-баланс-и-продления)
|
||||||
|
8. [Отчёты и ресурсы](#отчёты-и-ресурсы)
|
||||||
|
9. [Статус блокировок](#статус-блокировок)
|
||||||
|
10. [GeoIP](#geoip)
|
||||||
|
11. [Уведомления](#уведомления)
|
||||||
|
12. [Пространства, пользователи и роли](#пространства-пользователи-и-роли)
|
||||||
|
13. [Журналы](#журналы)
|
||||||
|
14. [Настройки, интеграции и бэкапы](#настройки-интеграции-и-бэкапы)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дашборд
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Одна страница, чтобы понять, всё ли в порядке:
|
||||||
|
|
||||||
|
- **KPI**: активные VPS, расход в месяц (в базовой валюте), суммарный баланс у хостеров по API, **запас дней** — на сколько хватит денег при текущем расходе, сколько VPS истекает в ближайшие 7 дней, число проблем.
|
||||||
|
- **Баннер «Требуется внимание»** и кнопка «Внимание» в шапке собирают проблемы инвентаря: истекающая оплата, низкий баланс, давно не синхронизированные аккаунты, расхождение баланса API и журнала.
|
||||||
|
- **Быстрые действия** — переходы в частые разделы (можно скрыть в настройках).
|
||||||
|
- **Графики** платежей и расходов по месяцам и таблица «Здоровье инвентаря» с CSV-экспортом.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Все суммы в разных валютах пересчитываются в базовую: по курсу хостера (если задан в карточке хостера) или по курсам из URL в настройках (формат ЦБ РФ `cbr-xml-daily` или `{ base, rates }`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VPS и карточка сервера
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Таблица с поиском по IP, DNS, проекту, назначению и ОС; фильтры, настраиваемые колонки (вид), сортировка.
|
||||||
|
- Для каждого сервера: IP/IPv6 и доп. адреса, домены (из CFDM), аккаунт и хостер, страна/город/ДЦ с флагом, CPU/RAM/диск, проект, статус, тариф (дневной/месячный), оплачено до.
|
||||||
|
- **Массовые действия**: выделите несколько VPS — сменить статус или проект, удалить.
|
||||||
|
- **Произвольные поля** (настраиваются в *Настройки → Общие*) появляются в таблице и форме.
|
||||||
|
- Поля, изменённые вручную, запоминаются как «пользовательские» — синхронизация с хостером их не перезаписывает.
|
||||||
|
|
||||||
|
Карточка сервера разбита на вкладки: **Обзор** (сеть и ресурсы), **Финансы** (тариф, дата оплаты и связанные платежи), **Заметки**, **Синк** (что пришло от хостера).
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
**Дата «оплачено до»** считается автоматически: из поля хостера, а для дневного тарифа — из баланса аккаунта и суточного списания; прямые платежи за VPS продлевают дату.
|
||||||
|
|
||||||
|
**Uptime-мониторинг**: для VPS с включённым мониторингом раз в N минут (по умолчанию 5) проверяется TCP-доступность SSH-порта. Переходы «упал/поднялся» попадают в уведомления и историю проверок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проекты
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Группировка серверов по проектам (цвет, заметки). Для каждого — количество VPS, расход в месяц и суммарные ресурсы. Карточка проекта показывает его VPS и быстрые переходы в отчёты.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Проект можно указать прямо в форме VPS: существующий находится без учёта регистра (в том числе кириллица), новый создаётся автоматически.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Схема инфраструктуры
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Редактор топологии на React Flow:
|
||||||
|
|
||||||
|
- узлы **VPS** (с флагом, IP, ресурсами, ценой и статусом uptime), фигуры, заметки, группы;
|
||||||
|
- **связи** с типом (сеть, зависимость, туннель, VPN, синхронизация, произвольная), направлением, стилем линии, подписью, протоколом и IP концов туннеля;
|
||||||
|
- **сервисы CFDM** — отдельные узлы с членством VPS (для балансировки/HA);
|
||||||
|
- несколько схем во вкладках, блокировка от случайных правок, экспорт в PNG, автосохранение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Хостеры, аккаунты и синхронизация
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
**Хостер** — компания (сайт, базовая валюта, курсы USD/EUR для пересчёта) и **тип API** с URL биллинга — один на хостера.
|
||||||
|
**Аккаунт** — ваш логин у хостера: учётные данные API, режим оплаты (ежедневно/ежемесячно), порог низкого баланса. Пароли API в интерфейс не возвращаются — видно только, что они заданы, и логин.
|
||||||
|
|
||||||
|
Поддерживаемые API:
|
||||||
|
|
||||||
|
| Тип | Что нужно в аккаунте | Что синхронизируется |
|
||||||
|
|---|---|---|
|
||||||
|
| **BILLmanager 6** | логин и пароль API | VPS, платежи, баланс, тарифы `vds.order` по всем ДЦ |
|
||||||
|
| **4VPS** | Panel ID и API Key | VPS, баланс, тарифы |
|
||||||
|
| **Маклауд / VDSina** (userapi) | API Token | VPS, операции, баланс, тарифы |
|
||||||
|
| **Veesp** | email и пароль client area | VPS, счета, баланс, тарифы |
|
||||||
|
| **RuVDS** | API-токен из ЛК | VPS, платежи, баланс, тарифы |
|
||||||
|
|
||||||
|
Для разных BILLmanager-хостеров есть **профили** с особенностями их API (FirstByte, IHOR, DataCheap, Serv.host, WaiCore, LandVPS, Cloudrix…) — новый добавляется одним файлом, см. [profiles/README.md](../apps/api/src/services/billmanager/profiles/README.md).
|
||||||
|
|
||||||
|
Синхронизация запускается кнопкой на аккаунте («Синхронизировать» / «Синхронизировать все») или по расписанию (*Настройки → Синхронизация*: интервал синка VPS и платежей — от 15 минут, тарифов — от 60 минут). Повторный запуск во время идущего синка того же аккаунта отклоняется. Каждый запуск пишется в [журнал синка](#журналы).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Активные тарифы
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Прайс-листы хостеров, загруженные через API: ресурсы, цена, ДЦ, доступность к заказу. Удобно сравнивать хостеров и замечать новые тарифы — о них приходит уведомление. Если загрузка прайс-листа не удалась, сохранённый список не стирается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Платежи, баланс и продления
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Платежи** — пополнения баланса хостера и прямые оплаты VPS (привязка к аккаунту и/или серверу необязательна), итоги по валютам, импорт из синка без дублей.
|
||||||
|
- **Баланс и списания** — журнал движений по балансу (приход/расход), сводка и чистый остаток.
|
||||||
|
- **Продления** — календарь ближайших оплат на 7/30/90 дней и стоимость продлений.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отчёты и ресурсы
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Отчёты**: расход в месяц по проектам и хостерам, платежи по типам, фильтр по проекту и периоду, экспорт в CSV.
|
||||||
|
- **Ресурсы**: суммарные vCPU/RAM/диск активных VPS и распределение по хостерам.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Статус блокировок
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Проверка доступности популярных сервисов **с самого VPS** (через [censorcheck](https://github.com/vernette/censorcheck)): доступен, заблокирован, отказ (403), редирект, таймаут. Результат автоматически привязывается к VPS по IP; есть история прогонов и группировка по VPS или по сервису.
|
||||||
|
|
||||||
|
Запуск на сервере — одна команда (скрипт ставит недостающие `jq`/`dig`, показывает прогресс и отправляет результат с одноразовым токеном):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://vt.example.com/cc | bash # разовая проверка
|
||||||
|
curl -fsSL https://vt.example.com/cc | bash -s -- --daily # раз в сутки (cron)
|
||||||
|
curl -fsSL https://vt.example.com/cc | bash -s -- --remove-daily
|
||||||
|
```
|
||||||
|
|
||||||
|
**MikroTik (RouterOS 7.22+)** — пробы идут через указанный исходящий IP:
|
||||||
|
|
||||||
|
```
|
||||||
|
:global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.example.com/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc
|
||||||
|
```
|
||||||
|
|
||||||
|
(`?daily=1` — ежедневный scheduler, `?remove=daily` — удалить.) Кнопки «Скопировать команду», «Раз в день», «MikroTik» на странице подставляют ваш домен. Нужны `CENSORCHECK_INGEST_SECRET` и `CENSORCHECK_PUBLIC_URL` — см. [configuration.md](configuration.md#блокировки-и-geoip).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GeoIP
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Какую страну видят у вашего IP GeoIP-базы (MaxMind, RIPE, ipinfo, Cloudflare…) и сервисы (Google, YouTube, Netflix, ChatGPT…), включая CDN, — через [ipregion](https://github.com/vernette/ipregion). Матрица VPS × сервис, переключение «по VPS / по сервису», подсветка расхождений, история.
|
||||||
|
|
||||||
|
Запуск так же, как у блокировок: `curl -fsSL https://vt.example.com/ic | bash` (`--daily`, `--remove-daily`), для MikroTik — `ic.rsc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Уведомления
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Каналы: **Telegram** (Chat ID, несколько чатов через запятую, топики форума через Thread ID, свой Bot API URL для прокси) и **webhook** (POST JSON). Кнопка «Тест» проверяет настройки и подсказывает типичные ошибки (бот не в чате, неверный токен, нет топика).
|
||||||
|
|
||||||
|
| Событие | Когда | Повтор |
|
||||||
|
|---|---|---|
|
||||||
|
| Истекает оплата | VPS с оплатой в ближайшие 7 дней | не чаще раза в сутки для того же набора |
|
||||||
|
| Низкий баланс | баланс аккаунта по API ниже порога | раз в сутки |
|
||||||
|
| Дайджест синка | после планового синка, если что-то изменилось | при новых изменениях |
|
||||||
|
| Новые тарифы | у хостера появились тарифы | при новом наборе |
|
||||||
|
| VPS недоступен / восстановлен | переход статуса uptime-проверки | каждый переход |
|
||||||
|
|
||||||
|
Каждое событие включается отдельно. Все отправки (и пропуски-дубли) видны в журнале уведомлений. Длинные сообщения обрезаются по строкам под лимит Telegram, имена из API экранируются.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пространства, пользователи и роли
|
||||||
|
|
||||||
|
При включённой авторизации (`AUTH_REQUIRED=true`) вход идёт через **auth-portal** (JWT), а данные разделены на **пространства**:
|
||||||
|
|
||||||
|
- у каждого пользователя есть личное пространство; есть общее «Основное» и можно создавать командные;
|
||||||
|
- роли в пространстве: **owner → admin → member → viewer**. Viewer только смотрит; member меняет VPS, платежи, хостеров, аккаунты и запускает синк; admin управляет настройками и участниками;
|
||||||
|
- **шаринг VPS**: сервер можно показать в другом пространстве (чтение/запись) или передать целиком;
|
||||||
|
- удалённые пространства попадают в корзину и восстанавливаются;
|
||||||
|
- права портала (`vps:<раздел>:read|write|admin`) ограничивают разделы поверх ролей — см. [configuration.md](configuration.md#авторизация-и-права).
|
||||||
|
|
||||||
|
Синхронизация, уведомления, uptime и интеграции работают отдельно для каждого пространства.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Журналы
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
- **Журнал синка** — каждый запуск по аккаунтам: время, длительность, статус, что изменилось или текст ошибки.
|
||||||
|
- **Журнал изменений** — кто, когда и что поменял (создание/изменение/удаление) с diff полей; лента или таблица. При настроенном портале события дублируются в его аудит.
|
||||||
|
|
||||||
|
Старые записи журналов удаляются автоматически (уведомления и синк — 90 дней, uptime-проверки — 30 дней).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройки, интеграции и бэкапы
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
- **Общие** — быстрые действия на дашборде, базовая валюта, URL курсов и автоконвертация, произвольные поля VPS, **бэкап/восстановление** (JSON или файл БД).
|
||||||
|
- **Синхронизация** — автосинк и интервалы.
|
||||||
|
- **Уведомления** — Telegram, webhook и выбор событий.
|
||||||
|
- **Интеграции** — **App Switcher** (переключатель приложений в шапке) и **CF Domain Manager (CFDM)**: домены и балансировщики из CFDM подтягиваются каждые 15 минут и показываются у VPS и на схеме.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Работает и на телефоне — меню сворачивается, таблицы прокручиваются:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="screenshots/mobile-dashboard.png" width="260" alt="Дашборд на телефоне">
|
||||||
|
|
||||||
|
<img src="screenshots/mobile-vps.png" width="260" alt="VPS на телефоне">
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Скриншоты
|
||||||
|
|
||||||
|
Скриншоты в README и `docs/` сняты на **демо-данных**: хостеры вымышленные (Nordhost, Aurora Cloud, Baltic VDS, Pixel Hosting), IP — из документационных диапазонов RFC 5737 (`203.0.113.0/24`, `198.51.100.0/24`, `192.0.2.0/24`), домены — `*.example.net`.
|
||||||
|
|
||||||
|
## Как пересоздать
|
||||||
|
|
||||||
|
Нужны собранный фронтенд и Chrome/Chromium/Edge.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter web build
|
||||||
|
pnpm --filter @cfdm/shared build && pnpm --filter @cfdm/db build
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Поднять демо на пустой базе (порт 3101, без планировщика):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api
|
||||||
|
rm -f ../../data/demo.db*
|
||||||
|
DB_PATH=../../data/demo.db VITEST=1 LOG_LEVEL=warn npx tsx scripts/seed-demo.ts --serve
|
||||||
|
```
|
||||||
|
|
||||||
|
2. В другом терминале из корня репозитория снять все экраны:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apps/api/scripts/docs-screenshots.mjs docs/screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Путь к браузеру — переменная `CHROME`, адрес демо — `BASE`.
|
||||||
|
|
||||||
|
Список экранов и их параметры (размер окна, клик по вкладке, прокрутка, мобильный режим) — в начале [`docs-screenshots.mjs`](../../apps/api/scripts/docs-screenshots.mjs). Демо-данные — [`seed-demo.ts`](../../apps/api/scripts/seed-demo.ts).
|
||||||
|
|
||||||
|
Файлы в репозитории дополнительно ужаты до 256-цветной палитры (тёмная тема переносит это без видимых потерь), чтобы README грузился быстрее.
|
||||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 79 KiB |