Inspoter — self-hosted панель для управления доменами, DNS, серверами и хостингом, мониторинга доступности, работы с почтой и webhook, а также просмотра логов и оповещений. Данные разделены по рабочим пространствам, поэтому один экземпляр приложения подходит для нескольких команд или проектов.
- дашборды: именованные страницы с виджетами на сетке 12 колонок;
- домены и DNS-записи из Cloudflare, Hetzner DNS и GoDaddy;
- серверы Hetzner Cloud и хостинг Hostinger или cPanel;
- HTTP-, TCP- и PING-мониторинг сервисов;
- мониторинг метрик VPS: CPU, память, swap, load, диск и uptime через Docker-агент;
- почтовые аккаунты IMAP/SMTP, сообщения и вложения;
- каналы сообщений, входящие и исходящие webhook, совместимые с Discord;
- закладки, логи и оповещения;
- MCP-сервер и REST API управления разделом «Сообщения» для ИИ-ассистентов, с правами на уровне API-токена;
- рабочие пространства, участники и переключение между командами;
- интерфейс на английском и русском языках;
- вход по паролю или через Authentik OIDC.
- Next.js 16.2.10, React 19 и TypeScript;
- Tailwind CSS v4 и shadcn/ui;
- Prisma 7 и PostgreSQL 16;
- Vitest и Playwright;
- Python 3.12 (Docker-агент сбора метрик, stdlib only);
- Node.js 24.x и pnpm 11.12.0;
- Docker с Docker Compose для приведённых ниже команд запуска PostgreSQL и всего приложения.
Версия pnpm закреплена в package.json. Для её установки через Corepack выполните corepack enable.
Скопируйте контракт переменных окружения и заполните обязательные значения:
cp .env.example .envДля входа по паролю задайте OPERATOR_USERNAME и одну из переменных: OPERATOR_PASSWORD_HASH или OPERATOR_PASSWORD. Вторая предназначена только для разработки. Параметры базы задаются через DATABASE_URL.
Следующие команды запускают PostgreSQL в Docker, а собранное приложение — напрямую в Node.js:
docker compose up -d db
pnpm install
pnpm db:migrate
pnpm db:seed
pnpm build
pnpm startПриложение будет доступно по адресу http://localhost:3000.
После установки зависимостей, настройки .env и применения миграций запустите dev-сервер с Turbopack:
pnpm devПриложение будет доступно по адресу http://localhost:3800.
Compose запускает PostgreSQL и production-сборку приложения. Хостовый порт 3800 направлен на порт 3000 контейнера:
docker compose up -d --build
docker compose exec app pnpm db:seedПриложение будет доступно по адресу http://localhost:3800.
Текущий
docker-compose.ymlне передаёт сервисуappпеременныеCREDENTIAL_ENCRYPTION_KEYиAUTHENTIK_*. Поэтому конфигурация провайдеров, почтовых аккаунтов, исходящих webhook и Authentik недоступна в этом режиме, пока эти переменные не будут явно добавлены в окружение сервиса. Вход по паролю продолжает работать.
Шаблон базовой конфигурации находится в .env.example. Основные переменные:
| Переменная | Требование | Назначение |
|---|---|---|
DATABASE_URL |
обязательна | Подключение к PostgreSQL. Для БД из Compose при запуске приложения на хосте используется localhost:3832; внутри Compose — db:5432. |
OPERATOR_USERNAME |
обязательна для базового seed | Логин оператора, создаваемого командой pnpm db:seed. |
OPERATOR_PASSWORD_HASH |
одна из двух | Scrypt-хеш salt:hash; предпочтительный вариант. |
OPERATOR_PASSWORD |
одна из двух | Пароль в открытом виде только для разработки; игнорируется, если задан хеш. |
CREDENTIAL_ENCRYPTION_KEY |
для защищённых учётных данных | 64-символьный hex-ключ для шифрования данных провайдеров, почтовых аккаунтов и секретов исходящих webhook. |
AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET, AUTHENTIK_REDIRECT_URI |
необязательны, задаются вместе | Включают вход через Authentik OIDC. Если группа не задана, доступен вход по паролю. |
LIST_PAGE_SIZE |
необязательна | Размер страницы; значение по умолчанию — 50. |
WEBHOOK_RATE_LIMIT, WEBHOOK_RATE_WINDOW_MS, WEBHOOK_MAX_BODY_BYTES |
необязательны | Ограничения для входящих webhook. |
WEBHOOK_AUTO_DISABLE_AFTER |
необязательна | Сколько неудачных доставок подряд отключают исходящий webhook (по умолчанию 10). Счётчик обнуляется успешной доставкой и ручным включением. |
BACKUP_MAX_IMPORT_BYTES, BACKUP_IMPORT_TX_TIMEOUT_MS |
необязательны | Резервное копирование (/settings/backup): максимальный размер импортируемого архива (по умолчанию 512 МиБ) и таймаут транзакции импорта (по умолчанию 5 минут). |
SERVER_METRICS_RATE_LIMIT, SERVER_METRICS_RATE_WINDOW_MS |
необязательны | Rate limiting для публичного метрик-эндпоинта: максимум запросов на пару «токен + IP-адрес источника» (по умолчанию 12) и окно в мс (по умолчанию 60 000). |
LLM_REQUEST_TIMEOUT_MS, LLM_CALL_RATE_LIMIT, LLM_CALL_RATE_WINDOW_MS |
необязательны | Модели ИИ: таймаут одного запроса к модели (по умолчанию 60 000 мс) и лимит вызовов на рабочее пространство (по умолчанию 60 вызовов в час). Адрес модели и ключ задаются в UI, а не здесь. |
Раздел «Дашборды» — обзорные страницы рабочего пространства. Дашбордов может быть сколько угодно, они общие для всех участников пространства, а один из них можно назначить стартовым: именно он открывается сразу после входа. Если стартовый не выбран, открывается первый по порядку.
Виджеты лежат на сетке из 12 колонок. Перетаскивание и изменение размера доступны только в режиме редактирования (кнопка «Редактировать»), поэтому в обычном режиме плитки остаются интерактивными. Размер меняется мышью за уголок в правом нижнем углу или стрелками с клавиатуры, когда уголок в фокусе. На экране уже 640 пикселей сетка превращается в одну колонку, а редактирование раскладки отключается.
Данные виджетов обновляются раз в минуту одним запросом на дашборд, пока вкладка активна. Ошибка одного виджета не ломает страницу: такая плитка показывает причину, а остальные продолжают работать.
Доступные виджеты:
| Виджет | Что показывает | Настройки |
|---|---|---|
| Часы и дата | Текущее время и дату | Формат 12/24 ч, секунды, дата, временная зона |
| Погода | Текущую погоду по координатам (Open-Meteo, без API-ключа) | Название места, широта, долгота, °C/°F |
| Календарь | Сетку месяца с отметками дней, когда были события | Источники: оповещения, инциденты сервисов, письма, действия |
| Заметка | Произвольный текст | Текст заметки |
| Закладки | Плитки ссылок из закладок | Категория, количество |
| Статусы сервисов | Сводку мониторинга и список сервисов | Список сервисов, количество |
| Метрики серверов | CPU, память и диск по данным агента | Сервер, количество |
| Почта | Последние письма | Ящик, только непрочитанные, количество |
| Оповещения | Последние оповещения | Уровни важности, количество |
| Логи | Последние записи журнала | Уровни, количество |
Виджет погоды — единственный виджет, обращающийся к внешнему сервису: серверный
запрос к api.open-meteo.com с координатами из настроек виджета, ответ кешируется в
памяти процесса на 10 минут. Если такой запрос нежелателен, не добавляйте этот виджет —
остальные работают только на данных самого приложения. Второй и последний внешний
адресат приложения — эндпоинт модели из раздела «Модели ИИ (LLM)», и
он тоже включается только вручную.
Дашборды входят в резервную копию рабочего пространства как отдельный раздел «Дашборды». Признак стартового дашборда при импорте не переносится: он уникален в пределах пространства, поэтому после восстановления назначьте стартовый заново.
Провайдеры подключаются в разделе Настройки → Провайдеры (/settings/providers). Токены и ключи API хранятся в базе в зашифрованном виде для текущего рабочего пространства; отдельные env-переменные для токенов провайдеров не используются. Добавлять, изменять и удалять провайдеров может любой участник рабочего пространства — ограничения по роли (OWNER/MEMBER) здесь нет.
| Раздел | Провайдеры |
|---|---|
| Домены и DNS | Cloudflare, Hetzner DNS, GoDaddy |
| Серверы | Hetzner Cloud |
| Хостинг | Hostinger, cPanel WHM, cPanel UAPI |
| LLM | любой endpoint, совместимый с OpenAI API |
Без настроенного провайдера соответствующий раздел показывает пустое состояние.
Приложение умеет обращаться к языковой модели через любой endpoint, совместимый с OpenAI API: локальные Ollama, vLLM, LM Studio, а также облачные OpenRouter и OpenAI. Пока это только инфраструктура — пользовательских AI-функций в интерфейсе ещё нет.
Модель подключается там же, где остальные провайдеры: Настройки → Провайдеры,
тип OpenAI-compatible. Нужны три поля — базовый URL API (например
http://127.0.0.1:11434/v1 для Ollama), имя модели и API-ключ. Ключ, как и другие
учётные данные, хранится в базе в зашифрованном виде для текущего рабочего
пространства; наружу отдаётся только маскированный хвост. Отдельной env-переменной
для ключа нет.
Пока учётные данные не заданы, слой моделей полностью выключен: ни одного исходящего запроса не выполняется и ошибок не возникает.
Что стоит знать до подключения:
- Куда уходят данные. Это второй и последний внешний адресат приложения (первый —
Open-Meteo в виджете погоды) и единственный, в тело запроса которого попадает
содержимое рабочего пространства. Рекомендуемый режим — локальная модель:
при базовом URL вида
http://127.0.0.1:11434/v1данные не покидают хост. Облачный провайдер поддерживается наравне с локальным, но выбор делаете вы, задавая URL. - Учёт. Каждый вызов модели попадает в журнал действий: модель, режим драйвера и число израсходованных токенов, а для неудачных вызовов — категория ошибки.
- Лимит. На рабочее пространство действует ограничение
LLM_CALL_RATE_LIMITвызовов заLLM_CALL_RATE_WINDOW_MS(по умолчанию 60 в час). - Таймаут. Один запрос к модели ждёт ответа не дольше
LLM_REQUEST_TIMEOUT_MS(по умолчанию 60 секунд) и не повторяется автоматически: повтор стоил бы вторых токенов, а модель к этому моменту обычно всё ещё генерирует ответ.
Swagger UI доступен авторизованным пользователям в разделе Настройки → API документация (/settings/api-docs; для русской локали — /ru/settings/api-docs). Приложение передаёт проверенную в репозитории спецификацию странице на сервере и не публикует отдельный /openapi.json.
Контракт OpenAPI 3.1.1 описывает публичные маршруты, не использующие сессионную куку:
POST /api/webhooks/{type}с Bearer-токеном;POST /api/webhooks/channels/{webhookId}/{token}с секретным токеном в URL;POSTиGET /api/discord/webhooks/{webhookId}/{token}— Discord-совместимый приём, секрет в URL;POST /api/discord/webhooks/{webhookId}/{token}/slack— форма Slack;POST /api/server-metricsс Bearer-токеном;POST /api/mcpс Bearer-токеном.
Поле category в payload типа alert необязательно: отправитель без понятия категорий (Alertmanager, Zabbix, UptimeRobot) получает 201, а оповещение попадает в раздел без категории — оператор проставляет её в таблице. Имя категории сопоставляется без учёта регистра, поэтому availability и Availability от разных систем попадают в одну категорию. У подписчиков исходящих webhook это значит, что поле category в событии ALERT_CREATED может быть null.
Все они определяют рабочее пространство по API-токену и не используют X-Inspoter-Workspace. Описания операций и примеры в Swagger UI написаны на английском. Внутренние dashboard API, OIDC и маршруты управления токенами не входят в публичную спецификацию. Не сохраняйте и не передавайте channel webhook URL через логи: URL содержит секрет.
Проверить спецификацию можно так:
node scripts/check-public-openapi.mjsСкрипт фиксирует список публичных путей, проверяет, что каждый объявленный маршрут действительно экспортирует POST, и отклоняет workspace-заголовки, внешние URL серверов и секреты в примерах. Пооперационные утверждения — включая лимиты Discord-payload и rate-limit-заголовки — живут в tests/unit/openapi/public-openapi.test.ts и выполняются вместе с pnpm test:unit.
Вебхуки Inspoter говорят на проводном формате Discord в обе стороны. Нормативный контракт — specs/discord-webhook-compatibility.md; ниже только то, что нужно оператору.
Канальный вебхук создаётся в настройках канала (значок шестерёнки → «Вебхуки»). Диалог показывает три значения, построенных из одного секрета: обычный URL, готовый cURL и Discord-совместимый URL. Любая интеграция, умеющая писать в Discord-канал — CI, Grafana, Sentry, Uptime Kuma, GitHub Actions — начинает писать в Inspoter подстановкой этого адреса вместо ссылки на discord.com. Тело запроса переписывать не нужно.
curl -X POST 'http://your-host/api/discord/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN?wait=true' \\
-H 'Content-Type: application/json' \\
-d '{"username":"CI","content":"Build 842 passed","embeds":[{"title":"Build 842","color":3066993}]}'Поддерживаются content (≤2000), embeds (≤10 карточек, ≤6000 символов суммарно), username, avatar_url, tts, flags, application/json и multipart/form-data с payload_json. Ответ по умолчанию — 204 без тела; ?wait=true даёт 200 и объект сообщения. Ошибки приходят в форме Discord (50006, 50035, 40005, 401, 429 с retry_after). Embeds отображаются в канале карточками.
Discord-URL — такой же секрет, как обычный: обратный прокси обязан вырезать путь /api/discord/webhooks/* из логов.
У каждого исходящего вебхука в Настройках → Исходящих вебхуках есть формат доставки:
| Формат | Что уходит на провод |
|---|---|
INSPOT (по умолчанию) |
Собственный конверт с подписью HMAC-SHA256 в X-Inspot-Signature. Существующие подписки не меняются. |
DISCORD_EXECUTE |
Тело Discord Execute Webhook: событие уходит карточкой embed. Указывайте URL вебхука Discord-канала. |
DISCORD_EVENTS |
Конверт Discord Webhook Events с подписью Ed25519, PING при создании, таймаут 3 с. Публичный ключ виден в UI. |
DISCORD_EXECUTE уважает retry_after из ответа 429. После WEBHOOK_AUTO_DISABLE_AFTER неудачных доставок подряд вебхук отключается автоматически, а ручное включение обнуляет счётчик.
POST /api/mcp — эндпоинт Model Context Protocol: ИИ-ассистент (Claude Code и Claude Desktop, Cursor, VS Code) получает доступ к почте, оповещениям, закладкам, сообщениям, серверам, сервисам и логам рабочего пространства. Транспорт — Streamable HTTP без сессий; авторизация — тот же универсальный API-токен, что и у webhook, через заголовок Authorization: Bearer <токен>.
Настройки → API-токены (/settings/webhooks). При создании токена отметьте нужные права; секрет показывается один раз. Права можно изменить позже кнопкой «Права» — секрет при этом не меняется, а ротация токена права сохраняет.
Токен без единого права остаётся токеном приёма webhook и метрик: на /api/mcp он получает 401. Поэтому все токены, выданные до появления MCP, доступа к данным рабочего пространства не получают.
{
"mcpServers": {
"inspoter": {
"type": "http",
"url": "https://dashboard.example.com/api/mcp",
"headers": { "Authorization": "Bearer ВАШ_ТОКЕН" }
}
}
}Клиентам без поддержки HTTP-транспорта подойдёт обёртка mcp-remote: npx mcp-remote https://dashboard.example.com/api/mcp --header "Authorization: Bearer ВАШ_ТОКЕН".
tools/list показывает только те инструменты, на которые у токена есть права.
| Право | Инструменты |
|---|---|
mail:read |
mail_accounts_list, mail_folders_list, mail_labels_list, mail_search, mail_get |
mail:write |
mail_draft_save, mail_send |
alerts:read |
alerts_search, alerts_get, alert_categories_list |
alerts:write |
alerts_set_category, alert_category_create |
bookmarks:read |
bookmarks_search, bookmarks_get, bookmark_categories_list |
bookmarks:write |
bookmark_create |
messages:read |
message_categories_list, messages_list, channel_webhooks_list |
messages:write |
message_category_create, message_category_rename, message_channel_create, message_channel_rename, message_send, channel_webhook_create, channel_webhook_revoke |
servers:read |
servers_list, server_get |
services:read |
services_list, service_get, service_checks |
logs:read |
logs_search |
Категоризация оповещений: alerts_search с categoryId: "none" возвращает оповещения без категории, alert_category_create заводит категорию (существующая по такому же имени переиспользуется), alerts_set_category привязывает оповещение к категории или снимает привязку при categoryId: null. Проставленная так категория помечается в интерфейсе как «Поставлено моделью», и оператор может её изменить.
Черновики и отправка работают с любого IMAP-аккаунта рабочего пространства: mail_accounts_list возвращает доступные аккаунты, их id передаётся в mail_draft_save и mail_send. Системный аккаунт WEBHOOK только принимает почту и отправлять через него нельзя.
Показатели серверов (CPU, load, память, swap, диск, uptime) приходят прямо в ответе servers_list и server_get в поле metrics; история проверок сервисов — в service_checks.
Раздел «Сообщения»: message_categories_list возвращает дерево категорий с каналами, message_category_create и message_channel_create работают по принципу get-or-create (совпадение имени без учёта регистра, поэтому повторный запуск того же сценария ничего не дублирует), message_send пишет в канал. Удаления категорий и каналов у агента нет — оно уносит всю переписку и остаётся действием оператора в интерфейсе. Право messages:write включает и управление вебхуками канала: channel_webhook_create возвращает URL с секретом один раз, channel_webhook_revoke его отзывает.
Rate limit общий с webhook-эндпоинтами и настраивается через WEBHOOK_RATE_LIMIT и WEBHOOK_RATE_WINDOW_MS. GET и DELETE на /api/mcp отвечают 405: сессии протокола не поддерживаются.
/api/v1/messages/** — то же самое, что даёт MCP, но обычным REST: для клиентов и скриптов без поддержки MCP. Авторизация — тот же bearer-токен и те же права messages:read / messages:write; рабочее пространство берётся из токена, сессионная cookie и заголовок X-Inspoter-Workspace не участвуют. Полная схема — в Настройки → Документация API (/settings/api-docs).
| Метод и путь | Право | Действие |
|---|---|---|
GET /api/v1/messages/categories |
messages:read |
категории с каналами |
POST /api/v1/messages/categories |
messages:write |
get-or-create категории: 201 — создана, 200 — уже была |
PATCH /api/v1/messages/categories/{categoryId} |
messages:write |
переименование категории |
POST /api/v1/messages/channels |
messages:write |
get-or-create канала в категории |
PATCH /api/v1/messages/channels/{channelId} |
messages:write |
переименование канала |
GET /api/v1/messages/channels/{channelId}/messages |
messages:read |
лента канала с курсорной пагинацией |
POST /api/v1/messages/channels/{channelId}/messages |
messages:write |
отправка сообщения |
GET /api/v1/messages/channels/{channelId}/webhooks |
messages:read |
вебхуки канала без секретов |
POST /api/v1/messages/channels/{channelId}/webhooks |
messages:write |
новый вебхук канала; URL с секретом отдаётся один раз |
DELETE /api/v1/messages/channels/{channelId}/webhooks/{webhookId} |
messages:write |
отзыв вебхука |
curl -X POST https://dashboard.example.com/api/v1/messages/categories \
-H "Authorization: Bearer ВАШ_ТОКЕН" -H "Content-Type: application/json" \
-d '{"name":"Deployments"}'
curl -X POST https://dashboard.example.com/api/v1/messages/channels \
-H "Authorization: Bearer ВАШ_ТОКЕН" -H "Content-Type: application/json" \
-d '{"categoryId":"<id категории>","name":"releases"}'
curl -X POST https://dashboard.example.com/api/v1/messages/channels/<id канала>/messages \
-H "Authorization: Bearer ВАШ_ТОКЕН" -H "Content-Type: application/json" \
-d '{"content":"Деплой завершён на web-01."}'Ошибки приходят в виде { "error": { "code", "message" } }: 401 — токена нет либо он отозван, канальный или без прав MCP; 403 — не хватает нужного права; 404 — объекта нет в рабочем пространстве токена; 400 (VALIDATION_FAILED) — тело не прошло проверку, поле issues указывает на конкретное поле; 429 — превышен общий с webhook rate limit.
Сообщения, отправленные по токену (и через REST, и через MCP), сохраняются с признаком AGENT и в ленте помечаются как «Агент»; автором становится имя токена, если явный author не передан. Изменения, сделанные REST-маршрутами, попадают в журнал действий под именем токена — MCP-инструменты в журнал не пишут, как и остальные существующие инструменты.
Dockerized Python-агент собирает OS-метрики (CPU, память, swap, load average, диск, uptime) с хоста и отправляет их в dashboard каждые 60 секунд по HTTPS.
- Создайте API-токен в разделе Настройки → API-токены (или используйте существующий): один workspace-токен принимает и входящие webhook, и метрики. Одноразовый секрет показывается при создании; доступна ротация.
- На карточке сервера в разделе Серверы откройте диалог подключения агента и скопируйте установочный сниппет.
- Подставьте токен в
.envагента и выполните сниппет на целевом сервере.
Токен не привязан к конкретному серверу. Dashboard определяет сервер на каждой отправке метрик по заявленным глобальным IPv4-адресам: сначала по уже существующим claims адресов, затем — через инвентарь провайдера, а если совпадений нет — создаёт agent-only запись сервера (для хостов без глобального IPv4 запись переиспользуется по hostname). При неоднозначном совпадении запрос отклоняется с 409 SERVER_MATCH_AMBIGUOUS.
| Состояние | Значение |
|---|---|
not_configured |
Данных от агента ещё нет |
live |
Свежий снимок (менее 180 секунд) |
stale |
Последний снимок старше 180 секунд |
stale означает «снапшоты не доходят», а не «сервер недоступен» — состояние питания показывает отдельный индикатор. Агент пишет в лог только класс статуса (2xx/4xx/5xx), поэтому причину отказа выясняют повтором запроса через curl с того же хоста: см. таблицу симптомов в metrics-agent/README.md. Частый случай — WAF перед dashboard, блокирующий агента по User-Agent.
Блок характеристик карточки (CPU, RAM, Диск, ОС) приходит из инвентаря провайдера, блок метрик — от агента. Диск провайдера берётся из primary_disk_size, то есть из фактического диска виртуальной машины: номинал тарифа (server_type.disk в Hetzner API) расходится с ним навсегда после апгрейда в режиме «CPU и RAM только». Строка Диск (использование) всегда описывает корневую ФС и совпадает с df -h / на хосте.
Шаблон metrics-agent/compose.yml монтирует /proc и probe-директорию хоста:
# На целевом сервере:
install -d -m 0555 /var/lib/inspoter-metrics-agent/rootfs-probe
# Задайте METRICS_ENDPOINT, METRICS_TOKEN, SERVER_IPS в .env
docker compose -f metrics-agent/compose.yml up -dОбраз Python 3.12-slim, non-root, read-only filesystem, cap_drop: ALL. Зависимости pip не требуются.
POST /api/server-metrics — единственный публичный эндпоинт для приёма метрик. Авторизация через универсальный API-токен рабочего пространства (Bearer, SHA-256 hash-only) — тот же токен, что и для входящих webhook. Сессионная аутентификация не используется. Rate limit: 12 запросов в минуту на пару «токен + IP» (настраивается через env). Успешный ответ содержит { code, localServerId }.
Управление токенами: GET/POST /api/webhook-tokens, PATCH/DELETE /api/webhook-tokens/[id], POST /api/webhook-tokens/[id]/rotate — защищены сессионной аутентификацией и доступны любому участнику рабочего пространства. PATCH меняет права MCP (см. раздел выше).
После базового seed можно добавить демонстрационные закладки, логи, оповещения, почтовые сообщения, каналы сообщений и токен входящего webhook:
pnpm db:seed:demoDemo seed не создаёт домены, серверы, записи хостинга или сервисы мониторинга. Эти разделы заполняются через подключённые провайдеры или интерфейс приложения.
pnpm dev # dev-сервер, http://localhost:3800
pnpm lint # ESLint и проверка нативных контролов
pnpm typecheck # TypeScript без генерации файлов
pnpm format # форматирование Prettier
pnpm format:check # проверка форматирования Prettier
pnpm openapi:check # lint и проверка публичного OpenAPI-контракта
pnpm test # все Vitest-тесты (unit + integration)
pnpm test:unit # чистые unit-тесты — без БД, без Docker
pnpm test:integration # интеграционные тесты с БД (нужен test Postgres на 3833)
pnpm test:e2e # e2e-тесты (Playwright)
pnpm test:ci # полный CI-профильE2E- и CI-тесты используют отдельную базу из docker-compose.test.yml. Её переменные описаны в .env.test.example.