Add inbound country flags and node load in location picker
- Inbound.CountryCode (ISO alpha-2), set on publish; flag shown before the location name (SVG via country-flag-icons on the site, emoji in bot) - Node.Capacity (optional, not a limit); available inbounds report load percent of capacity or active config count, sorted least loaded first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7743977a1
commit
bb5e59904f
51 files changed
+3160
-284
No files matched your search
@@ -41,6 +41,7 @@ public static class InboundEndpoints
|
|||||||
id,
|
id,
|
||||||
body.IsPublished,
|
body.IsPublished,
|
||||||
body.DisplayName,
|
body.DisplayName,
|
||||||
|
body.CountryCode,
|
||||||
body.AllowedRoleIds ?? []
|
body.AllowedRoleIds ?? []
|
||||||
);
|
);
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
@@ -61,5 +62,6 @@ public static class InboundEndpoints
|
|||||||
public sealed record PublishInboundBody(
|
public sealed record PublishInboundBody(
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
IReadOnlyList<Guid>? AllowedRoleIds
|
IReadOnlyList<Guid>? AllowedRoleIds,
|
||||||
|
string? CountryCode = null
|
||||||
);
|
);
|
||||||
@@ -57,7 +57,8 @@ public static class NodeEndpoints
|
|||||||
body.IsEnabled,
|
body.IsEnabled,
|
||||||
body.NotifyOnStatusChange,
|
body.NotifyOnStatusChange,
|
||||||
body.Username,
|
body.Username,
|
||||||
body.Password
|
body.Password,
|
||||||
|
body.Capacity
|
||||||
);
|
);
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -101,5 +102,6 @@ public sealed record UpdateNodeBody(
|
|||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool NotifyOnStatusChange,
|
bool NotifyOnStatusChange,
|
||||||
string? Username,
|
string? Username,
|
||||||
string? Password
|
string? Password,
|
||||||
|
int? Capacity = null
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace PnvPanel.Api.Telegram;
|
||||||
|
|
||||||
|
/// <summary>Emoji-флаг страны по ISO 3166-1 alpha-2 (пара regional indicator symbols). В Telegram
|
||||||
|
/// флаги рисуются на всех платформах, поэтому здесь emoji, а на сайте — SVG (Windows emoji-флаги
|
||||||
|
/// не рисует).</summary>
|
||||||
|
internal static class CountryFlag
|
||||||
|
{
|
||||||
|
/// <summary>"DE" → "🇩🇪 " (с пробелом, для префикса к имени); пусто, если кода нет или он кривой.</summary>
|
||||||
|
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')
|
||||||
|
+ " ";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,246 +353,246 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
|
|
||||||
switch (parts[0])
|
switch (parts[0])
|
||||||
{
|
{
|
||||||
case "login":
|
case "login":
|
||||||
{
|
{
|
||||||
var result =
|
var result =
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? await sender.Send(
|
? await sender.Send(
|
||||||
new ApproveTelegramLoginCommand(requestId, fromId),
|
new ApproveTelegramLoginCommand(requestId, fromId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
: await sender.Send(
|
: await sender.Send(
|
||||||
new RejectTelegramLoginCommand(requestId, fromId),
|
new RejectTelegramLoginCommand(requestId, fromId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
if (callback.Message is not null)
|
if (callback.Message is not null)
|
||||||
{
|
{
|
||||||
var statusText = result.IsSuccess
|
var statusText = result.IsSuccess
|
||||||
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
|
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
|
||||||
: $"⚠️ {result.Error.Message}";
|
: $"⚠️ {result.Error.Message}";
|
||||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||||
await botClient.EditMessageText(
|
await botClient.EditMessageText(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
callback.Message.Id,
|
callback.Message.Id,
|
||||||
text,
|
text,
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "act":
|
case "act":
|
||||||
{
|
{
|
||||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
{
|
{
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
"Недостаточно прав.",
|
"Недостаточно прав.",
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result =
|
var result =
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? await sender.Send(
|
? await sender.Send(
|
||||||
new ApproveActivationCommand(requestId),
|
new ApproveActivationCommand(requestId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
: await sender.Send(
|
: await sender.Send(
|
||||||
new RejectActivationCommand(requestId, Reason: null),
|
new RejectActivationCommand(requestId, Reason: null),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
if (callback.Message is not null)
|
if (callback.Message is not null)
|
||||||
{
|
{
|
||||||
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
|
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
|
||||||
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
|
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
|
||||||
// если его обработали в другом месте — на сайте или из другого чата).
|
// если его обработали в другом месте — на сайте или из другого чата).
|
||||||
var statusText = result.IsSuccess
|
var statusText = result.IsSuccess
|
||||||
? (
|
? (
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? "✅ Пользователь активирован."
|
? "✅ Пользователь активирован."
|
||||||
: "❌ Запрос отклонён."
|
: "❌ Запрос отклонён."
|
||||||
)
|
)
|
||||||
: $"⚠️ {result.Error.Message}";
|
: $"⚠️ {result.Error.Message}";
|
||||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||||
await botClient.EditMessageText(
|
await botClient.EditMessageText(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
callback.Message.Id,
|
callback.Message.Id,
|
||||||
text,
|
text,
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "erq":
|
case "erq":
|
||||||
{
|
{
|
||||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
{
|
{
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
"Недостаточно прав.",
|
"Недостаточно прав.",
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result =
|
var result =
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? await sender.Send(
|
? await sender.Send(
|
||||||
new ApproveExtensionRequestCommand(requestId),
|
new ApproveExtensionRequestCommand(requestId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
: await sender.Send(
|
: await sender.Send(
|
||||||
new RejectExtensionRequestCommand(requestId, Reason: null),
|
new RejectExtensionRequestCommand(requestId, Reason: null),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
if (callback.Message is not null)
|
if (callback.Message is not null)
|
||||||
{
|
{
|
||||||
var statusText = result.IsSuccess
|
var statusText = result.IsSuccess
|
||||||
? (
|
? (
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? "✅ Заявка на продление одобрена."
|
? "✅ Заявка на продление одобрена."
|
||||||
: "❌ Заявка отклонена."
|
: "❌ Заявка отклонена."
|
||||||
)
|
)
|
||||||
: $"⚠️ {result.Error.Message}";
|
: $"⚠️ {result.Error.Message}";
|
||||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||||
await botClient.EditMessageText(
|
await botClient.EditMessageText(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
callback.Message.Id,
|
callback.Message.Id,
|
||||||
text,
|
text,
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "pay":
|
case "pay":
|
||||||
{
|
{
|
||||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
{
|
{
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
"Недостаточно прав.",
|
"Недостаточно прав.",
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result =
|
var result =
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? await sender.Send(
|
? await sender.Send(
|
||||||
new ConfirmPaymentRequestCommand(requestId),
|
new ConfirmPaymentRequestCommand(requestId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
: await sender.Send(
|
: await sender.Send(
|
||||||
new RejectPaymentRequestCommand(requestId, Reason: null),
|
new RejectPaymentRequestCommand(requestId, Reason: null),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
if (callback.Message is not null)
|
if (callback.Message is not null)
|
||||||
{
|
{
|
||||||
var statusText = result.IsSuccess
|
var statusText = result.IsSuccess
|
||||||
? (
|
? (
|
||||||
parts[1] == "approve"
|
parts[1] == "approve"
|
||||||
? "✅ Оплата подтверждена."
|
? "✅ Оплата подтверждена."
|
||||||
: "❌ Оплата отклонена."
|
: "❌ Оплата отклонена."
|
||||||
)
|
)
|
||||||
: $"⚠️ {result.Error.Message}";
|
: $"⚠️ {result.Error.Message}";
|
||||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||||
await botClient.EditMessageText(
|
await botClient.EditMessageText(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
callback.Message.Id,
|
callback.Message.Id,
|
||||||
text,
|
text,
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "cfg":
|
case "cfg":
|
||||||
{
|
{
|
||||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
{
|
{
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
"Telegram не привязан.",
|
"Telegram не привязан.",
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var linkResult = await sender.Send(
|
var linkResult = await sender.Send(
|
||||||
new GetConfigLinkQuery(requestId),
|
new GetConfigLinkQuery(requestId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
await botClient.AnswerCallbackQuery(
|
await botClient.AnswerCallbackQuery(
|
||||||
callback.Id,
|
callback.Id,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!linkResult.IsSuccess)
|
if (!linkResult.IsSuccess)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await botClient.SendMessage(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
$"Не удалось получить ссылку: {linkResult.Error.Message}",
|
$"Не удалось получить ссылку: {linkResult.Error.Message}",
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback.Message is null)
|
if (callback.Message is null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||||
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
||||||
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
|
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
|
||||||
var text =
|
var text =
|
||||||
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||||
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
|
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
|
||||||
.Where(row => row.All(b => b.CallbackData != data))
|
.Where(row => row.All(b => b.CallbackData != data))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
await botClient.EditMessageText(
|
await botClient.EditMessageText(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
callback.Message.Id,
|
callback.Message.Id,
|
||||||
text,
|
text,
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
replyMarkup: remainingRows.Length > 0
|
replyMarkup: remainingRows.Length > 0
|
||||||
? new InlineKeyboardMarkup(remainingRows)
|
? new InlineKeyboardMarkup(remainingRows)
|
||||||
: null,
|
: null,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,7 +730,7 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
+ string.Join(
|
+ string.Join(
|
||||||
'\n',
|
'\n',
|
||||||
result.Value.Configs.Select(c =>
|
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[]
|
new[]
|
||||||
{
|
{
|
||||||
InlineKeyboardButton.WithCallbackData(
|
InlineKeyboardButton.WithCallbackData(
|
||||||
$"🔗 {c.Label ?? c.Location}",
|
$"🔗 {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location}",
|
||||||
$"cfg:link:{c.Id}"
|
$"cfg:link:{c.Id}"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed record AdminVpnConfigDto(
|
|||||||
string ClientEmail,
|
string ClientEmail,
|
||||||
VpnProtocol Protocol,
|
VpnProtocol Protocol,
|
||||||
string Location,
|
string Location,
|
||||||
|
string? CountryCode,
|
||||||
string NodeName,
|
string NodeName,
|
||||||
long UsedUpBytes,
|
long UsedUpBytes,
|
||||||
long UsedDownBytes,
|
long UsedDownBytes,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public sealed class ListAllConfigsQueryHandler(
|
|||||||
c.ClientEmail,
|
c.ClientEmail,
|
||||||
c.Protocol,
|
c.Protocol,
|
||||||
inbound?.DisplayName ?? inbound?.Remark ?? "?",
|
inbound?.DisplayName ?? inbound?.Remark ?? "?",
|
||||||
|
inbound?.CountryCode,
|
||||||
node?.Name ?? "?",
|
node?.Name ?? "?",
|
||||||
c.UsedUpBytes,
|
c.UsedUpBytes,
|
||||||
c.UsedDownBytes,
|
c.UsedDownBytes,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed record InboundDto(
|
|||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
bool IsAvailable,
|
bool IsAvailable,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
|
string? CountryCode,
|
||||||
IReadOnlyList<Guid> AllowedRoleIds,
|
IReadOnlyList<Guid> AllowedRoleIds,
|
||||||
DateTimeOffset? LastSyncAt
|
DateTimeOffset? LastSyncAt
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,7 @@ public sealed record InboundDto(
|
|||||||
inbound.IsPublished,
|
inbound.IsPublished,
|
||||||
inbound.IsAvailable,
|
inbound.IsAvailable,
|
||||||
inbound.DisplayName,
|
inbound.DisplayName,
|
||||||
|
inbound.CountryCode,
|
||||||
inbound.AllowedRoleIds,
|
inbound.AllowedRoleIds,
|
||||||
inbound.LastSyncAt
|
inbound.LastSyncAt
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ public sealed record PublishInboundCommand(
|
|||||||
Guid InboundId,
|
Guid InboundId,
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
|
string? CountryCode,
|
||||||
IReadOnlyList<Guid> AllowedRoleIds
|
IReadOnlyList<Guid> AllowedRoleIds
|
||||||
) : ICommand<Result<InboundDto>>;
|
) : ICommand<Result<InboundDto>>;
|
||||||
@@ -22,7 +22,7 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
|
|||||||
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
||||||
|
|
||||||
if (command.IsPublished)
|
if (command.IsPublished)
|
||||||
inbound.Publish(command.DisplayName, command.AllowedRoleIds);
|
inbound.Publish(command.DisplayName, command.CountryCode, command.AllowedRoleIds);
|
||||||
else
|
else
|
||||||
inbound.Unpublish();
|
inbound.Unpublish();
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,9 @@ public sealed class PublishInboundCommandValidator : AbstractValidator<PublishIn
|
|||||||
public PublishInboundCommandValidator()
|
public PublishInboundCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.DisplayName).MaximumLength(100);
|
RuleFor(x => 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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ public sealed record NodeDto(
|
|||||||
NodeStatus Status,
|
NodeStatus Status,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool NotifyOnStatusChange,
|
bool NotifyOnStatusChange,
|
||||||
|
int? Capacity,
|
||||||
DateTimeOffset? LastSyncAt
|
DateTimeOffset? LastSyncAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,7 @@ public sealed record NodeDto(
|
|||||||
node.Status,
|
node.Status,
|
||||||
node.IsEnabled,
|
node.IsEnabled,
|
||||||
node.NotifyOnStatusChange,
|
node.NotifyOnStatusChange,
|
||||||
|
node.Capacity,
|
||||||
node.LastSyncAt
|
node.LastSyncAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,6 @@ public sealed record UpdateNodeCommand(
|
|||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool NotifyOnStatusChange,
|
bool NotifyOnStatusChange,
|
||||||
string? Username,
|
string? Username,
|
||||||
string? Password
|
string? Password,
|
||||||
|
int? Capacity
|
||||||
) : ICommand<Result<NodeDto>>;
|
) : ICommand<Result<NodeDto>>;
|
||||||
@@ -48,6 +48,7 @@ public sealed class UpdateNodeCommandHandler(
|
|||||||
node.Disable();
|
node.Disable();
|
||||||
|
|
||||||
node.SetNotifyOnStatusChange(command.NotifyOnStatusChange);
|
node.SetNotifyOnStatusChange(command.NotifyOnStatusChange);
|
||||||
|
node.SetCapacity(command.Capacity);
|
||||||
|
|
||||||
var credentialsChanged =
|
var credentialsChanged =
|
||||||
!string.IsNullOrWhiteSpace(command.Username)
|
!string.IsNullOrWhiteSpace(command.Username)
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public sealed class UpdateNodeCommandValidator : AbstractValidator<UpdateNodeCom
|
|||||||
RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500);
|
RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500);
|
||||||
RuleFor(x => x.Location).MaximumLength(100);
|
RuleFor(x => x.Location).MaximumLength(100);
|
||||||
RuleFor(x => x.Username).MaximumLength(200);
|
RuleFor(x => x.Username).MaximumLength(200);
|
||||||
|
RuleFor(x => x.Capacity).GreaterThan(0).When(x => x.Capacity is not null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
-2
@@ -9,5 +9,15 @@ public sealed record ListAvailableInboundsQuery
|
|||||||
: IQuery<Result<IReadOnlyList<AvailableInboundDto>>>,
|
: IQuery<Result<IReadOnlyList<AvailableInboundDto>>>,
|
||||||
IRequiresActivation;
|
IRequiresActivation;
|
||||||
|
|
||||||
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.</summary>
|
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.
|
||||||
public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol);
|
/// Загрузка — по ноде целиком (все её инбаунды делят один сервер), по активным конфигам. Если у
|
||||||
|
/// ноды задана ёмкость — отдаём только процент (<see cref="LoadPercent"/>, может быть больше 100:
|
||||||
|
/// ёмкость не лимит), абсолютное число не раскрываем; иначе — только <see cref="NodeConfigCount"/>.</summary>
|
||||||
|
public sealed record AvailableInboundDto(
|
||||||
|
Guid InboundId,
|
||||||
|
string DisplayName,
|
||||||
|
VpnProtocol Protocol,
|
||||||
|
string? CountryCode,
|
||||||
|
int? LoadPercent,
|
||||||
|
int? NodeConfigCount
|
||||||
|
);
|
||||||
+52
-11
@@ -3,6 +3,7 @@ using PnvPanel.Application.Auth;
|
|||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
|
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
|
||||||
|
|
||||||
@@ -24,22 +25,62 @@ public sealed class ListAvailableInboundsQueryHandler(
|
|||||||
if (profile is null)
|
if (profile is null)
|
||||||
return Result.Failure<IReadOnlyList<AvailableInboundDto>>(AuthErrors.Unauthorized);
|
return Result.Failure<IReadOnlyList<AvailableInboundDto>>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var enabledNodeIds = dbContext
|
var enabledNodes = dbContext.Nodes.AsNoTracking().Where(n => n.IsEnabled);
|
||||||
.Nodes.AsNoTracking()
|
|
||||||
.Where(n => n.IsEnabled)
|
|
||||||
.Select(n => n.Id);
|
|
||||||
|
|
||||||
var inbounds = await dbContext
|
var inbounds = await dbContext
|
||||||
.Inbounds.AsNoTracking()
|
.Inbounds.AsNoTracking()
|
||||||
.Where(i =>
|
.Where(i => i.IsPublished && i.AllowedRoleIds.Contains(profile.RoleId))
|
||||||
i.IsPublished
|
.Join(
|
||||||
&& enabledNodeIds.Contains(i.NodeId)
|
enabledNodes,
|
||||||
&& i.AllowedRoleIds.Contains(profile.RoleId)
|
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);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<AvailableInboundDto>>(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<IReadOnlyList<AvailableInboundDto>>(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ public sealed record VpnConfigDto(
|
|||||||
string ClientEmail,
|
string ClientEmail,
|
||||||
VpnProtocol Protocol,
|
VpnProtocol Protocol,
|
||||||
string Location,
|
string Location,
|
||||||
|
string? CountryCode,
|
||||||
long UsedUpBytes,
|
long UsedUpBytes,
|
||||||
long UsedDownBytes,
|
long UsedDownBytes,
|
||||||
DateTimeOffset? ExpiresAt,
|
DateTimeOffset? ExpiresAt,
|
||||||
@@ -29,6 +30,7 @@ public sealed record VpnConfigDto(
|
|||||||
config.ClientEmail,
|
config.ClientEmail,
|
||||||
config.Protocol,
|
config.Protocol,
|
||||||
inbound.DisplayName ?? inbound.Remark,
|
inbound.DisplayName ?? inbound.Remark,
|
||||||
|
inbound.CountryCode,
|
||||||
config.UsedUpBytes,
|
config.UsedUpBytes,
|
||||||
config.UsedDownBytes,
|
config.UsedDownBytes,
|
||||||
config.ExpiresAt,
|
config.ExpiresAt,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public sealed class Inbound : Entity
|
|||||||
public bool IsPublished { get; private set; }
|
public bool IsPublished { get; private set; }
|
||||||
public bool IsAvailable { get; private set; } = true;
|
public bool IsAvailable { get; private set; } = true;
|
||||||
public string? DisplayName { get; private set; }
|
public string? DisplayName { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед
|
||||||
|
/// именем инбаунда. Только для отображения, на подключение не влияет.</summary>
|
||||||
|
public string? CountryCode { get; private set; }
|
||||||
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
|
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
|
||||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||||
|
|
||||||
@@ -57,11 +61,18 @@ public sealed class Inbound : Entity
|
|||||||
IsAvailable = true;
|
IsAvailable = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Publish(string? displayName, IReadOnlyCollection<Guid> allowedRoleIds)
|
public void Publish(
|
||||||
|
string? displayName,
|
||||||
|
string? countryCode,
|
||||||
|
IReadOnlyCollection<Guid> allowedRoleIds
|
||||||
|
)
|
||||||
{
|
{
|
||||||
IsPublished = true;
|
IsPublished = true;
|
||||||
IsAvailable = true;
|
IsAvailable = true;
|
||||||
DisplayName = displayName;
|
DisplayName = displayName;
|
||||||
|
CountryCode = string.IsNullOrWhiteSpace(countryCode)
|
||||||
|
? null
|
||||||
|
: countryCode.Trim().ToUpperInvariant();
|
||||||
AllowedRoleIds = allowedRoleIds.Distinct().ToList();
|
AllowedRoleIds = allowedRoleIds.Distinct().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ public sealed class Node : Entity
|
|||||||
/// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями.</summary>
|
/// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями.</summary>
|
||||||
public int ConsecutiveProbeFailures { get; private set; }
|
public int ConsecutiveProbeFailures { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Ёмкость ноды — сколько активных конфигов она нормально тянет, задаёт админ. Не лимит:
|
||||||
|
/// только знаменатель для загрузки, которую пользователь видит при выборе локации
|
||||||
|
/// (ListAvailableInbounds). null — не задана, тогда пользователю показывается число конфигов.</summary>
|
||||||
|
public int? Capacity { get; private set; }
|
||||||
|
|
||||||
public bool IsEnabled { get; private set; }
|
public bool IsEnabled { get; private set; }
|
||||||
public bool NotifyOnStatusChange { get; private set; }
|
public bool NotifyOnStatusChange { get; private set; }
|
||||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||||
@@ -73,6 +78,14 @@ public sealed class Node : Entity
|
|||||||
? baseAddress
|
? baseAddress
|
||||||
: new UriBuilder(baseAddress) { Path = baseAddress.AbsolutePath + "/" }.Uri;
|
: 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;
|
public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials;
|
||||||
|
|
||||||
/// <summary>Слать админам в Telegram уведомление при каждом переходе Online↔Offline (см.
|
/// <summary>Слать админам в Telegram уведомление при каждом переходе Online↔Offline (см.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public class InboundConfiguration : IEntityTypeConfiguration<Inbound>
|
|||||||
builder.Property(x => x.Protocol).HasConversion<string>().HasMaxLength(32);
|
builder.Property(x => x.Protocol).HasConversion<string>().HasMaxLength(32);
|
||||||
builder.Property(x => x.Remark).IsRequired().HasMaxLength(200);
|
builder.Property(x => x.Remark).IsRequired().HasMaxLength(200);
|
||||||
builder.Property(x => x.DisplayName).HasMaxLength(100);
|
builder.Property(x => x.DisplayName).HasMaxLength(100);
|
||||||
|
builder.Property(x => x.CountryCode).HasMaxLength(2);
|
||||||
|
|
||||||
builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]");
|
builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]");
|
||||||
|
|
||||||
|
|||||||
+1138
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInboundCountryCode : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CountryCode",
|
||||||
|
table: "Inbounds",
|
||||||
|
type: "character varying(2)",
|
||||||
|
maxLength: 2,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CountryCode",
|
||||||
|
table: "Inbounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1141
File diff suppressed because it is too large.
Load diff
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNodeCapacity : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Capacity",
|
||||||
|
table: "Nodes",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Capacity",
|
||||||
|
table: "Nodes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -426,6 +426,10 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("uuid[]");
|
.HasColumnType("uuid[]");
|
||||||
|
|
||||||
|
b.Property<string>("CountryCode")
|
||||||
|
.HasMaxLength(2)
|
||||||
|
.HasColumnType("character varying(2)");
|
||||||
|
|
||||||
b.Property<string>("DisplayName")
|
b.Property<string>("DisplayName")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
@@ -597,6 +601,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int?>("Capacity")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("ConsecutiveProbeFailures")
|
b.Property<int>("ConsecutiveProbeFailures")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ public class ListAllConfigsQueryHandlerTests
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
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");
|
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||||
|
|
||||||
dbContext.Nodes.Add(node);
|
dbContext.Nodes.Add(node);
|
||||||
|
|||||||
+6
-4
@@ -22,7 +22,7 @@ public class PublishInboundCommandHandlerTests
|
|||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
|
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);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ public class PublishInboundCommandHandlerTests
|
|||||||
Assert.Equal("EU Fast", inbound.DisplayName);
|
Assert.Equal("EU Fast", inbound.DisplayName);
|
||||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
||||||
Assert.Equal("EU Fast", result.Value.DisplayName);
|
Assert.Equal("EU Fast", result.Value.DisplayName);
|
||||||
|
Assert.Equal("DE", inbound.CountryCode);
|
||||||
|
Assert.Equal("DE", result.Value.CountryCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -38,13 +40,13 @@ public class PublishInboundCommandHandlerTests
|
|||||||
{
|
{
|
||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
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);
|
dbContext.Inbounds.Add(inbound);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
|
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);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ public class PublishInboundCommandHandlerTests
|
|||||||
|
|
||||||
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
|
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);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
+35
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ public class SyncNodeCommandHandlerTests
|
|||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
var node = CreateNode();
|
var node = CreateNode();
|
||||||
var inbound = Inbound.FromRemote(node.Id, "old-id", VpnProtocol.Vless, "old", 443);
|
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);
|
var config = VpnConfig.Create(Guid.NewGuid(), inbound.Id, VpnProtocol.Vless, null);
|
||||||
config.AssignRemoteClient("external-id");
|
config.AssignRemoteClient("external-id");
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -40,6 +40,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
|
|
||||||
var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
|
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);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,7 +155,8 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
"new-admin",
|
"new-admin",
|
||||||
"new-password"
|
"new-password",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
@@ -181,6 +185,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
"new-admin",
|
"new-admin",
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -211,6 +216,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -235,6 +241,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ public class CreateVpnConfigCommandHandlerTests
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
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.Nodes.Add(node);
|
||||||
dbContext.Inbounds.Add(inbound);
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ public class GetMyConfigsQueryHandlerTests
|
|||||||
var otherUserId = Guid.NewGuid();
|
var otherUserId = Guid.NewGuid();
|
||||||
|
|
||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
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 activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active");
|
||||||
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked");
|
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked");
|
||||||
|
|||||||
+158
@@ -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<IIdentityService>();
|
||||||
|
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<CancellationToken>()).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<VpnConfig> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ public class InboundTests
|
|||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
|
|
||||||
inbound.Publish("Germany (VLESS)", [roleId, roleId]);
|
inbound.Publish("Germany (VLESS)", null, [roleId, roleId]);
|
||||||
|
|
||||||
Assert.True(inbound.IsPublished);
|
Assert.True(inbound.IsPublished);
|
||||||
Assert.Equal("Germany (VLESS)", inbound.DisplayName);
|
Assert.Equal("Germany (VLESS)", inbound.DisplayName);
|
||||||
@@ -48,12 +48,26 @@ public class InboundTests
|
|||||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
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]
|
[Fact]
|
||||||
public void Unpublish_SetsIsPublishedFalseButKeepsRoles()
|
public void Unpublish_SetsIsPublishedFalseButKeepsRoles()
|
||||||
{
|
{
|
||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
inbound.Publish("Germany", [roleId]);
|
inbound.Publish("Germany", null, [roleId]);
|
||||||
|
|
||||||
inbound.Unpublish();
|
inbound.Unpublish();
|
||||||
|
|
||||||
@@ -73,7 +87,7 @@ public class InboundTests
|
|||||||
public void MarkUnavailable_SetsIsAvailableAndIsPublishedFalse()
|
public void MarkUnavailable_SetsIsAvailableAndIsPublishedFalse()
|
||||||
{
|
{
|
||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||||
inbound.Publish("Germany", [Guid.NewGuid()]);
|
inbound.Publish("Germany", null, [Guid.NewGuid()]);
|
||||||
|
|
||||||
inbound.MarkUnavailable();
|
inbound.MarkUnavailable();
|
||||||
|
|
||||||
@@ -100,7 +114,7 @@ public class InboundTests
|
|||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||||
inbound.MarkUnavailable();
|
inbound.MarkUnavailable();
|
||||||
|
|
||||||
inbound.Publish("Germany", [Guid.NewGuid()]);
|
inbound.Publish("Germany", null, [Guid.NewGuid()]);
|
||||||
|
|
||||||
Assert.True(inbound.IsAvailable);
|
Assert.True(inbound.IsAvailable);
|
||||||
}
|
}
|
||||||
@@ -111,7 +125,7 @@ public class InboundTests
|
|||||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
var otherRoleId = Guid.NewGuid();
|
var otherRoleId = Guid.NewGuid();
|
||||||
inbound.Publish("Germany", [roleId, otherRoleId]);
|
inbound.Publish("Germany", null, [roleId, otherRoleId]);
|
||||||
|
|
||||||
inbound.RemoveAllowedRole(roleId);
|
inbound.RemoveAllowedRole(roleId);
|
||||||
|
|
||||||
|
|||||||
@@ -212,4 +212,26 @@ public class NodeTests
|
|||||||
Assert.DoesNotContain("protected-secret", text);
|
Assert.DoesNotContain("protected-secret", text);
|
||||||
Assert.Contains("REDACTED", 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<DomainException>(() => node.SetCapacity(capacity));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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 }` — **без пагинации**, весь список сразу |
|
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], configQuota, planId }` — **без пагинации**, весь список сразу |
|
||||||
| POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) |
|
| POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) |
|
||||||
| PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` |
|
| PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` |
|
||||||
@@ -343,7 +343,7 @@ approve/reject над `ActivationRequest`.
|
|||||||
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
||||||
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
|
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
|
||||||
| POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `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` |
|
| DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
|
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
|
||||||
| POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, 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[]` |
|
| 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` |
|
| DELETE | `/api/admin/inbounds/{id}` | admin | только для `IsAvailable=false`, иначе `Inbounds.StillAvailable` | `204` |
|
||||||
|
|
||||||
## Admin — Users & Stats
|
## Admin — Users & Stats
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ AppUser
|
|||||||
| `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` |
|
| `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` |
|
||||||
| `ConsecutiveProbeFailures` | `int` | Счётчик подряд неудачных проб для гистерезиса статуса (`RecordProbe`); обнуляется первой удачной пробой |
|
| `ConsecutiveProbeFailures` | `int` | Счётчик подряд неудачных проб для гистерезиса статуса (`RecordProbe`); обнуляется первой удачной пробой |
|
||||||
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
|
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
|
||||||
|
| `Capacity` | `int?` | Ёмкость — сколько активных конфигов нода нормально тянет. Не лимит: знаменатель загрузки (%), которую пользователь видит при выборе локации; `null` — показывается число конфигов |
|
||||||
| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` |
|
| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` |
|
||||||
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
|
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
|
||||||
| `CreatedAt` | `DateTimeOffset`| |
|
| `CreatedAt` | `DateTimeOffset`| |
|
||||||
@@ -82,6 +83,7 @@ AppUser
|
|||||||
| `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) |
|
| `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) |
|
||||||
| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) |
|
| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) |
|
||||||
| `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» |
|
| `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» |
|
||||||
|
| `CountryCode` | `string?` | ISO 3166-1 alpha-2 (`DE`) — флаг перед именем у пользователя; задаётся при публикации, только отображение |
|
||||||
| `LastSyncAt` | `DateTimeOffset?` | |
|
| `LastSyncAt` | `DateTimeOffset?` | |
|
||||||
|
|
||||||
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя
|
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя
|
||||||
@@ -118,7 +120,7 @@ AppUser
|
|||||||
> `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией
|
> `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией
|
||||||
> зарезервированной квоты, а не заранее закэшированным статусом.
|
> зарезервированной квоты, а не заранее закэшированным статусом.
|
||||||
|
|
||||||
> **Пользователю показываем только `DisplayName` + протокол.** Адрес/хост ноды, `RemoteInboundId`,
|
> **Пользователю показываем только `DisplayName` + протокол (+ флаг по `CountryCode`).** Адрес/хост ноды, `RemoteInboundId`,
|
||||||
> `Port` и прочие детали 3x-ui в пользовательские DTO не попадают (только в админские).
|
> `Port` и прочие детали 3x-ui в пользовательские DTO не попадают (только в админские).
|
||||||
|
|
||||||
### VpnConfig — конфиг пользователя (клиент в 3x-ui)
|
### VpnConfig — конфиг пользователя (клиент в 3x-ui)
|
||||||
|
|||||||
+5
-1
@@ -89,7 +89,11 @@ frontend/
|
|||||||
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
||||||
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
||||||
неактивированного — `ActivationGate` вместо дашборда.
|
неактивированного — `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`) —
|
(`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) —
|
||||||
в форме создания не настраивается. После успеха карточка конфига появляется в списке; ссылку/QR
|
в форме создания не настраивается. После успеха карточка конфига появляется в списке; ссылку/QR
|
||||||
пользователь открывает отдельно.
|
пользователь открывает отдельно.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"country-flag-icons": "^1.6.20",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"lucide-react": "^1.22.0",
|
"lucide-react": "^1.22.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
|
|||||||
Generated
+8
@@ -53,6 +53,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
country-flag-icons:
|
||||||
|
specifier: ^1.6.20
|
||||||
|
version: 1.6.20
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.4
|
specifier: ^26.3.4
|
||||||
version: 26.3.4(typescript@6.0.3)
|
version: 26.3.4(typescript@6.0.3)
|
||||||
@@ -1232,6 +1235,9 @@ packages:
|
|||||||
cookie-es@3.1.1:
|
cookie-es@3.1.1:
|
||||||
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
||||||
|
|
||||||
|
country-flag-icons@1.6.20:
|
||||||
|
resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -3214,6 +3220,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie-es@3.1.1: {}
|
cookie-es@3.1.1: {}
|
||||||
|
|
||||||
|
country-flag-icons@1.6.20: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
d3-array@3.2.4:
|
d3-array@3.2.4:
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||||
|
import { COUNTRY_CODES, countryName } from '@/shared/lib/countries'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import { listRoles } from '@/features/admin/roles/api'
|
import { listRoles } from '@/features/admin/roles/api'
|
||||||
import type { InboundDto } from '@/shared/api/types'
|
import type { InboundDto } from '@/shared/api/types'
|
||||||
import { publishInbound } from './api'
|
import { publishInbound } from './api'
|
||||||
|
|
||||||
|
// Radix Select не допускает value="" у SelectItem — «без флага» кодируем отдельным маркером.
|
||||||
|
const NO_COUNTRY = 'none'
|
||||||
|
|
||||||
export function PublishInboundDialog({
|
export function PublishInboundDialog({
|
||||||
inbound,
|
inbound,
|
||||||
open,
|
open,
|
||||||
@@ -20,8 +26,9 @@ export function PublishInboundDialog({
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [countryCode, setCountryCode] = useState(inbound.countryCode ?? NO_COUNTRY)
|
||||||
const [isPublished, setIsPublished] = useState(inbound.isPublished)
|
const [isPublished, setIsPublished] = useState(inbound.isPublished)
|
||||||
const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark)
|
const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark)
|
||||||
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds))
|
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds))
|
||||||
@@ -30,7 +37,13 @@ export function PublishInboundDialog({
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
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 () => {
|
onSuccess: async () => {
|
||||||
toast.success(t('admin.nodes.publishSaved'))
|
toast.success(t('admin.nodes.publishSaved'))
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] })
|
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] })
|
||||||
@@ -73,6 +86,27 @@ export function PublishInboundDialog({
|
|||||||
<Label htmlFor="displayName">{t('admin.nodes.displayName')}</Label>
|
<Label htmlFor="displayName">{t('admin.nodes.displayName')}</Label>
|
||||||
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.nodes.country')}</Label>
|
||||||
|
<Select value={countryCode} onValueChange={setCountryCode}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_COUNTRY}>{t('admin.nodes.noCountry')}</SelectItem>
|
||||||
|
{COUNTRY_CODES.map((code) => [code, countryName(code, i18n.language)] as const)
|
||||||
|
.sort(([, a], [, b]) => a.localeCompare(b, i18n.language))
|
||||||
|
.map(([code, name]) => (
|
||||||
|
<SelectItem key={code} value={code}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CountryFlag code={code} />
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.nodes.allowedRoles')}</Label>
|
<Label>{t('admin.nodes.allowedRoles')}</Label>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ export function publishInbound(
|
|||||||
id: string,
|
id: string,
|
||||||
isPublished: boolean,
|
isPublished: boolean,
|
||||||
displayName: string | undefined,
|
displayName: string | undefined,
|
||||||
|
countryCode: string | undefined,
|
||||||
allowedRoleIds: string[],
|
allowedRoleIds: string[],
|
||||||
) {
|
) {
|
||||||
return apiRequest<InboundDto>(`/admin/inbounds/${id}/publish`, {
|
return apiRequest<InboundDto>(`/admin/inbounds/${id}/publish`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { isPublished, displayName: displayName ?? null, allowedRoleIds },
|
body: { isPublished, displayName: displayName ?? null, countryCode: countryCode ?? null, allowedRoleIds },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
|||||||
const [notifyOnStatusChange, setNotifyOnStatusChange] = useState(node.notifyOnStatusChange)
|
const [notifyOnStatusChange, setNotifyOnStatusChange] = useState(node.notifyOnStatusChange)
|
||||||
const [username, setUsername] = useState(node.username)
|
const [username, setUsername] = useState(node.username)
|
||||||
const [password, setPassword] = useState('')
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -32,6 +35,7 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
|||||||
notifyOnStatusChange,
|
notifyOnStatusChange,
|
||||||
username.trim() || undefined,
|
username.trim() || undefined,
|
||||||
password || undefined,
|
password || undefined,
|
||||||
|
parsedCapacity,
|
||||||
),
|
),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(t('admin.nodes.updated'))
|
toast.success(t('admin.nodes.updated'))
|
||||||
@@ -72,6 +76,20 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
|||||||
<Label htmlFor="editLocation">{t('admin.nodes.location')}</Label>
|
<Label htmlFor="editLocation">{t('admin.nodes.location')}</Label>
|
||||||
<Input id="editLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
|
<Input id="editLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="editCapacity">
|
||||||
|
{t('admin.nodes.capacity')} ({t('admin.nodes.optional')})
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="editCapacity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={capacity}
|
||||||
|
onChange={(e) => setCapacity(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.nodes.capacityHint')}</p>
|
||||||
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||||
{t('admin.nodes.enabled')}
|
{t('admin.nodes.enabled')}
|
||||||
@@ -107,7 +125,7 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={!name.trim() || !baseAddress.trim() || mutation.isPending}>
|
<Button type="submit" disabled={!name.trim() || !baseAddress.trim() || !capacityValid || mutation.isPending}>
|
||||||
{t('admin.roles.save')}
|
{t('admin.roles.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function updateNode(
|
|||||||
notifyOnStatusChange: boolean,
|
notifyOnStatusChange: boolean,
|
||||||
username: string | undefined,
|
username: string | undefined,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
|
capacity: number | undefined,
|
||||||
) {
|
) {
|
||||||
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
|
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -29,6 +30,7 @@ export function updateNode(
|
|||||||
notifyOnStatusChange,
|
notifyOnStatusChange,
|
||||||
username: username ?? null,
|
username: username ?? null,
|
||||||
password: password ?? null,
|
password: password ?? null,
|
||||||
|
capacity: capacity ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di
|
|||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import { formatBytes } from '@/shared/lib/format'
|
import { formatBytes } from '@/shared/lib/format'
|
||||||
import type { ConfigStatus, VpnConfigDto } from '@/shared/api/types'
|
import type { ConfigStatus, VpnConfigDto } from '@/shared/api/types'
|
||||||
|
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||||
import { getConfigLink, revokeConfig, rotateConfig } from './api'
|
import { getConfigLink, revokeConfig, rotateConfig } from './api'
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<ConfigStatus, 'success' | 'warning' | 'destructive'> = {
|
const STATUS_VARIANT: Record<ConfigStatus, 'success' | 'warning' | 'destructive'> = {
|
||||||
@@ -69,7 +70,10 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
|
|||||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">{config.label ?? config.location}</CardTitle>
|
<CardTitle className="text-base">{config.label ?? config.location}</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">{config.location}</p>
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<CountryFlag code={config.countryCode} />
|
||||||
|
{config.location}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">{config.protocol}</Badge>
|
<Badge variant="outline">{config.protocol}</Badge>
|
||||||
|
|||||||
@@ -11,8 +11,41 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { AvailableInboundDto } from '@/shared/api/types'
|
import type { AvailableInboundDto } from '@/shared/api/types'
|
||||||
|
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { createConfig } from './api'
|
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 (
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 pl-3 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-2 w-2 rounded-full',
|
||||||
|
level === 'high' ? 'bg-red-500' : level === 'medium' ? 'bg-amber-500' : 'bg-emerald-500',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{t(`configs.load.${level}`)} · {inbound.loadPercent}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (inbound.nodeConfigCount !== null) {
|
||||||
|
return (
|
||||||
|
<span className="ml-auto pl-3 text-xs text-muted-foreground">
|
||||||
|
{t('configs.nodeConfigs', { count: inbound.nodeConfigCount })}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto[] }) {
|
export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto[] }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -74,7 +107,11 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{inbounds.map((inbound) => (
|
{inbounds.map((inbound) => (
|
||||||
<SelectItem key={inbound.inboundId} value={inbound.inboundId}>
|
<SelectItem key={inbound.inboundId} value={inbound.inboundId}>
|
||||||
{inbound.displayName} ({inbound.protocol})
|
<span className="flex w-full items-center gap-2">
|
||||||
|
<CountryFlag code={inbound.countryCode} />
|
||||||
|
{inbound.displayName} ({inbound.protocol})
|
||||||
|
<NodeLoad inbound={inbound} />
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { listAllConfigs } from '@/features/admin/configs/api'
|
|||||||
import { forceRevokeConfig } from '@/features/admin/users/api'
|
import { forceRevokeConfig } from '@/features/admin/users/api'
|
||||||
import { listNodes } from '@/features/admin/nodes/api'
|
import { listNodes } from '@/features/admin/nodes/api'
|
||||||
import type { ConfigStatus, VpnProtocol } from '@/shared/api/types'
|
import type { ConfigStatus, VpnProtocol } from '@/shared/api/types'
|
||||||
|
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||||
|
|
||||||
const PROTOCOLS: VpnProtocol[] = ['Vless', 'Vmess', 'Trojan', 'Shadowsocks']
|
const PROTOCOLS: VpnProtocol[] = ['Vless', 'Vmess', 'Trojan', 'Shadowsocks']
|
||||||
|
|
||||||
@@ -166,7 +167,10 @@ function AdminConfigsPage() {
|
|||||||
<Badge variant="outline">{config.protocol}</Badge>
|
<Badge variant="outline">{config.protocol}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
{config.nodeName} · {config.location}
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CountryFlag code={config.countryCode} />
|
||||||
|
{config.nodeName} · {config.location}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-muted-foreground">
|
<td className="py-2 text-muted-foreground">
|
||||||
↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)}
|
↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type VpnConfigDto = {
|
|||||||
clientEmail: string
|
clientEmail: string
|
||||||
protocol: VpnProtocol
|
protocol: VpnProtocol
|
||||||
location: string
|
location: string
|
||||||
|
countryCode: string | null
|
||||||
usedUpBytes: number
|
usedUpBytes: number
|
||||||
usedDownBytes: number
|
usedDownBytes: number
|
||||||
expiresAt: string | null
|
expiresAt: string | null
|
||||||
@@ -68,6 +69,7 @@ export type AdminVpnConfigDto = {
|
|||||||
clientEmail: string
|
clientEmail: string
|
||||||
protocol: VpnProtocol
|
protocol: VpnProtocol
|
||||||
location: string
|
location: string
|
||||||
|
countryCode: string | null
|
||||||
nodeName: string
|
nodeName: string
|
||||||
usedUpBytes: number
|
usedUpBytes: number
|
||||||
usedDownBytes: number
|
usedDownBytes: number
|
||||||
@@ -80,6 +82,11 @@ export type AvailableInboundDto = {
|
|||||||
inboundId: string
|
inboundId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
protocol: VpnProtocol
|
protocol: VpnProtocol
|
||||||
|
countryCode: string | null
|
||||||
|
/** Загрузка ноды в % от её ёмкости (может быть > 100); null — ёмкость не задана. */
|
||||||
|
loadPercent: number | null
|
||||||
|
/** Число активных конфигов на ноде — только когда ёмкость не задана (иначе null). */
|
||||||
|
nodeConfigCount: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConfigLinkDto = {
|
export type ConfigLinkDto = {
|
||||||
@@ -306,6 +313,7 @@ export type NodeDto = {
|
|||||||
status: NodeStatus
|
status: NodeStatus
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
notifyOnStatusChange: boolean
|
notifyOnStatusChange: boolean
|
||||||
|
capacity: number | null
|
||||||
lastSyncAt: string | null
|
lastSyncAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +338,7 @@ export type InboundDto = {
|
|||||||
isPublished: boolean
|
isPublished: boolean
|
||||||
isAvailable: boolean
|
isAvailable: boolean
|
||||||
displayName: string | null
|
displayName: string | null
|
||||||
|
countryCode: string | null
|
||||||
allowedRoleIds: string[]
|
allowedRoleIds: string[]
|
||||||
lastSyncAt: string | null
|
lastSyncAt: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FlagComponent } from 'country-flag-icons/react/3x2'
|
||||||
|
import {
|
||||||
|
AE, AM, AR, AT, AU, AZ, BE, BG, BR, BY, CA, CH, CL, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GE, GR, HK,
|
||||||
|
HR, HU, ID, IE, IL, IN, IS, IT, JP, KG, KR, KZ, LT, LU, LV, MD, MX, MY, NL, NO, PL, PT, RO, RS, RU,
|
||||||
|
SE, SG, SI, SK, TH, TR, TW, UA, US, UZ, VN, ZA,
|
||||||
|
} from 'country-flag-icons/react/3x2'
|
||||||
|
|
||||||
|
// Курируемый список стран, где обычно арендуют VPN-серверы: полный набор country-flag-icons весит
|
||||||
|
// ~1 МБ SVG, а named-импорты тришейкаются. Нужна новая страна — добавить её сюда (бэкенд принимает
|
||||||
|
// любой ISO 3166-1 alpha-2; неизвестный UI код показывается текстом вместо флага).
|
||||||
|
// Emoji-флаги не используем: Windows их не рисует (вместо 🇩🇪 показывает «DE»).
|
||||||
|
export const FLAGS: Record<string, FlagComponent> = {
|
||||||
|
AE, AM, AR, AT, AU, AZ, BE, BG, BR, BY, CA, CH, CL, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GE, GR, HK,
|
||||||
|
HR, HU, ID, IE, IL, IN, IS, IT, JP, KG, KR, KZ, LT, LU, LV, MD, MX, MY, NL, NO, PL, PT, RO, RS, RU,
|
||||||
|
SE, SG, SI, SK, TH, TR, TW, UA, US, UZ, VN, ZA,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COUNTRY_CODES = Object.keys(FLAGS)
|
||||||
|
|
||||||
|
/** Локализованное имя страны по ISO-коду (Intl), без собственных переводов. */
|
||||||
|
export function countryName(code: string, language: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([language], { type: 'region' }).of(code) ?? code
|
||||||
|
} catch {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,11 @@ const resources = {
|
|||||||
empty: 'У вас пока нет конфигов. Создайте первый.',
|
empty: 'У вас пока нет конфигов. Создайте первый.',
|
||||||
create: 'Создать конфиг',
|
create: 'Создать конфиг',
|
||||||
selectLocation: 'Выберите локацию',
|
selectLocation: 'Выберите локацию',
|
||||||
|
load: { low: 'Свободно', medium: 'Средняя загрузка', high: 'Загружен' },
|
||||||
|
nodeConfigs_one: '{{count}} конфиг на сервере',
|
||||||
|
nodeConfigs_few: '{{count}} конфига на сервере',
|
||||||
|
nodeConfigs_many: '{{count}} конфигов на сервере',
|
||||||
|
nodeConfigs_other: '{{count}} конфига на сервере',
|
||||||
location: 'Локация',
|
location: 'Локация',
|
||||||
label: 'Метка (необязательно)',
|
label: 'Метка (необязательно)',
|
||||||
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
||||||
@@ -466,6 +471,9 @@ const resources = {
|
|||||||
enabled: 'Включена',
|
enabled: 'Включена',
|
||||||
disabled: 'Отключена',
|
disabled: 'Отключена',
|
||||||
notifyOnStatusChange: 'Уведомлять в Telegram при смене статуса (онлайн/офлайн)',
|
notifyOnStatusChange: 'Уведомлять в Telegram при смене статуса (онлайн/офлайн)',
|
||||||
|
capacity: 'Ёмкость (конфигов)',
|
||||||
|
capacityHint:
|
||||||
|
'Сколько активных конфигов нода нормально тянет. Не лимит: по ней пользователям показывается загрузка в %, менее загруженные локации идут первыми. Пусто — показывается число конфигов.',
|
||||||
inbounds: 'Инбаунды',
|
inbounds: 'Инбаунды',
|
||||||
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
||||||
publish: 'Публикация',
|
publish: 'Публикация',
|
||||||
@@ -477,6 +485,8 @@ const resources = {
|
|||||||
publishSaved: 'Настройки публикации сохранены.',
|
publishSaved: 'Настройки публикации сохранены.',
|
||||||
displayName: 'Отображаемое имя',
|
displayName: 'Отображаемое имя',
|
||||||
allowedRoles: 'Доступно ролям',
|
allowedRoles: 'Доступно ролям',
|
||||||
|
country: 'Страна (флаг у пользователей)',
|
||||||
|
noCountry: 'Без флага',
|
||||||
isPublishedLabel: 'Опубликовать инбаунд',
|
isPublishedLabel: 'Опубликовать инбаунд',
|
||||||
optional: 'необязательно',
|
optional: 'необязательно',
|
||||||
passwordKeepUnchanged: 'оставьте пустым, чтобы не менять',
|
passwordKeepUnchanged: 'оставьте пустым, чтобы не менять',
|
||||||
@@ -688,6 +698,9 @@ const resources = {
|
|||||||
empty: "You don't have any configs yet. Create your first one.",
|
empty: "You don't have any configs yet. Create your first one.",
|
||||||
create: 'Create config',
|
create: 'Create config',
|
||||||
selectLocation: 'Select location',
|
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',
|
location: 'Location',
|
||||||
label: 'Label (optional)',
|
label: 'Label (optional)',
|
||||||
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
|
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',
|
enabled: 'Enabled',
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
notifyOnStatusChange: 'Notify in Telegram on status change (online/offline)',
|
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',
|
inbounds: 'Inbounds',
|
||||||
noInbounds: 'No inbounds found — click "Sync".',
|
noInbounds: 'No inbounds found — click "Sync".',
|
||||||
publish: 'Publishing',
|
publish: 'Publishing',
|
||||||
@@ -1088,6 +1104,8 @@ const resources = {
|
|||||||
publishSaved: 'Publishing settings saved.',
|
publishSaved: 'Publishing settings saved.',
|
||||||
displayName: 'Display name',
|
displayName: 'Display name',
|
||||||
allowedRoles: 'Allowed for roles',
|
allowedRoles: 'Allowed for roles',
|
||||||
|
country: 'Country (flag shown to users)',
|
||||||
|
noCountry: 'No flag',
|
||||||
isPublishedLabel: 'Publish inbound',
|
isPublishedLabel: 'Publish inbound',
|
||||||
optional: 'optional',
|
optional: 'optional',
|
||||||
passwordKeepUnchanged: 'leave blank to keep unchanged',
|
passwordKeepUnchanged: 'leave blank to keep unchanged',
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { FLAGS } from '@/shared/lib/countries'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export function CountryFlag({ code, className }: { code: string | null | undefined; className?: string }) {
|
||||||
|
if (!code) return null
|
||||||
|
const Flag = FLAGS[code.toUpperCase()]
|
||||||
|
if (!Flag) {
|
||||||
|
return <span className={cn('font-mono text-xs text-muted-foreground', className)}>{code.toUpperCase()}</span>
|
||||||
|
}
|
||||||
|
return <Flag className={cn('inline-block h-3.5 w-auto shrink-0 rounded-[2px]', className)} aria-hidden />
|
||||||
|
}
|
||||||
Reference in new issue
Block a user