Add inbound country flags and node load in location picker
CI / Backend (build + test) (push) Failing after 1m36s
CI / Frontend (lint + typecheck + build) (push) Successful in 41s

- Inbound.CountryCode (ISO alpha-2), set on publish; flag shown before
  the location name (SVG via country-flag-icons on the site, emoji in bot)
- Node.Capacity (optional, not a limit); available inbounds report load
  percent of capacity or active config count, sorted least loaded first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 19:45:17 +03:00
1 parent d7743977a1
commit bb5e59904f
51 files changed
+3160 -284

No files matched your search

+3 -3
View File
@@ -63,7 +63,7 @@ rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 за
| Метод | Путь | Тело запроса | Тело ответа |
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` (`countryCode`, `loadPercent`/`nodeConfigCount`; от менее загруженных нод) |
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], configQuota, planId }` — **без пагинации**, весь список сразу |
| POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) |
| PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` |
@@ -343,7 +343,7 @@ approve/reject над `ActivationRequest`.
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
| POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` |
| PUT | `/api/admin/nodes/{id}` | admin | `{ name, baseAddress, location?, isEnabled, notifyOnStatusChange, username?, password? }` | `NodeDto` |
| PUT | `/api/admin/nodes/{id}` | admin | `{ name, baseAddress, location?, isEnabled, notifyOnStatusChange, username?, password?, capacity? }` | `NodeDto` |
| DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
| POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` |
@@ -360,7 +360,7 @@ approve/reject над `ActivationRequest`.
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds? }` | `InboundDto` |
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, countryCode?, allowedRoleIds? }` | `InboundDto` |
| DELETE | `/api/admin/inbounds/{id}` | admin | только для `IsAvailable=false`, иначе `Inbounds.StillAvailable` | `204` |
## Admin — Users & Stats
+3 -1
View File
@@ -52,6 +52,7 @@ AppUser
| `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` |
| `ConsecutiveProbeFailures` | `int` | Счётчик подряд неудачных проб для гистерезиса статуса (`RecordProbe`); обнуляется первой удачной пробой |
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
| `Capacity` | `int?` | Ёмкость — сколько активных конфигов нода нормально тянет. Не лимит: знаменатель загрузки (%), которую пользователь видит при выборе локации; `null` — показывается число конфигов |
| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` |
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
| `CreatedAt` | `DateTimeOffset`| |
@@ -82,6 +83,7 @@ AppUser
| `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) |
| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) |
| `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» |
| `CountryCode` | `string?` | ISO 3166-1 alpha-2 (`DE`) — флаг перед именем у пользователя; задаётся при публикации, только отображение |
| `LastSyncAt` | `DateTimeOffset?` | |
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя
@@ -118,7 +120,7 @@ AppUser
> `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией
> зарезервированной квоты, а не заранее закэшированным статусом.
> **Пользователю показываем только `DisplayName` + протокол.** Адрес/хост ноды, `RemoteInboundId`,
> **Пользователю показываем только `DisplayName` + протокол (+ флаг по `CountryCode`).** Адрес/хост ноды, `RemoteInboundId`,
> `Port` и прочие детали 3x-ui в пользовательские DTO не попадают (только в админские).
### VpnConfig — конфиг пользователя (клиент в 3x-ui)
+5 -1
View File
@@ -89,7 +89,11 @@ frontend/
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
неактивированного — `ActivationGate` вместо дашборда.
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит одновременных IP
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`, с флагом страны) + метка. Флаги —
SVG из `country-flag-icons` (курируемый список в `shared/lib/countries.ts`; emoji-флаги Windows не рисует),
в Telegram-боте — emoji. У каждой локации — загрузка её ноды: «светофор» + % от `Node.Capacity`
(<50 / <80 / ≥80), либо число конфигов на сервере, если ёмкость не задана; порядок — с сервера,
от менее загруженных. Лимит одновременных IP
(`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) —
в форме создания не настраивается. После успеха карточка конфига появляется в списке; ссылку/QR
пользователь открывает отдельно.