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

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

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

No files matched your search

@@ -41,6 +41,7 @@ public static class InboundEndpoints
id,
body.IsPublished,
body.DisplayName,
body.CountryCode,
body.AllowedRoleIds ?? []
);
var result = await sender.Send(command, cancellationToken);
@@ -61,5 +62,6 @@ public static class InboundEndpoints
public sealed record PublishInboundBody(
bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid>? AllowedRoleIds
IReadOnlyList<Guid>? AllowedRoleIds,
string? CountryCode = null
);
@@ -57,7 +57,8 @@ public static class NodeEndpoints
body.IsEnabled,
body.NotifyOnStatusChange,
body.Username,
body.Password
body.Password,
body.Capacity
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
@@ -101,5 +102,6 @@ public sealed record UpdateNodeBody(
bool IsEnabled,
bool NotifyOnStatusChange,
string? Username,
string? Password
string? Password,
int? Capacity = null
);
@@ -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])
{
case "login":
{
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveTelegramLoginCommand(requestId, fromId),
cancellationToken
)
: await sender.Send(
new RejectTelegramLoginCommand(requestId, fromId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
case "login":
{
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveTelegramLoginCommand(requestId, fromId),
cancellationToken
)
: await sender.Send(
new RejectTelegramLoginCommand(requestId, fromId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "act":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveActivationCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectActivationCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
// если его обработали в другом месте — на сайте или из другого чата).
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Пользователь активирован."
: "❌ Запрос отклонён."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
case "act":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveActivationCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectActivationCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
// если его обработали в другом месте — на сайте или из другого чата).
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Пользователь активирован."
: "❌ Запрос отклонён."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "erq":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveExtensionRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectExtensionRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Заявка на продление одобрена."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
case "erq":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveExtensionRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectExtensionRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Заявка на продление одобрена."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "pay":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ConfirmPaymentRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectPaymentRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Оплата подтверждена."
: "❌ Оплата отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
case "pay":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ConfirmPaymentRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectPaymentRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Оплата подтверждена."
: "❌ Оплата отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "cfg":
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
var linkResult = await sender.Send(
new GetConfigLinkQuery(requestId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
cancellationToken: cancellationToken
);
if (!linkResult.IsSuccess)
{
await botClient.SendMessage(
chatId.Value,
$"Не удалось получить ссылку: {linkResult.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
if (callback.Message is null)
break;
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
var text =
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
.Where(row => row.All(b => b.CallbackData != data))
.ToArray();
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0
? new InlineKeyboardMarkup(remainingRows)
: null,
cancellationToken: cancellationToken
);
break;
case "cfg":
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
var linkResult = await sender.Send(
new GetConfigLinkQuery(requestId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
cancellationToken: cancellationToken
);
if (!linkResult.IsSuccess)
{
await botClient.SendMessage(
chatId.Value,
$"Не удалось получить ссылку: {linkResult.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
if (callback.Message is null)
break;
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
var text =
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
.Where(row => row.All(b => b.CallbackData != data))
.ToArray();
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0
? new InlineKeyboardMarkup(remainingRows)
: null,
cancellationToken: cancellationToken
);
break;
}
}
}
@@ -730,7 +730,7 @@ public sealed class PnvBotUpdateHandler(
+ string.Join(
'\n',
result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
$"• {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
)
);
@@ -741,7 +741,7 @@ public sealed class PnvBotUpdateHandler(
new[]
{
InlineKeyboardButton.WithCallbackData(
$"🔗 {c.Label ?? c.Location}",
$"🔗 {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location}",
$"cfg:link:{c.Id}"
),
}
@@ -13,6 +13,7 @@ public sealed record AdminVpnConfigDto(
string ClientEmail,
VpnProtocol Protocol,
string Location,
string? CountryCode,
string NodeName,
long UsedUpBytes,
long UsedDownBytes,
@@ -82,6 +82,7 @@ public sealed class ListAllConfigsQueryHandler(
c.ClientEmail,
c.Protocol,
inbound?.DisplayName ?? inbound?.Remark ?? "?",
inbound?.CountryCode,
node?.Name ?? "?",
c.UsedUpBytes,
c.UsedDownBytes,
@@ -12,6 +12,7 @@ public sealed record InboundDto(
bool IsPublished,
bool IsAvailable,
string? DisplayName,
string? CountryCode,
IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt
)
@@ -27,6 +28,7 @@ public sealed record InboundDto(
inbound.IsPublished,
inbound.IsAvailable,
inbound.DisplayName,
inbound.CountryCode,
inbound.AllowedRoleIds,
inbound.LastSyncAt
);
@@ -7,5 +7,6 @@ public sealed record PublishInboundCommand(
Guid InboundId,
bool IsPublished,
string? DisplayName,
string? CountryCode,
IReadOnlyList<Guid> AllowedRoleIds
) : ICommand<Result<InboundDto>>;
@@ -22,7 +22,7 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
return Result.Failure<InboundDto>(InboundErrors.NotFound);
if (command.IsPublished)
inbound.Publish(command.DisplayName, command.AllowedRoleIds);
inbound.Publish(command.DisplayName, command.CountryCode, command.AllowedRoleIds);
else
inbound.Unpublish();
@@ -7,5 +7,9 @@ public sealed class PublishInboundCommandValidator : AbstractValidator<PublishIn
public PublishInboundCommandValidator()
{
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,
bool IsEnabled,
bool NotifyOnStatusChange,
int? Capacity,
DateTimeOffset? LastSyncAt
)
{
@@ -25,6 +26,7 @@ public sealed record NodeDto(
node.Status,
node.IsEnabled,
node.NotifyOnStatusChange,
node.Capacity,
node.LastSyncAt
);
}
@@ -11,5 +11,6 @@ public sealed record UpdateNodeCommand(
bool IsEnabled,
bool NotifyOnStatusChange,
string? Username,
string? Password
string? Password,
int? Capacity
) : ICommand<Result<NodeDto>>;
@@ -48,6 +48,7 @@ public sealed class UpdateNodeCommandHandler(
node.Disable();
node.SetNotifyOnStatusChange(command.NotifyOnStatusChange);
node.SetCapacity(command.Capacity);
var credentialsChanged =
!string.IsNullOrWhiteSpace(command.Username)
@@ -10,5 +10,6 @@ public sealed class UpdateNodeCommandValidator : AbstractValidator<UpdateNodeCom
RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500);
RuleFor(x => x.Location).MaximumLength(100);
RuleFor(x => x.Username).MaximumLength(200);
RuleFor(x => x.Capacity).GreaterThan(0).When(x => x.Capacity is not null);
}
}
@@ -9,5 +9,15 @@ public sealed record ListAvailableInboundsQuery
: IQuery<Result<IReadOnlyList<AvailableInboundDto>>>,
IRequiresActivation;
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.</summary>
public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol);
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.
/// Загрузка — по ноде целиком (все её инбаунды делят один сервер), по активным конфигам. Если у
/// ноды задана ёмкость — отдаём только процент (<see cref="LoadPercent"/>, может быть больше 100:
/// ёмкость не лимит), абсолютное число не раскрываем; иначе — только <see cref="NodeConfigCount"/>.</summary>
public sealed record AvailableInboundDto(
Guid InboundId,
string DisplayName,
VpnProtocol Protocol,
string? CountryCode,
int? LoadPercent,
int? NodeConfigCount
);
@@ -3,6 +3,7 @@ using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
@@ -24,22 +25,62 @@ public sealed class ListAvailableInboundsQueryHandler(
if (profile is null)
return Result.Failure<IReadOnlyList<AvailableInboundDto>>(AuthErrors.Unauthorized);
var enabledNodeIds = dbContext
.Nodes.AsNoTracking()
.Where(n => n.IsEnabled)
.Select(n => n.Id);
var enabledNodes = dbContext.Nodes.AsNoTracking().Where(n => n.IsEnabled);
var inbounds = await dbContext
.Inbounds.AsNoTracking()
.Where(i =>
i.IsPublished
&& enabledNodeIds.Contains(i.NodeId)
&& i.AllowedRoleIds.Contains(profile.RoleId)
.Where(i => i.IsPublished && i.AllowedRoleIds.Contains(profile.RoleId))
.Join(
enabledNodes,
i => i.NodeId,
n => n.Id,
(i, n) => new
{
i.Id,
i.NodeId,
DisplayName = i.DisplayName ?? i.Remark,
i.Protocol,
i.CountryCode,
n.Capacity,
}
)
.OrderBy(i => i.DisplayName ?? i.Remark)
.Select(i => new AvailableInboundDto(i.Id, i.DisplayName ?? i.Remark, i.Protocol))
.ToListAsync(cancellationToken);
return Result.Success<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,
VpnProtocol Protocol,
string Location,
string? CountryCode,
long UsedUpBytes,
long UsedDownBytes,
DateTimeOffset? ExpiresAt,
@@ -29,6 +30,7 @@ public sealed record VpnConfigDto(
config.ClientEmail,
config.Protocol,
inbound.DisplayName ?? inbound.Remark,
inbound.CountryCode,
config.UsedUpBytes,
config.UsedDownBytes,
config.ExpiresAt,
@@ -18,6 +18,10 @@ public sealed class Inbound : Entity
public bool IsPublished { get; private set; }
public bool IsAvailable { get; private set; } = true;
public string? DisplayName { get; private set; }
/// <summary>ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед
/// именем инбаунда. Только для отображения, на подключение не влияет.</summary>
public string? CountryCode { get; private set; }
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
public DateTimeOffset? LastSyncAt { get; private set; }
@@ -57,11 +61,18 @@ public sealed class Inbound : Entity
IsAvailable = true;
}
public void Publish(string? displayName, IReadOnlyCollection<Guid> allowedRoleIds)
public void Publish(
string? displayName,
string? countryCode,
IReadOnlyCollection<Guid> allowedRoleIds
)
{
IsPublished = true;
IsAvailable = true;
DisplayName = displayName;
CountryCode = string.IsNullOrWhiteSpace(countryCode)
? null
: countryCode.Trim().ToUpperInvariant();
AllowedRoleIds = allowedRoleIds.Distinct().ToList();
}
+13
View File
@@ -20,6 +20,11 @@ public sealed class Node : Entity
/// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями.</summary>
public int ConsecutiveProbeFailures { get; private set; }
/// <summary>Ёмкость ноды — сколько активных конфигов она нормально тянет, задаёт админ. Не лимит:
/// только знаменатель для загрузки, которую пользователь видит при выборе локации
/// (ListAvailableInbounds). null — не задана, тогда пользователю показывается число конфигов.</summary>
public int? Capacity { get; private set; }
public bool IsEnabled { get; private set; }
public bool NotifyOnStatusChange { get; private set; }
public DateTimeOffset? LastSyncAt { get; private set; }
@@ -73,6 +78,14 @@ public sealed class Node : Entity
? baseAddress
: new UriBuilder(baseAddress) { Path = baseAddress.AbsolutePath + "/" }.Uri;
public void SetCapacity(int? capacity)
{
if (capacity is <= 0)
throw new DomainException("Ёмкость ноды должна быть положительной.");
Capacity = capacity;
}
public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials;
/// <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.Remark).IsRequired().HasMaxLength(200);
builder.Property(x => x.DisplayName).HasMaxLength(100);
builder.Property(x => x.CountryCode).HasMaxLength(2);
builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]");
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -426,6 +426,10 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
@@ -597,6 +601,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int?>("Capacity")
.HasColumnType("integer");
b.Property<int>("ConsecutiveProbeFailures")
.ValueGeneratedOnAdd()
.HasColumnType("integer")