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

@@ -52,7 +52,7 @@ public class CreateVpnConfigCommandHandlerTests
null
);
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
inbound.Publish(null, [roleId]);
inbound.Publish(null, null, [roleId]);
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
@@ -22,7 +22,7 @@ public class GetMyConfigsQueryHandlerTests
var otherUserId = Guid.NewGuid();
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
inbound.Publish("My inbound", []);
inbound.Publish("My inbound", null, []);
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active");
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked");
@@ -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);
}
}