Add inbound country flags and node load in location picker
- 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:
1 parent
d7743977a1
commit
bb5e59904f
51 files changed
+3160
-284
No files matched your search
@@ -52,6 +52,7 @@ export type VpnConfigDto = {
|
||||
clientEmail: string
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
countryCode: string | null
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
expiresAt: string | null
|
||||
@@ -68,6 +69,7 @@ export type AdminVpnConfigDto = {
|
||||
clientEmail: string
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
countryCode: string | null
|
||||
nodeName: string
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
@@ -80,6 +82,11 @@ export type AvailableInboundDto = {
|
||||
inboundId: string
|
||||
displayName: string
|
||||
protocol: VpnProtocol
|
||||
countryCode: string | null
|
||||
/** Загрузка ноды в % от её ёмкости (может быть > 100); null — ёмкость не задана. */
|
||||
loadPercent: number | null
|
||||
/** Число активных конфигов на ноде — только когда ёмкость не задана (иначе null). */
|
||||
nodeConfigCount: number | null
|
||||
}
|
||||
|
||||
export type ConfigLinkDto = {
|
||||
@@ -306,6 +313,7 @@ export type NodeDto = {
|
||||
status: NodeStatus
|
||||
isEnabled: boolean
|
||||
notifyOnStatusChange: boolean
|
||||
capacity: number | null
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
@@ -330,6 +338,7 @@ export type InboundDto = {
|
||||
isPublished: boolean
|
||||
isAvailable: boolean
|
||||
displayName: string | null
|
||||
countryCode: string | null
|
||||
allowedRoleIds: string[]
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FlagComponent } from 'country-flag-icons/react/3x2'
|
||||
import {
|
||||
AE, AM, AR, AT, AU, AZ, BE, BG, BR, BY, CA, CH, CL, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GE, GR, HK,
|
||||
HR, HU, ID, IE, IL, IN, IS, IT, JP, KG, KR, KZ, LT, LU, LV, MD, MX, MY, NL, NO, PL, PT, RO, RS, RU,
|
||||
SE, SG, SI, SK, TH, TR, TW, UA, US, UZ, VN, ZA,
|
||||
} from 'country-flag-icons/react/3x2'
|
||||
|
||||
// Курируемый список стран, где обычно арендуют VPN-серверы: полный набор country-flag-icons весит
|
||||
// ~1 МБ SVG, а named-импорты тришейкаются. Нужна новая страна — добавить её сюда (бэкенд принимает
|
||||
// любой ISO 3166-1 alpha-2; неизвестный UI код показывается текстом вместо флага).
|
||||
// Emoji-флаги не используем: Windows их не рисует (вместо 🇩🇪 показывает «DE»).
|
||||
export const FLAGS: Record<string, FlagComponent> = {
|
||||
AE, AM, AR, AT, AU, AZ, BE, BG, BR, BY, CA, CH, CL, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GE, GR, HK,
|
||||
HR, HU, ID, IE, IL, IN, IS, IT, JP, KG, KR, KZ, LT, LU, LV, MD, MX, MY, NL, NO, PL, PT, RO, RS, RU,
|
||||
SE, SG, SI, SK, TH, TR, TW, UA, US, UZ, VN, ZA,
|
||||
}
|
||||
|
||||
export const COUNTRY_CODES = Object.keys(FLAGS)
|
||||
|
||||
/** Локализованное имя страны по ISO-коду (Intl), без собственных переводов. */
|
||||
export function countryName(code: string, language: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([language], { type: 'region' }).of(code) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,11 @@ const resources = {
|
||||
empty: 'У вас пока нет конфигов. Создайте первый.',
|
||||
create: 'Создать конфиг',
|
||||
selectLocation: 'Выберите локацию',
|
||||
load: { low: 'Свободно', medium: 'Средняя загрузка', high: 'Загружен' },
|
||||
nodeConfigs_one: '{{count}} конфиг на сервере',
|
||||
nodeConfigs_few: '{{count}} конфига на сервере',
|
||||
nodeConfigs_many: '{{count}} конфигов на сервере',
|
||||
nodeConfigs_other: '{{count}} конфига на сервере',
|
||||
location: 'Локация',
|
||||
label: 'Метка (необязательно)',
|
||||
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
||||
@@ -466,6 +471,9 @@ const resources = {
|
||||
enabled: 'Включена',
|
||||
disabled: 'Отключена',
|
||||
notifyOnStatusChange: 'Уведомлять в Telegram при смене статуса (онлайн/офлайн)',
|
||||
capacity: 'Ёмкость (конфигов)',
|
||||
capacityHint:
|
||||
'Сколько активных конфигов нода нормально тянет. Не лимит: по ней пользователям показывается загрузка в %, менее загруженные локации идут первыми. Пусто — показывается число конфигов.',
|
||||
inbounds: 'Инбаунды',
|
||||
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
||||
publish: 'Публикация',
|
||||
@@ -477,6 +485,8 @@ const resources = {
|
||||
publishSaved: 'Настройки публикации сохранены.',
|
||||
displayName: 'Отображаемое имя',
|
||||
allowedRoles: 'Доступно ролям',
|
||||
country: 'Страна (флаг у пользователей)',
|
||||
noCountry: 'Без флага',
|
||||
isPublishedLabel: 'Опубликовать инбаунд',
|
||||
optional: 'необязательно',
|
||||
passwordKeepUnchanged: 'оставьте пустым, чтобы не менять',
|
||||
@@ -688,6 +698,9 @@ const resources = {
|
||||
empty: "You don't have any configs yet. Create your first one.",
|
||||
create: 'Create config',
|
||||
selectLocation: 'Select location',
|
||||
load: { low: 'Low load', medium: 'Medium load', high: 'Busy' },
|
||||
nodeConfigs_one: '{{count}} config on server',
|
||||
nodeConfigs_other: '{{count}} configs on server',
|
||||
location: 'Location',
|
||||
label: 'Label (optional)',
|
||||
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
|
||||
@@ -1077,6 +1090,9 @@ const resources = {
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
notifyOnStatusChange: 'Notify in Telegram on status change (online/offline)',
|
||||
capacity: 'Capacity (configs)',
|
||||
capacityHint:
|
||||
'How many active configs the node handles comfortably. Not a limit: users see load in % based on it, less loaded locations come first. Empty — the config count is shown instead.',
|
||||
inbounds: 'Inbounds',
|
||||
noInbounds: 'No inbounds found — click "Sync".',
|
||||
publish: 'Publishing',
|
||||
@@ -1088,6 +1104,8 @@ const resources = {
|
||||
publishSaved: 'Publishing settings saved.',
|
||||
displayName: 'Display name',
|
||||
allowedRoles: 'Allowed for roles',
|
||||
country: 'Country (flag shown to users)',
|
||||
noCountry: 'No flag',
|
||||
isPublishedLabel: 'Publish inbound',
|
||||
optional: 'optional',
|
||||
passwordKeepUnchanged: 'leave blank to keep unchanged',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FLAGS } from '@/shared/lib/countries'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export function CountryFlag({ code, className }: { code: string | null | undefined; className?: string }) {
|
||||
if (!code) return null
|
||||
const Flag = FLAGS[code.toUpperCase()]
|
||||
if (!Flag) {
|
||||
return <span className={cn('font-mono text-xs text-muted-foreground', className)}>{code.toUpperCase()}</span>
|
||||
}
|
||||
return <Flag className={cn('inline-block h-3.5 w-auto shrink-0 rounded-[2px]', className)} aria-hidden />
|
||||
}
|
||||
Reference in new issue
Block a user