From bb5e59904f60f1309da290b7d300d1cc52bc8866 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:45:17 +0300 Subject: [PATCH] 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 --- .../Endpoints/InboundEndpoints.cs | 4 +- .../PnvPanel.Api/Endpoints/NodeEndpoints.cs | 6 +- .../src/PnvPanel.Api/Telegram/CountryFlag.cs | 19 + .../Telegram/PnvBotUpdateHandler.cs | 476 +++---- .../Admin/Configs/AdminVpnConfigDto.cs | 1 + .../Configs/ListAllConfigsQueryHandler.cs | 1 + .../Admin/Inbounds/InboundDto.cs | 2 + .../Admin/Inbounds/PublishInboundCommand.cs | 1 + .../Inbounds/PublishInboundCommandHandler.cs | 2 +- .../PublishInboundCommandValidator.cs | 4 + .../Admin/Nodes/NodeDto.cs | 2 + .../Admin/Nodes/UpdateNodeCommand.cs | 3 +- .../Admin/Nodes/UpdateNodeCommandHandler.cs | 1 + .../Admin/Nodes/UpdateNodeCommandValidator.cs | 1 + .../ListAvailableInboundsQuery.cs | 14 +- .../ListAvailableInboundsQueryHandler.cs | 63 +- .../Configs/VpnConfigDto.cs | 2 + .../src/PnvPanel.Domain/Inbounds/Inbound.cs | 13 +- backend/src/PnvPanel.Domain/Nodes/Node.cs | 13 + .../Configurations/InboundConfiguration.cs | 1 + ...06163554_AddInboundCountryCode.Designer.cs | 1138 ++++++++++++++++ .../20261006163554_AddInboundCountryCode.cs | 29 + ...20261006164246_AddNodeCapacity.Designer.cs | 1141 +++++++++++++++++ .../20261006164246_AddNodeCapacity.cs | 28 + .../Migrations/AppDbContextModelSnapshot.cs | 7 + .../ListAllConfigsQueryHandlerTests.cs | 2 +- .../PublishInboundCommandHandlerTests.cs | 10 +- .../PublishInboundCommandValidatorTests.cs | 35 + .../Nodes/SyncNodeCommandHandlerTests.cs | 2 +- .../Nodes/UpdateNodeCommandHandlerTests.cs | 11 +- .../CreateVpnConfigCommandHandlerTests.cs | 2 +- .../GetMyConfigsQueryHandlerTests.cs | 2 +- .../ListAvailableInboundsQueryHandlerTests.cs | 158 +++ .../Inbounds/InboundTests.cs | 24 +- .../PnvPanel.Domain.Tests/Nodes/NodeTests.cs | 22 + docs/api-design.md | 6 +- docs/domain-model.md | 4 +- docs/frontend.md | 6 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 8 + .../admin/inbounds/PublishInboundDialog.tsx | 38 +- frontend/src/features/admin/inbounds/api.ts | 3 +- .../features/admin/nodes/EditNodeDialog.tsx | 20 +- frontend/src/features/admin/nodes/api.ts | 2 + frontend/src/features/configs/ConfigCard.tsx | 6 +- .../features/configs/CreateConfigDialog.tsx | 39 +- frontend/src/routes/admin/configs.tsx | 6 +- frontend/src/shared/api/types.ts | 9 + frontend/src/shared/lib/countries.ts | 27 + frontend/src/shared/lib/i18n.ts | 18 + frontend/src/shared/ui/country-flag.tsx | 11 + 51 files changed, 3160 insertions(+), 284 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Telegram/CountryFlag.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandValidatorTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandlerTests.cs create mode 100644 frontend/src/shared/lib/countries.ts create mode 100644 frontend/src/shared/ui/country-flag.tsx diff --git a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs index 5850e1c..1deb461 100644 --- a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs @@ -41,6 +41,7 @@ public static class InboundEndpoints id, body.IsPublished, body.DisplayName, + body.CountryCode, body.AllowedRoleIds ?? [] ); var result = await sender.Send(command, cancellationToken); @@ -61,5 +62,6 @@ public static class InboundEndpoints public sealed record PublishInboundBody( bool IsPublished, string? DisplayName, - IReadOnlyList? AllowedRoleIds + IReadOnlyList? AllowedRoleIds, + string? CountryCode = null ); diff --git a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs index b2a13f4..984b6f2 100644 --- a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs @@ -57,7 +57,8 @@ public static class NodeEndpoints body.IsEnabled, body.NotifyOnStatusChange, body.Username, - body.Password + body.Password, + body.Capacity ); var result = await sender.Send(command, cancellationToken); return result.ToHttpResult(); @@ -101,5 +102,6 @@ public sealed record UpdateNodeBody( bool IsEnabled, bool NotifyOnStatusChange, string? Username, - string? Password + string? Password, + int? Capacity = null ); diff --git a/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs b/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs new file mode 100644 index 0000000..2c75cba --- /dev/null +++ b/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs @@ -0,0 +1,19 @@ +namespace PnvPanel.Api.Telegram; + +/// Emoji-флаг страны по ISO 3166-1 alpha-2 (пара regional indicator symbols). В Telegram +/// флаги рисуются на всех платформах, поэтому здесь emoji, а на сайте — SVG (Windows emoji-флаги +/// не рисует). +internal static class CountryFlag +{ + /// "DE" → "🇩🇪 " (с пробелом, для префикса к имени); пусто, если кода нет или он кривой. + public static string Prefix(string? countryCode) + { + if (countryCode is not { Length: 2 } || !countryCode.All(char.IsAsciiLetter)) + return string.Empty; + + var upper = countryCode.ToUpperInvariant(); + return char.ConvertFromUtf32(0x1F1E6 + upper[0] - 'A') + + char.ConvertFromUtf32(0x1F1E6 + upper[1] - 'A') + + " "; + } +} diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 14ea328..0556233 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -353,246 +353,246 @@ public sealed class PnvBotUpdateHandler( switch (parts[0]) { - case "login": - { - var result = - parts[1] == "approve" - ? await sender.Send( - new ApproveTelegramLoginCommand(requestId, fromId), - cancellationToken - ) - : await sender.Send( - new RejectTelegramLoginCommand(requestId, fromId), - cancellationToken - ); - - await botClient.AnswerCallbackQuery( - callback.Id, - result.IsSuccess ? "Готово" : result.Error.Message, - cancellationToken: cancellationToken - ); - if (callback.Message is not null) - { - var statusText = result.IsSuccess - ? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.") - : $"⚠️ {result.Error.Message}"; - var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; - await botClient.EditMessageText( - chatId.Value, - callback.Message.Id, - text, - parseMode: ParseMode.Html, - cancellationToken: cancellationToken - ); - } - - break; + case "login": + { + var result = + parts[1] == "approve" + ? await sender.Send( + new ApproveTelegramLoginCommand(requestId, fromId), + cancellationToken + ) + : await sender.Send( + new RejectTelegramLoginCommand(requestId, fromId), + cancellationToken + ); + + await botClient.AnswerCallbackQuery( + callback.Id, + result.IsSuccess ? "Готово" : result.Error.Message, + cancellationToken: cancellationToken + ); + if (callback.Message is not null) + { + var statusText = result.IsSuccess + ? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.") + : $"⚠️ {result.Error.Message}"; + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + text, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken + ); + } + + break; } - case "act": - { - if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) - { - await botClient.AnswerCallbackQuery( - callback.Id, - "Недостаточно прав.", - cancellationToken: cancellationToken - ); - return; - } - - var result = - parts[1] == "approve" - ? await sender.Send( - new ApproveActivationCommand(requestId), - cancellationToken - ) - : await sender.Send( - new RejectActivationCommand(requestId, Reason: null), - cancellationToken - ); - - await botClient.AnswerCallbackQuery( - callback.Id, - result.IsSuccess ? "Готово" : result.Error.Message, - cancellationToken: cancellationToken - ); - if (callback.Message is not null) - { - // Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки - // «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч. - // если его обработали в другом месте — на сайте или из другого чата). - var statusText = result.IsSuccess - ? ( - parts[1] == "approve" - ? "✅ Пользователь активирован." - : "❌ Запрос отклонён." - ) - : $"⚠️ {result.Error.Message}"; - var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; - await botClient.EditMessageText( - chatId.Value, - callback.Message.Id, - text, - parseMode: ParseMode.Html, - cancellationToken: cancellationToken - ); - } - - break; + case "act": + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery( + callback.Id, + "Недостаточно прав.", + cancellationToken: cancellationToken + ); + return; + } + + var result = + parts[1] == "approve" + ? await sender.Send( + new ApproveActivationCommand(requestId), + cancellationToken + ) + : await sender.Send( + new RejectActivationCommand(requestId, Reason: null), + cancellationToken + ); + + await botClient.AnswerCallbackQuery( + callback.Id, + result.IsSuccess ? "Готово" : result.Error.Message, + cancellationToken: cancellationToken + ); + if (callback.Message is not null) + { + // Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки + // «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч. + // если его обработали в другом месте — на сайте или из другого чата). + var statusText = result.IsSuccess + ? ( + parts[1] == "approve" + ? "✅ Пользователь активирован." + : "❌ Запрос отклонён." + ) + : $"⚠️ {result.Error.Message}"; + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + text, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken + ); + } + + break; } - case "erq": - { - if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) - { - await botClient.AnswerCallbackQuery( - callback.Id, - "Недостаточно прав.", - cancellationToken: cancellationToken - ); - return; - } - - var result = - parts[1] == "approve" - ? await sender.Send( - new ApproveExtensionRequestCommand(requestId), - cancellationToken - ) - : await sender.Send( - new RejectExtensionRequestCommand(requestId, Reason: null), - cancellationToken - ); - - await botClient.AnswerCallbackQuery( - callback.Id, - result.IsSuccess ? "Готово" : result.Error.Message, - cancellationToken: cancellationToken - ); - if (callback.Message is not null) - { - var statusText = result.IsSuccess - ? ( - parts[1] == "approve" - ? "✅ Заявка на продление одобрена." - : "❌ Заявка отклонена." - ) - : $"⚠️ {result.Error.Message}"; - var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; - await botClient.EditMessageText( - chatId.Value, - callback.Message.Id, - text, - parseMode: ParseMode.Html, - cancellationToken: cancellationToken - ); - } - - break; + case "erq": + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery( + callback.Id, + "Недостаточно прав.", + cancellationToken: cancellationToken + ); + return; + } + + var result = + parts[1] == "approve" + ? await sender.Send( + new ApproveExtensionRequestCommand(requestId), + cancellationToken + ) + : await sender.Send( + new RejectExtensionRequestCommand(requestId, Reason: null), + cancellationToken + ); + + await botClient.AnswerCallbackQuery( + callback.Id, + result.IsSuccess ? "Готово" : result.Error.Message, + cancellationToken: cancellationToken + ); + if (callback.Message is not null) + { + var statusText = result.IsSuccess + ? ( + parts[1] == "approve" + ? "✅ Заявка на продление одобрена." + : "❌ Заявка отклонена." + ) + : $"⚠️ {result.Error.Message}"; + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + text, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken + ); + } + + break; } - case "pay": - { - if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) - { - await botClient.AnswerCallbackQuery( - callback.Id, - "Недостаточно прав.", - cancellationToken: cancellationToken - ); - return; - } - - var result = - parts[1] == "approve" - ? await sender.Send( - new ConfirmPaymentRequestCommand(requestId), - cancellationToken - ) - : await sender.Send( - new RejectPaymentRequestCommand(requestId, Reason: null), - cancellationToken - ); - - await botClient.AnswerCallbackQuery( - callback.Id, - result.IsSuccess ? "Готово" : result.Error.Message, - cancellationToken: cancellationToken - ); - if (callback.Message is not null) - { - var statusText = result.IsSuccess - ? ( - parts[1] == "approve" - ? "✅ Оплата подтверждена." - : "❌ Оплата отклонена." - ) - : $"⚠️ {result.Error.Message}"; - var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; - await botClient.EditMessageText( - chatId.Value, - callback.Message.Id, - text, - parseMode: ParseMode.Html, - cancellationToken: cancellationToken - ); - } - - break; + case "pay": + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery( + callback.Id, + "Недостаточно прав.", + cancellationToken: cancellationToken + ); + return; + } + + var result = + parts[1] == "approve" + ? await sender.Send( + new ConfirmPaymentRequestCommand(requestId), + cancellationToken + ) + : await sender.Send( + new RejectPaymentRequestCommand(requestId, Reason: null), + cancellationToken + ); + + await botClient.AnswerCallbackQuery( + callback.Id, + result.IsSuccess ? "Готово" : result.Error.Message, + cancellationToken: cancellationToken + ); + if (callback.Message is not null) + { + var statusText = result.IsSuccess + ? ( + parts[1] == "approve" + ? "✅ Оплата подтверждена." + : "❌ Оплата отклонена." + ) + : $"⚠️ {result.Error.Message}"; + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + text, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken + ); + } + + break; } - case "cfg": - { - if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) - { - await botClient.AnswerCallbackQuery( - callback.Id, - "Telegram не привязан.", - cancellationToken: cancellationToken - ); - return; - } - - var linkResult = await sender.Send( - new GetConfigLinkQuery(requestId), - cancellationToken - ); - await botClient.AnswerCallbackQuery( - callback.Id, - cancellationToken: cancellationToken - ); - - if (!linkResult.IsSuccess) - { - await botClient.SendMessage( - chatId.Value, - $"Не удалось получить ссылку: {linkResult.Error.Message}", - cancellationToken: cancellationToken - ); - return; - } - - if (callback.Message is null) - break; - - // Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным - // блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением. - // Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте. - var text = - $"{Escape(callback.Message.Text ?? "")}\n\n{Escape(linkResult.Value.ConnectionString)}"; - var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? []) - .Where(row => row.All(b => b.CallbackData != data)) - .ToArray(); - - await botClient.EditMessageText( - chatId.Value, - callback.Message.Id, - text, - parseMode: ParseMode.Html, - replyMarkup: remainingRows.Length > 0 - ? new InlineKeyboardMarkup(remainingRows) - : null, - cancellationToken: cancellationToken - ); - - break; + case "cfg": + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery( + callback.Id, + "Telegram не привязан.", + cancellationToken: cancellationToken + ); + return; + } + + var linkResult = await sender.Send( + new GetConfigLinkQuery(requestId), + cancellationToken + ); + await botClient.AnswerCallbackQuery( + callback.Id, + cancellationToken: cancellationToken + ); + + if (!linkResult.IsSuccess) + { + await botClient.SendMessage( + chatId.Value, + $"Не удалось получить ссылку: {linkResult.Error.Message}", + cancellationToken: cancellationToken + ); + return; + } + + if (callback.Message is null) + break; + + // Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным + // блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением. + // Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте. + var text = + $"{Escape(callback.Message.Text ?? "")}\n\n{Escape(linkResult.Value.ConnectionString)}"; + var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? []) + .Where(row => row.All(b => b.CallbackData != data)) + .ToArray(); + + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + text, + parseMode: ParseMode.Html, + replyMarkup: remainingRows.Length > 0 + ? new InlineKeyboardMarkup(remainingRows) + : null, + cancellationToken: cancellationToken + ); + + break; } } } @@ -730,7 +730,7 @@ public sealed class PnvBotUpdateHandler( + string.Join( '\n', result.Value.Configs.Select(c => - $"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}" + $"• {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location} ({c.Protocol}) — {c.Status}" ) ); @@ -741,7 +741,7 @@ public sealed class PnvBotUpdateHandler( new[] { InlineKeyboardButton.WithCallbackData( - $"🔗 {c.Label ?? c.Location}", + $"🔗 {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location}", $"cfg:link:{c.Id}" ), } diff --git a/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs b/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs index 49bb562..b473096 100644 --- a/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs @@ -13,6 +13,7 @@ public sealed record AdminVpnConfigDto( string ClientEmail, VpnProtocol Protocol, string Location, + string? CountryCode, string NodeName, long UsedUpBytes, long UsedDownBytes, diff --git a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs index c98c3c3..9f25067 100644 --- a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs @@ -82,6 +82,7 @@ public sealed class ListAllConfigsQueryHandler( c.ClientEmail, c.Protocol, inbound?.DisplayName ?? inbound?.Remark ?? "?", + inbound?.CountryCode, node?.Name ?? "?", c.UsedUpBytes, c.UsedDownBytes, diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs index da4e1f4..785a162 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs @@ -12,6 +12,7 @@ public sealed record InboundDto( bool IsPublished, bool IsAvailable, string? DisplayName, + string? CountryCode, IReadOnlyList AllowedRoleIds, DateTimeOffset? LastSyncAt ) @@ -27,6 +28,7 @@ public sealed record InboundDto( inbound.IsPublished, inbound.IsAvailable, inbound.DisplayName, + inbound.CountryCode, inbound.AllowedRoleIds, inbound.LastSyncAt ); diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs index 8a7f9b0..2116b74 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs @@ -7,5 +7,6 @@ public sealed record PublishInboundCommand( Guid InboundId, bool IsPublished, string? DisplayName, + string? CountryCode, IReadOnlyList AllowedRoleIds ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs index d2d00d4..24792b2 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs @@ -22,7 +22,7 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre return Result.Failure(InboundErrors.NotFound); if (command.IsPublished) - inbound.Publish(command.DisplayName, command.AllowedRoleIds); + inbound.Publish(command.DisplayName, command.CountryCode, command.AllowedRoleIds); else inbound.Unpublish(); diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs index f91cb1f..74aa333 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs @@ -7,5 +7,9 @@ public sealed class PublishInboundCommandValidator : AbstractValidator x.DisplayName).MaximumLength(100); + RuleFor(x => x.CountryCode) + .Matches("^[A-Za-z]{2}$") + .When(x => !string.IsNullOrWhiteSpace(x.CountryCode)) + .WithMessage("CountryCode must be an ISO 3166-1 alpha-2 code."); } } diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs index d5dea8f..cc70a63 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs @@ -12,6 +12,7 @@ public sealed record NodeDto( NodeStatus Status, bool IsEnabled, bool NotifyOnStatusChange, + int? Capacity, DateTimeOffset? LastSyncAt ) { @@ -25,6 +26,7 @@ public sealed record NodeDto( node.Status, node.IsEnabled, node.NotifyOnStatusChange, + node.Capacity, node.LastSyncAt ); } diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs index 79be9cb..e18cf60 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs @@ -11,5 +11,6 @@ public sealed record UpdateNodeCommand( bool IsEnabled, bool NotifyOnStatusChange, string? Username, - string? Password + string? Password, + int? Capacity ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs index a23da58..1929c9f 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs @@ -48,6 +48,7 @@ public sealed class UpdateNodeCommandHandler( node.Disable(); node.SetNotifyOnStatusChange(command.NotifyOnStatusChange); + node.SetCapacity(command.Capacity); var credentialsChanged = !string.IsNullOrWhiteSpace(command.Username) diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs index 86508da..71ac475 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs @@ -10,5 +10,6 @@ public sealed class UpdateNodeCommandValidator : AbstractValidator x.BaseAddress).NotEmpty().MaximumLength(500); RuleFor(x => x.Location).MaximumLength(100); RuleFor(x => x.Username).MaximumLength(200); + RuleFor(x => x.Capacity).GreaterThan(0).When(x => x.Capacity is not null); } } diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs index d2228d2..90e5e67 100644 --- a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs @@ -9,5 +9,15 @@ public sealed record ListAvailableInboundsQuery : IQuery>>, IRequiresActivation; -/// Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui. -public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol); +/// Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui. +/// Загрузка — по ноде целиком (все её инбаунды делят один сервер), по активным конфигам. Если у +/// ноды задана ёмкость — отдаём только процент (, может быть больше 100: +/// ёмкость не лимит), абсолютное число не раскрываем; иначе — только . +public sealed record AvailableInboundDto( + Guid InboundId, + string DisplayName, + VpnProtocol Protocol, + string? CountryCode, + int? LoadPercent, + int? NodeConfigCount +); diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs index 7f5911f..154888c 100644 --- a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs @@ -3,6 +3,7 @@ using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using LiteCqrs; using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Configs.ListAvailableInbounds; @@ -24,22 +25,62 @@ public sealed class ListAvailableInboundsQueryHandler( if (profile is null) return Result.Failure>(AuthErrors.Unauthorized); - var enabledNodeIds = dbContext - .Nodes.AsNoTracking() - .Where(n => n.IsEnabled) - .Select(n => n.Id); + var enabledNodes = dbContext.Nodes.AsNoTracking().Where(n => n.IsEnabled); var inbounds = await dbContext .Inbounds.AsNoTracking() - .Where(i => - i.IsPublished - && enabledNodeIds.Contains(i.NodeId) - && i.AllowedRoleIds.Contains(profile.RoleId) + .Where(i => i.IsPublished && i.AllowedRoleIds.Contains(profile.RoleId)) + .Join( + enabledNodes, + i => i.NodeId, + n => n.Id, + (i, n) => new + { + i.Id, + i.NodeId, + DisplayName = i.DisplayName ?? i.Remark, + i.Protocol, + i.CountryCode, + n.Capacity, + } ) - .OrderBy(i => i.DisplayName ?? i.Remark) - .Select(i => new AvailableInboundDto(i.Id, i.DisplayName ?? i.Remark, i.Protocol)) .ToListAsync(cancellationToken); - return Result.Success>(inbounds); + var nodeIds = inbounds.Select(i => i.NodeId).Distinct().ToList(); + // Считаем по всем инбаундам ноды (в т.ч. неопубликованным и недоступным этой роли) — сервер + // грузят все его клиенты, а не только видимые этому пользователю. + var activeCountByNode = await dbContext + .VpnConfigs.AsNoTracking() + .Where(c => c.Status == ConfigStatus.Active) + .Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => i.NodeId) + .Where(nodeId => nodeIds.Contains(nodeId)) + .GroupBy(nodeId => nodeId) + .Select(g => new { NodeId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.NodeId, x => x.Count, cancellationToken); + + var result = inbounds + .Select(i => + { + var count = activeCountByNode.GetValueOrDefault(i.NodeId); + var loadPercent = i.Capacity is { } capacity + ? (int)Math.Round(count * 100.0 / capacity) + : (int?)null; + return new AvailableInboundDto( + i.Id, + i.DisplayName, + i.Protocol, + i.CountryCode, + loadPercent, + loadPercent is null ? count : null + ); + }) + // Менее загруженные — первыми. Ноды с ёмкостью (сравнимый процент) идут раньше нод без + // неё: сырое число конфигов с процентом не сравнить. + .OrderBy(i => i.LoadPercent is null) + .ThenBy(i => i.LoadPercent ?? i.NodeConfigCount) + .ThenBy(i => i.DisplayName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + return Result.Success>(result); } } diff --git a/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs index a85ec48..f51e449 100644 --- a/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs +++ b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs @@ -15,6 +15,7 @@ public sealed record VpnConfigDto( string ClientEmail, VpnProtocol Protocol, string Location, + string? CountryCode, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, @@ -29,6 +30,7 @@ public sealed record VpnConfigDto( config.ClientEmail, config.Protocol, inbound.DisplayName ?? inbound.Remark, + inbound.CountryCode, config.UsedUpBytes, config.UsedDownBytes, config.ExpiresAt, diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs index d87c4eb..bf5a8e2 100644 --- a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -18,6 +18,10 @@ public sealed class Inbound : Entity public bool IsPublished { get; private set; } public bool IsAvailable { get; private set; } = true; public string? DisplayName { get; private set; } + + /// ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед + /// именем инбаунда. Только для отображения, на подключение не влияет. + public string? CountryCode { get; private set; } public IReadOnlyList AllowedRoleIds { get; private set; } = []; public DateTimeOffset? LastSyncAt { get; private set; } @@ -57,11 +61,18 @@ public sealed class Inbound : Entity IsAvailable = true; } - public void Publish(string? displayName, IReadOnlyCollection allowedRoleIds) + public void Publish( + string? displayName, + string? countryCode, + IReadOnlyCollection allowedRoleIds + ) { IsPublished = true; IsAvailable = true; DisplayName = displayName; + CountryCode = string.IsNullOrWhiteSpace(countryCode) + ? null + : countryCode.Trim().ToUpperInvariant(); AllowedRoleIds = allowedRoleIds.Distinct().ToList(); } diff --git a/backend/src/PnvPanel.Domain/Nodes/Node.cs b/backend/src/PnvPanel.Domain/Nodes/Node.cs index 4efa52d..cfb9373 100644 --- a/backend/src/PnvPanel.Domain/Nodes/Node.cs +++ b/backend/src/PnvPanel.Domain/Nodes/Node.cs @@ -20,6 +20,11 @@ public sealed class Node : Entity /// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями. public int ConsecutiveProbeFailures { get; private set; } + /// Ёмкость ноды — сколько активных конфигов она нормально тянет, задаёт админ. Не лимит: + /// только знаменатель для загрузки, которую пользователь видит при выборе локации + /// (ListAvailableInbounds). null — не задана, тогда пользователю показывается число конфигов. + public int? Capacity { get; private set; } + public bool IsEnabled { get; private set; } public bool NotifyOnStatusChange { get; private set; } public DateTimeOffset? LastSyncAt { get; private set; } @@ -73,6 +78,14 @@ public sealed class Node : Entity ? baseAddress : new UriBuilder(baseAddress) { Path = baseAddress.AbsolutePath + "/" }.Uri; + public void SetCapacity(int? capacity) + { + if (capacity is <= 0) + throw new DomainException("Ёмкость ноды должна быть положительной."); + + Capacity = capacity; + } + public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials; /// Слать админам в Telegram уведомление при каждом переходе Online↔Offline (см. diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs index 551a50c..66946a4 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs @@ -15,6 +15,7 @@ public class InboundConfiguration : IEntityTypeConfiguration builder.Property(x => x.Protocol).HasConversion().HasMaxLength(32); builder.Property(x => x.Remark).IsRequired().HasMaxLength(200); builder.Property(x => x.DisplayName).HasMaxLength(100); + builder.Property(x => x.CountryCode).HasMaxLength(2); builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]"); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.Designer.cs new file mode 100644 index 0000000..def14f7 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.Designer.cs @@ -0,0 +1,1138 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261006163554_AddInboundCountryCode")] + partial class AddInboundCountryCode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Period") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Media.MediaImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UploadedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("MediaImages", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ConsecutiveProbeFailures") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NotifyOnStatusChange") + .HasColumnType("boolean"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigCount") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Plans", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("PricingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PricingSettingsId", "MinConfigs") + .IsUnique(); + + b.ToTable("PricingDiscountTiers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedDays") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ConfigQuota") + .HasColumnType("integer"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.cs new file mode 100644 index 0000000..47afa41 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006163554_AddInboundCountryCode.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddInboundCountryCode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CountryCode", + table: "Inbounds", + type: "character varying(2)", + maxLength: 2, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CountryCode", + table: "Inbounds"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.Designer.cs new file mode 100644 index 0000000..82e8754 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.Designer.cs @@ -0,0 +1,1141 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261006164246_AddNodeCapacity")] + partial class AddNodeCapacity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Period") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Media.MediaImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UploadedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("MediaImages", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("ConsecutiveProbeFailures") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NotifyOnStatusChange") + .HasColumnType("boolean"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigCount") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Plans", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("PricingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PricingSettingsId", "MinConfigs") + .IsUnique(); + + b.ToTable("PricingDiscountTiers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedDays") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ConfigQuota") + .HasColumnType("integer"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.cs new file mode 100644 index 0000000..773308b --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20261006164246_AddNodeCapacity.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNodeCapacity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Capacity", + table: "Nodes", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Capacity", + table: "Nodes"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 18ac2dd..9e616d0 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -426,6 +426,10 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .IsRequired() .HasColumnType("uuid[]"); + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + b.Property("DisplayName") .HasMaxLength(100) .HasColumnType("character varying(100)"); @@ -597,6 +601,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("Capacity") + .HasColumnType("integer"); + b.Property("ConsecutiveProbeFailures") .ValueGeneratedOnAdd() .HasColumnType("integer") diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs index db61352..1f24f1c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs @@ -26,7 +26,7 @@ public class ListAllConfigsQueryHandlerTests null ); var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish("Germany", []); + inbound.Publish("Germany", null, []); var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config"); dbContext.Nodes.Add(node); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs index eb3d502..a61138f 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs @@ -22,7 +22,7 @@ public class PublishInboundCommandHandlerTests var roleId = Guid.NewGuid(); var handler = new PublishInboundCommandHandler(dbContext, _currentUser); - var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId]); + var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", "de", [roleId]); var result = await handler.Handle(command, CancellationToken.None); @@ -31,6 +31,8 @@ public class PublishInboundCommandHandlerTests Assert.Equal("EU Fast", inbound.DisplayName); Assert.Contains(roleId, inbound.AllowedRoleIds); Assert.Equal("EU Fast", result.Value.DisplayName); + Assert.Equal("DE", inbound.CountryCode); + Assert.Equal("DE", result.Value.CountryCode); } [Fact] @@ -38,13 +40,13 @@ public class PublishInboundCommandHandlerTests { using var dbContext = InMemoryDbContextFactory.Create(); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish("EU Fast", [Guid.NewGuid()]); + inbound.Publish("EU Fast", null, [Guid.NewGuid()]); dbContext.Inbounds.Add(inbound); await dbContext.SaveChangesAsync(CancellationToken.None); var handler = new PublishInboundCommandHandler(dbContext, _currentUser); - var command = new PublishInboundCommand(inbound.Id, false, null, []); + var command = new PublishInboundCommand(inbound.Id, false, null, null, []); var result = await handler.Handle(command, CancellationToken.None); @@ -59,7 +61,7 @@ public class PublishInboundCommandHandlerTests var handler = new PublishInboundCommandHandler(dbContext, _currentUser); - var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", []); + var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", null, []); var result = await handler.Handle(command, CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandValidatorTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandValidatorTests.cs new file mode 100644 index 0000000..6d9cf33 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandValidatorTests.cs @@ -0,0 +1,35 @@ +using PnvPanel.Application.Admin.Inbounds; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Inbounds; + +public class PublishInboundCommandValidatorTests +{ + private readonly PublishInboundCommandValidator _validator = new(); + + private static PublishInboundCommand Command(string? countryCode) => + new(Guid.NewGuid(), true, "EU Fast", countryCode, []); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("DE")] + [InlineData("nl")] + public void Validate_WithEmptyOrAlpha2CountryCode_IsValid(string? countryCode) + { + var result = _validator.Validate(Command(countryCode)); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("DEU")] + [InlineData("D")] + [InlineData("1E")] + public void Validate_WithInvalidCountryCode_IsInvalid(string countryCode) + { + var result = _validator.Validate(Command(countryCode)); + + Assert.False(result.IsValid); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs index 9507b4f..4d9b70c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs @@ -55,7 +55,7 @@ public class SyncNodeCommandHandlerTests using var dbContext = InMemoryDbContextFactory.Create(); var node = CreateNode(); var inbound = Inbound.FromRemote(node.Id, "old-id", VpnProtocol.Vless, "old", 443); - inbound.Publish("Old", [Guid.NewGuid()]); + inbound.Publish("Old", null, [Guid.NewGuid()]); var config = VpnConfig.Create(Guid.NewGuid(), inbound.Id, VpnProtocol.Vless, null); config.AssignRemoteClient("external-id"); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs index 2cde325..ef0c68f 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs @@ -40,6 +40,7 @@ public class UpdateNodeCommandHandlerTests true, false, null, + null, null ); @@ -69,6 +70,7 @@ public class UpdateNodeCommandHandlerTests true, false, null, + null, null ); @@ -90,7 +92,7 @@ public class UpdateNodeCommandHandlerTests var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); - var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, false, null, null); + var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, false, null, null, null); var result = await handler.Handle(command, CancellationToken.None); @@ -121,6 +123,7 @@ public class UpdateNodeCommandHandlerTests true, false, null, + null, null ); @@ -152,7 +155,8 @@ public class UpdateNodeCommandHandlerTests true, false, "new-admin", - "new-password" + "new-password", + null ); var result = await handler.Handle(command, CancellationToken.None); @@ -181,6 +185,7 @@ public class UpdateNodeCommandHandlerTests true, false, "new-admin", + null, null ); @@ -211,6 +216,7 @@ public class UpdateNodeCommandHandlerTests true, true, null, + null, null ); @@ -235,6 +241,7 @@ public class UpdateNodeCommandHandlerTests true, false, null, + null, null ); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs index 70a623d..de07a88 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs @@ -52,7 +52,7 @@ public class CreateVpnConfigCommandHandlerTests null ); var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish(null, [roleId]); + inbound.Publish(null, null, [roleId]); dbContext.Nodes.Add(node); dbContext.Inbounds.Add(inbound); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs index e8dff5c..3b29f33 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -22,7 +22,7 @@ public class GetMyConfigsQueryHandlerTests var otherUserId = Guid.NewGuid(); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish("My inbound", []); + inbound.Publish("My inbound", null, []); var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active"); var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked"); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandlerTests.cs new file mode 100644 index 0000000..bbe5846 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandlerTests.cs @@ -0,0 +1,158 @@ +using NSubstitute; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs.ListAvailableInbounds; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using Xunit; + +namespace PnvPanel.Application.Tests.Configs.ListAvailableInbounds; + +public class ListAvailableInboundsQueryHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _roleId = Guid.NewGuid(); + + public ListAvailableInboundsQueryHandlerTests() + { + var profile = new CurrentUserProfile( + _userId, + "alice", + _roleId, + "user", + true, + false, + 5, + null, + RoleQuota.Unlimited, + "sub-token", + false, + null, + false + ); + _identityService.GetProfileAsync(_userId, Arg.Any()).Returns(profile); + } + + private static Node CreateNode(string name, int? capacity) + { + var node = Node.Register( + name, + new Uri($"https://{name}.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + node.SetCapacity(capacity); + return node; + } + + private Inbound CreatePublishedInbound(Node node, string remoteId, string displayName) + { + var inbound = Inbound.FromRemote(node.Id, remoteId, VpnProtocol.Vless, "remark", 443); + inbound.Publish(displayName, "DE", [_roleId]); + return inbound; + } + + private static IEnumerable ActiveConfigs(Inbound inbound, int count) => + Enumerable + .Range(0, count) + .Select(_ => VpnConfig.Create(Guid.NewGuid(), inbound.Id, VpnProtocol.Vless, null)) + .ToList(); + + private ListAvailableInboundsQueryHandler CreateHandler(IAppDbContext dbContext) => + new(dbContext, _identityService, FakeCurrentUser.Authenticated(_userId)); + + [Fact] + public async Task Handle_WithCapacity_ReturnsPercentOfAllActiveConfigsOnNode() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode("node-1", capacity: 10); + var published = CreatePublishedInbound(node, "1", "Germany"); + // Неопубликованный инбаунд той же ноды тоже грузит сервер. + var hidden = Inbound.FromRemote(node.Id, "2", VpnProtocol.Vless, "hidden", 444); + var revoked = VpnConfig.Create(Guid.NewGuid(), published.Id, VpnProtocol.Vless, null); + revoked.Revoke(); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.AddRange(published, hidden); + dbContext.VpnConfigs.AddRange(ActiveConfigs(published, 2)); + dbContext.VpnConfigs.AddRange(ActiveConfigs(hidden, 1)); + dbContext.VpnConfigs.Add(revoked); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var result = await CreateHandler(dbContext) + .Handle(new ListAvailableInboundsQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value); + Assert.Equal(30, item.LoadPercent); + Assert.Null(item.NodeConfigCount); + Assert.Equal("DE", item.CountryCode); + } + + [Fact] + public async Task Handle_WithoutCapacity_ReturnsConfigCount() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode("node-1", capacity: null); + var inbound = CreatePublishedInbound(node, "1", "Germany"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(ActiveConfigs(inbound, 4)); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var result = await CreateHandler(dbContext) + .Handle(new ListAvailableInboundsQuery(), CancellationToken.None); + + var item = Assert.Single(result.Value); + Assert.Null(item.LoadPercent); + Assert.Equal(4, item.NodeConfigCount); + } + + [Fact] + public async Task Handle_SortsByLoadAscending_NodesWithCapacityFirst() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var busy = CreateNode("busy", capacity: 10); + var idle = CreateNode("idle", capacity: 100); + var noCapacity = CreateNode("no-capacity", capacity: null); + var busyInbound = CreatePublishedInbound(busy, "1", "A busy"); + var idleInbound = CreatePublishedInbound(idle, "1", "B idle"); + var noCapacityInbound = CreatePublishedInbound(noCapacity, "1", "C unknown"); + + dbContext.Nodes.AddRange(busy, idle, noCapacity); + dbContext.Inbounds.AddRange(busyInbound, idleInbound, noCapacityInbound); + dbContext.VpnConfigs.AddRange(ActiveConfigs(busyInbound, 8)); + dbContext.VpnConfigs.AddRange(ActiveConfigs(idleInbound, 8)); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var result = await CreateHandler(dbContext) + .Handle(new ListAvailableInboundsQuery(), CancellationToken.None); + + Assert.Equal( + ["B idle", "A busy", "C unknown"], + result.Value.Select(i => i.DisplayName).ToArray() + ); + } + + [Fact] + public async Task Handle_ExcludesInboundsOfDisabledNodes() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode("node-1", capacity: null); + node.Disable(); + var inbound = CreatePublishedInbound(node, "1", "Germany"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var result = await CreateHandler(dbContext) + .Handle(new ListAvailableInboundsQuery(), CancellationToken.None); + + Assert.Empty(result.Value); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs index 5aafdf2..a55c42d 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs @@ -40,7 +40,7 @@ public class InboundTests var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); var roleId = Guid.NewGuid(); - inbound.Publish("Germany (VLESS)", [roleId, roleId]); + inbound.Publish("Germany (VLESS)", null, [roleId, roleId]); Assert.True(inbound.IsPublished); Assert.Equal("Germany (VLESS)", inbound.DisplayName); @@ -48,12 +48,26 @@ public class InboundTests Assert.Contains(roleId, inbound.AllowedRoleIds); } + [Theory] + [InlineData("de", "DE")] + [InlineData(" NL ", "NL")] + [InlineData("", null)] + [InlineData(null, null)] + public void Publish_NormalizesCountryCode(string? input, string? expected) + { + var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); + + inbound.Publish("Germany", input, []); + + Assert.Equal(expected, inbound.CountryCode); + } + [Fact] public void Unpublish_SetsIsPublishedFalseButKeepsRoles() { var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); var roleId = Guid.NewGuid(); - inbound.Publish("Germany", [roleId]); + inbound.Publish("Germany", null, [roleId]); inbound.Unpublish(); @@ -73,7 +87,7 @@ public class InboundTests public void MarkUnavailable_SetsIsAvailableAndIsPublishedFalse() { var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); - inbound.Publish("Germany", [Guid.NewGuid()]); + inbound.Publish("Germany", null, [Guid.NewGuid()]); inbound.MarkUnavailable(); @@ -100,7 +114,7 @@ public class InboundTests var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); inbound.MarkUnavailable(); - inbound.Publish("Germany", [Guid.NewGuid()]); + inbound.Publish("Germany", null, [Guid.NewGuid()]); Assert.True(inbound.IsAvailable); } @@ -111,7 +125,7 @@ public class InboundTests var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); var roleId = Guid.NewGuid(); var otherRoleId = Guid.NewGuid(); - inbound.Publish("Germany", [roleId, otherRoleId]); + inbound.Publish("Germany", null, [roleId, otherRoleId]); inbound.RemoveAllowedRole(roleId); diff --git a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs index dda096e..c4382cc 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs @@ -212,4 +212,26 @@ public class NodeTests Assert.DoesNotContain("protected-secret", text); Assert.Contains("REDACTED", text); } + + [Theory] + [InlineData(50)] + [InlineData(null)] + public void SetCapacity_WithPositiveOrNull_SetsCapacity(int? capacity) + { + var node = Node.Register("Germany-1", new Uri("https://de1.example.com"), Credentials, null); + + node.SetCapacity(capacity); + + Assert.Equal(capacity, node.Capacity); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void SetCapacity_WithNonPositive_Throws(int capacity) + { + var node = Node.Register("Germany-1", new Uri("https://de1.example.com"), Credentials, null); + + Assert.Throws(() => node.SetCapacity(capacity)); + } } diff --git a/docs/api-design.md b/docs/api-design.md index 73941b7..e06734c 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -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 diff --git a/docs/domain-model.md b/docs/domain-model.md index 93b8b3e..4adc9b6 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -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) diff --git a/docs/frontend.md b/docs/frontend.md index d4036b7..22b3e47 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -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 пользователь открывает отдельно. diff --git a/frontend/package.json b/frontend/package.json index a4c398c..0a65834 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "country-flag-icons": "^1.6.20", "i18next": "^26.3.4", "lucide-react": "^1.22.0", "qrcode.react": "^4.2.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2660439..783dc13 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + country-flag-icons: + specifier: ^1.6.20 + version: 1.6.20 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@6.0.3) @@ -1232,6 +1235,9 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3214,6 +3220,8 @@ snapshots: cookie-es@3.1.1: {} + country-flag-icons@1.6.20: {} + csstype@3.2.3: {} d3-array@3.2.4: diff --git a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx index c33ee07..0a3bcac 100644 --- a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx +++ b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx @@ -6,11 +6,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { CountryFlag } from '@/shared/ui/country-flag' +import { COUNTRY_CODES, countryName } from '@/shared/lib/countries' import { HttpError } from '@/shared/api/client' import { listRoles } from '@/features/admin/roles/api' import type { InboundDto } from '@/shared/api/types' import { publishInbound } from './api' +// Radix Select не допускает value="" у SelectItem — «без флага» кодируем отдельным маркером. +const NO_COUNTRY = 'none' + export function PublishInboundDialog({ inbound, open, @@ -20,8 +26,9 @@ export function PublishInboundDialog({ open: boolean onOpenChange: (open: boolean) => void }) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const queryClient = useQueryClient() + const [countryCode, setCountryCode] = useState(inbound.countryCode ?? NO_COUNTRY) const [isPublished, setIsPublished] = useState(inbound.isPublished) const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark) const [selectedRoles, setSelectedRoles] = useState>(new Set(inbound.allowedRoleIds)) @@ -30,7 +37,13 @@ export function PublishInboundDialog({ const mutation = useMutation({ mutationFn: () => - publishInbound(inbound.id, isPublished, displayName.trim() || undefined, Array.from(selectedRoles)), + publishInbound( + inbound.id, + isPublished, + displayName.trim() || undefined, + countryCode === NO_COUNTRY ? undefined : countryCode, + Array.from(selectedRoles), + ), onSuccess: async () => { toast.success(t('admin.nodes.publishSaved')) await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] }) @@ -73,6 +86,27 @@ export function PublishInboundDialog({ setDisplayName(e.target.value)} /> +
+ + +
diff --git a/frontend/src/features/admin/inbounds/api.ts b/frontend/src/features/admin/inbounds/api.ts index fa7f863..fcbf51c 100644 --- a/frontend/src/features/admin/inbounds/api.ts +++ b/frontend/src/features/admin/inbounds/api.ts @@ -9,11 +9,12 @@ export function publishInbound( id: string, isPublished: boolean, displayName: string | undefined, + countryCode: string | undefined, allowedRoleIds: string[], ) { return apiRequest(`/admin/inbounds/${id}/publish`, { method: 'PUT', - body: { isPublished, displayName: displayName ?? null, allowedRoleIds }, + body: { isPublished, displayName: displayName ?? null, countryCode: countryCode ?? null, allowedRoleIds }, }) } diff --git a/frontend/src/features/admin/nodes/EditNodeDialog.tsx b/frontend/src/features/admin/nodes/EditNodeDialog.tsx index 4a3783c..aab5b5f 100644 --- a/frontend/src/features/admin/nodes/EditNodeDialog.tsx +++ b/frontend/src/features/admin/nodes/EditNodeDialog.tsx @@ -20,6 +20,9 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op const [notifyOnStatusChange, setNotifyOnStatusChange] = useState(node.notifyOnStatusChange) const [username, setUsername] = useState(node.username) const [password, setPassword] = useState('') + const [capacity, setCapacity] = useState(node.capacity?.toString() ?? '') + const parsedCapacity = capacity.trim() ? Number(capacity) : undefined + const capacityValid = parsedCapacity === undefined || (Number.isInteger(parsedCapacity) && parsedCapacity > 0) const mutation = useMutation({ mutationFn: () => @@ -32,6 +35,7 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op notifyOnStatusChange, username.trim() || undefined, password || undefined, + parsedCapacity, ), onSuccess: async () => { toast.success(t('admin.nodes.updated')) @@ -72,6 +76,20 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op setLocation(e.target.value)} />
+
+ + setCapacity(e.target.value)} + /> +

