Add load balancer marker for inbound country flag
CI / Backend (build + test) (push) Failing after 1m26s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

Reserved CountryCode "XX" (Inbound.BalancerCountryCode) marks an inbound
behind a load balancer whose exit country is unknown: the site shows a
Shuffle icon with a tooltip instead of a flag, the bot shows 🔀. Admin can
pick it in the publish dialog. Fits the existing column, no migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-17 01:57:30 +03:00
1 parent 81562b5c56
commit e979adca57
9 files changed
+48 -6

No files matched your search

@@ -1,3 +1,5 @@
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Api.Telegram; namespace PnvPanel.Api.Telegram;
/// <summary>Emoji-флаг страны по ISO 3166-1 alpha-2 (пара regional indicator symbols). В Telegram /// <summary>Emoji-флаг страны по ISO 3166-1 alpha-2 (пара regional indicator symbols). В Telegram
@@ -5,13 +7,17 @@ namespace PnvPanel.Api.Telegram;
/// не рисует).</summary> /// не рисует).</summary>
internal static class CountryFlag internal static class CountryFlag
{ {
/// <summary>"DE" → "🇩🇪 " (с пробелом, для префикса к имени); пусто, если кода нет или он кривой.</summary> /// <summary>"DE" → "🇩🇪 " (с пробелом, для префикса к имени); балансировщик
/// (Inbound.BalancerCountryCode) → "🔀 "; пусто, если кода нет или он кривой.</summary>
public static string Prefix(string? countryCode) public static string Prefix(string? countryCode)
{ {
if (countryCode is not { Length: 2 } || !countryCode.All(char.IsAsciiLetter)) if (countryCode is not { Length: 2 } || !countryCode.All(char.IsAsciiLetter))
return string.Empty; return string.Empty;
var upper = countryCode.ToUpperInvariant(); var upper = countryCode.ToUpperInvariant();
if (upper == Inbound.BalancerCountryCode)
return "🔀 ";
return char.ConvertFromUtf32(0x1F1E6 + upper[0] - 'A') return char.ConvertFromUtf32(0x1F1E6 + upper[0] - 'A')
+ char.ConvertFromUtf32(0x1F1E6 + upper[1] - 'A') + char.ConvertFromUtf32(0x1F1E6 + upper[1] - 'A')
+ " "; + " ";
@@ -19,8 +19,13 @@ public sealed class Inbound : Entity
public bool IsAvailable { get; private set; } = true; public bool IsAvailable { get; private set; } = true;
public string? DisplayName { get; private set; } public string? DisplayName { get; private set; }
/// <summary>Зарезервированный CountryCode: инбаунд за балансировщиком, страна выхода заранее
/// неизвестна — вместо флага показывается значок балансира. "XX" — из пользовательского диапазона
/// ISO 3166-1 (реальной стране не присвоен), поэтому влезает в то же поле без миграции.</summary>
public const string BalancerCountryCode = "XX";
/// <summary>ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед /// <summary>ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед
/// именем инбаунда. Только для отображения, на подключение не влияет.</summary> /// именем инбаунда, либо BalancerCountryCode. Только для отображения, на подключение не влияет.</summary>
public string? CountryCode { get; private set; } public string? CountryCode { get; private set; }
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = []; public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
public DateTimeOffset? LastSyncAt { get; private set; } public DateTimeOffset? LastSyncAt { get; private set; }
@@ -51,6 +51,7 @@ public class InboundTests
[Theory] [Theory]
[InlineData("de", "DE")] [InlineData("de", "DE")]
[InlineData(" NL ", "NL")] [InlineData(" NL ", "NL")]
[InlineData("xx", Inbound.BalancerCountryCode)]
[InlineData("", null)] [InlineData("", null)]
[InlineData(null, null)] [InlineData(null, null)]
public void Publish_NormalizesCountryCode(string? input, string? expected) public void Publish_NormalizesCountryCode(string? input, string? expected)
+1 -1
View File
@@ -83,7 +83,7 @@ AppUser
| `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) | | `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) |
| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) |
| `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» |
| `CountryCode` | `string?` | ISO 3166-1 alpha-2 (`DE`) — флаг перед именем у пользователя; задаётся при публикации, только отображение | | `CountryCode` | `string?` | ISO 3166-1 alpha-2 (`DE`) — флаг перед именем у пользователя; задаётся при публикации, только отображение. Зарезервированный `XX` (`Inbound.BalancerCountryCode`) — инбаунд за балансировщиком, страна выхода неизвестна: вместо флага значок балансира (на сайте — иконка `Shuffle` с подсказкой, в боте — 🔀) |
| `LastSyncAt` | `DateTimeOffset?` | | | `LastSyncAt` | `DateTimeOffset?` | |
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя
+2 -1
View File
@@ -91,7 +91,8 @@ frontend/
неактивированного — `ActivationGate` вместо дашборда. неактивированного — `ActivationGate` вместо дашборда.
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`, с флагом страны) + метка. Флаги — - **Создание конфига**: диалог — выбор инбаунда (по `displayName`, с флагом страны) + метка. Флаги —
SVG из `country-flag-icons` (курируемый список в `shared/lib/countries.ts`; emoji-флаги Windows не рисует), SVG из `country-flag-icons` (курируемый список в `shared/lib/countries.ts`; emoji-флаги Windows не рисует),
в Telegram-боте — emoji. У каждой локации — загрузка её ноды: «светофор» + % от `Node.Capacity` в Telegram-боте — emoji. Код `XX` (`BALANCER_COUNTRY_CODE`) — инбаунд за балансировщиком: вместо флага
иконка `Shuffle` с подсказкой «страна выхода может меняться» (в боте — 🔀). У каждой локации — загрузка её ноды: «светофор» + % от `Node.Capacity`
(<50 / <80 / ≥80), либо число конфигов на сервере, если ёмкость не задана; порядок — с сервера, (<50 / <80 / ≥80), либо число конфигов на сервере, если ёмкость не задана; порядок — с сервера,
от менее загруженных. Лимит одновременных IP от менее загруженных. Лимит одновременных IP
(`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) — (`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) —
@@ -8,7 +8,7 @@ import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { CountryFlag } from '@/shared/ui/country-flag' import { CountryFlag } from '@/shared/ui/country-flag'
import { COUNTRY_CODES, countryName } from '@/shared/lib/countries' import { BALANCER_COUNTRY_CODE, COUNTRY_CODES, countryName } from '@/shared/lib/countries'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import { listRoles } from '@/features/admin/roles/api' import { listRoles } from '@/features/admin/roles/api'
import type { InboundDto } from '@/shared/api/types' import type { InboundDto } from '@/shared/api/types'
@@ -94,6 +94,12 @@ export function PublishInboundDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NO_COUNTRY}>{t('admin.nodes.noCountry')}</SelectItem> <SelectItem value={NO_COUNTRY}>{t('admin.nodes.noCountry')}</SelectItem>
<SelectItem value={BALANCER_COUNTRY_CODE}>
<span className="flex items-center gap-2">
<CountryFlag code={BALANCER_COUNTRY_CODE} />
{t('admin.nodes.balancerCountry')}
</span>
</SelectItem>
{COUNTRY_CODES.map((code) => [code, countryName(code, i18n.language)] as const) {COUNTRY_CODES.map((code) => [code, countryName(code, i18n.language)] as const)
.sort(([, a], [, b]) => a.localeCompare(b, i18n.language)) .sort(([, a], [, b]) => a.localeCompare(b, i18n.language))
.map(([code, name]) => ( .map(([code, name]) => (
+4
View File
@@ -17,6 +17,10 @@ export const FLAGS: Record<string, FlagComponent> = {
export const COUNTRY_CODES = Object.keys(FLAGS) export const COUNTRY_CODES = Object.keys(FLAGS)
/** Зарезервированный код «инбаунд за балансировщиком, страна выхода неизвестна» (см.
* Inbound.BalancerCountryCode на бэкенде) — вместо флага рисуется значок балансира. */
export const BALANCER_COUNTRY_CODE = 'XX'
/** Локализованное имя страны по ISO-коду (Intl), без собственных переводов. */ /** Локализованное имя страны по ISO-коду (Intl), без собственных переводов. */
export function countryName(code: string, language: string): string { export function countryName(code: string, language: string): string {
try { try {
+8
View File
@@ -13,6 +13,9 @@ const resources = {
system: 'Системная', system: 'Системная',
unlimited: 'Без лимита', unlimited: 'Без лимита',
countries: {
balancer: 'Через балансировщик — страна выхода может меняться',
},
auth: { auth: {
loginTitle: 'Вход', loginTitle: 'Вход',
registerTitle: 'Регистрация', registerTitle: 'Регистрация',
@@ -487,6 +490,7 @@ const resources = {
allowedRoles: 'Доступно ролям', allowedRoles: 'Доступно ролям',
country: 'Страна (флаг у пользователей)', country: 'Страна (флаг у пользователей)',
noCountry: 'Без флага', noCountry: 'Без флага',
balancerCountry: 'Балансировщик (страна выхода не определена)',
isPublishedLabel: 'Опубликовать инбаунд', isPublishedLabel: 'Опубликовать инбаунд',
optional: 'необязательно', optional: 'необязательно',
passwordKeepUnchanged: 'оставьте пустым, чтобы не менять', passwordKeepUnchanged: 'оставьте пустым, чтобы не менять',
@@ -645,6 +649,9 @@ const resources = {
system: 'System', system: 'System',
unlimited: 'Unlimited', unlimited: 'Unlimited',
countries: {
balancer: 'Via load balancer — exit country may vary',
},
auth: { auth: {
loginTitle: 'Log in', loginTitle: 'Log in',
registerTitle: 'Register', registerTitle: 'Register',
@@ -1117,6 +1124,7 @@ const resources = {
allowedRoles: 'Allowed for roles', allowedRoles: 'Allowed for roles',
country: 'Country (flag shown to users)', country: 'Country (flag shown to users)',
noCountry: 'No flag', noCountry: 'No flag',
balancerCountry: 'Load balancer (exit country varies)',
isPublishedLabel: 'Publish inbound', isPublishedLabel: 'Publish inbound',
optional: 'optional', optional: 'optional',
passwordKeepUnchanged: 'leave blank to keep unchanged', passwordKeepUnchanged: 'leave blank to keep unchanged',
+12 -1
View File
@@ -1,8 +1,19 @@
import { FLAGS } from '@/shared/lib/countries' import { Shuffle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { BALANCER_COUNTRY_CODE, FLAGS } from '@/shared/lib/countries'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
export function CountryFlag({ code, className }: { code: string | null | undefined; className?: string }) { export function CountryFlag({ code, className }: { code: string | null | undefined; className?: string }) {
const { t } = useTranslation()
if (!code) return null if (!code) return null
if (code.toUpperCase() === BALANCER_COUNTRY_CODE) {
const label = t('countries.balancer')
return (
<span title={label} aria-label={label} className={cn('inline-flex shrink-0 text-sky-400', className)}>
<Shuffle className="h-3.5 w-3.5" aria-hidden />
</span>
)
}
const Flag = FLAGS[code.toUpperCase()] const Flag = FLAGS[code.toUpperCase()]
if (!Flag) { if (!Flag) {
return <span className={cn('font-mono text-xs text-muted-foreground', className)}>{code.toUpperCase()}</span> return <span className={cn('font-mono text-xs text-muted-foreground', className)}>{code.toUpperCase()}</span>