{t('admin.nodes.capacityHint')}

+
- diff --git a/frontend/src/features/admin/nodes/api.ts b/frontend/src/features/admin/nodes/api.ts index 73805d5..bdcedb6 100644 --- a/frontend/src/features/admin/nodes/api.ts +++ b/frontend/src/features/admin/nodes/api.ts @@ -18,6 +18,7 @@ export function updateNode( notifyOnStatusChange: boolean, username: string | undefined, password: string | undefined, + capacity: number | undefined, ) { return apiRequest(`/admin/nodes/${id}`, { method: 'PUT', @@ -29,6 +30,7 @@ export function updateNode( notifyOnStatusChange, username: username ?? null, password: password ?? null, + capacity: capacity ?? null, }, }) } diff --git a/frontend/src/features/configs/ConfigCard.tsx b/frontend/src/features/configs/ConfigCard.tsx index 2a3bb17..5d9a0af 100644 --- a/frontend/src/features/configs/ConfigCard.tsx +++ b/frontend/src/features/configs/ConfigCard.tsx @@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di import { HttpError } from '@/shared/api/client' import { formatBytes } from '@/shared/lib/format' import type { ConfigStatus, VpnConfigDto } from '@/shared/api/types' +import { CountryFlag } from '@/shared/ui/country-flag' import { getConfigLink, revokeConfig, rotateConfig } from './api' const STATUS_VARIANT: Record = { @@ -69,7 +70,10 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
{config.label ?? config.location} -

{config.location}

+

+ + {config.location} +

{config.protocol} diff --git a/frontend/src/features/configs/CreateConfigDialog.tsx b/frontend/src/features/configs/CreateConfigDialog.tsx index e02de73..d19f418 100644 --- a/frontend/src/features/configs/CreateConfigDialog.tsx +++ b/frontend/src/features/configs/CreateConfigDialog.tsx @@ -11,8 +11,41 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { HttpError } from '@/shared/api/client' import type { AvailableInboundDto } from '@/shared/api/types' +import { CountryFlag } from '@/shared/ui/country-flag' +import { cn } from '@/shared/lib/cn' import { createConfig } from './api' +// Пороги «светофора» загрузки ноды, % от ёмкости. +const LOAD_MEDIUM = 50 +const LOAD_HIGH = 80 + +function NodeLoad({ inbound }: { inbound: AvailableInboundDto }) { + const { t } = useTranslation() + if (inbound.loadPercent !== null) { + const level = inbound.loadPercent >= LOAD_HIGH ? 'high' : inbound.loadPercent >= LOAD_MEDIUM ? 'medium' : 'low' + return ( + + + {t(`configs.load.${level}`)} · {inbound.loadPercent}% + + ) + } + if (inbound.nodeConfigCount !== null) { + return ( + + {t('configs.nodeConfigs', { count: inbound.nodeConfigCount })} + + ) + } + return null +} + export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto[] }) { const { t } = useTranslation() const queryClient = useQueryClient() @@ -74,7 +107,11 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto {inbounds.map((inbound) => ( - {inbound.displayName} ({inbound.protocol}) + + + {inbound.displayName} ({inbound.protocol}) + + ))} diff --git a/frontend/src/routes/admin/configs.tsx b/frontend/src/routes/admin/configs.tsx index 9e5a871..69a4f08 100644 --- a/frontend/src/routes/admin/configs.tsx +++ b/frontend/src/routes/admin/configs.tsx @@ -13,6 +13,7 @@ import { listAllConfigs } from '@/features/admin/configs/api' import { forceRevokeConfig } from '@/features/admin/users/api' import { listNodes } from '@/features/admin/nodes/api' import type { ConfigStatus, VpnProtocol } from '@/shared/api/types' +import { CountryFlag } from '@/shared/ui/country-flag' const PROTOCOLS: VpnProtocol[] = ['Vless', 'Vmess', 'Trojan', 'Shadowsocks'] @@ -166,7 +167,10 @@ function AdminConfigsPage() { {config.protocol} - {config.nodeName} · {config.location} + + + {config.nodeName} · {config.location} + ↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 01796e0..b894c5e 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -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 } diff --git a/frontend/src/shared/lib/countries.ts b/frontend/src/shared/lib/countries.ts new file mode 100644 index 0000000..ab57a0e --- /dev/null +++ b/frontend/src/shared/lib/countries.ts @@ -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 = { + 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 + } +} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 977d098..c9c5707 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -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', diff --git a/frontend/src/shared/ui/country-flag.tsx b/frontend/src/shared/ui/country-flag.tsx new file mode 100644 index 0000000..f0e3357 --- /dev/null +++ b/frontend/src/shared/ui/country-flag.tsx @@ -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 {code.toUpperCase()} + } + return +}