Enhance user management and node health check features
CI / Backend (build + test) (push) Failing after 2m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 51s

- Updated `ListUsersQueryHandler` to include plan names and config quotas in `UserSummaryDto`, enriching user data retrieval.
- Implemented `WithPlanNamesAsync` method to fetch plan names based on user plan IDs, improving user experience in the admin interface.
- Enhanced `Node` class with a `ConsecutiveProbeFailures` property for better status management during health checks.
- Modified `NodeHealthCheckService` to utilize the new `RecordProbe` method, implementing a hysteresis mechanism for node status changes.
- Updated frontend components to display user config quotas and plan names, improving clarity in user management.
- Enhanced tests for user listing and node status handling to ensure robust functionality and coverage.
- Updated documentation to reflect changes in user and node management features.
This commit is contained in:
Leonid Pershin
2026-08-05 08:34:17 +03:00
parent c2ed3240bd
commit 4b34c37ce3
20 changed files with 1563 additions and 28 deletions
@@ -40,16 +40,40 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService, IApp
)
.Select(r => r.UserId)
.ToListAsync(cancellationToken);
if (pendingUserIds.Count == 0)
return Result.Success(result);
var pendingSet = pendingUserIds.ToHashSet();
var enrichedItems = result
.Items.Select(u => pendingSet.Contains(u.Id) ? u with { BillingPendingReview = true } : u)
.ToList();
var withPlanNames = await WithPlanNamesAsync(enrichedItems, cancellationToken);
return Result.Success(
new PagedList<UserSummaryDto>(enrichedItems, result.Total, result.Page, result.PageSize)
new PagedList<UserSummaryDto>(withPlanNames, result.Total, result.Page, result.PageSize)
);
}
/// <summary>Имена тарифов по PlanId — тоже мимо IIdentityService: каталог Plans живёт в домене,
/// Identity знает только Id выбранного тарифа.</summary>
private async Task<IReadOnlyList<UserSummaryDto>> WithPlanNamesAsync(
IReadOnlyList<UserSummaryDto> items,
CancellationToken cancellationToken
)
{
var planIds = items.Where(u => u.PlanId.HasValue).Select(u => u.PlanId!.Value).Distinct().ToList();
if (planIds.Count == 0)
return items;
var names = await dbContext
.Plans.AsNoTracking()
.Where(p => planIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, p => p.Name, cancellationToken);
return items
.Select(u =>
u.PlanId is { } planId && names.TryGetValue(planId, out var name)
? u with { PlanName = name }
: u
)
.ToList();
}
}
@@ -38,10 +38,17 @@ public sealed record UserSummaryDto(
DateTimeOffset? ActivatedAt,
bool BillingEnabled,
DateTimeOffset? BillingPaidUntil,
/// <summary>Фактическая квота конфигов (AppUser.ConfigQuota), -1 = без лимита.</summary>
int ConfigQuota,
/// <summary>Выбранный каталожный тариф; null — квота задана вручную (оверрайд админа/кастом).</summary>
Guid? PlanId,
/// <summary>Есть Subscription-заявка на оплату в AwaitingConfirmation — конфиги не гасятся, пока
/// админ не решит (см. BillingService). Заполняется в ListUsersQueryHandler (не здесь — Identity
/// не должен знать про PaymentRequests), false по умолчанию для мест, не подгружающих это поле.</summary>
bool BillingPendingReview = false
bool BillingPendingReview = false,
/// <summary>Имя тарифа по PlanId — тоже заполняется в ListUsersQueryHandler (Identity не знает
/// про каталог Plans), null для кастомной квоты и для мест, не подгружающих это поле.</summary>
string? PlanName = null
);
public sealed record UserStatsDto(int Total, int Activated);
+34
View File
@@ -14,6 +14,12 @@ public sealed class Node : Entity
public NodeCredentials Credentials { get; private set; } = null!;
public string? Location { get; private set; }
public NodeStatus Status { get; private set; }
/// <summary>Счётчик подряд идущих неудачных проб (см. <see cref="RecordProbe"/>) — обнуляется
/// первой же удачной пробой. Нужен для гистерезиса статуса: одна HTTP-заминка панели
/// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями.</summary>
public int ConsecutiveProbeFailures { get; private set; }
public bool IsEnabled { get; private set; }
public bool NotifyOnStatusChange { get; private set; }
public DateTimeOffset? LastSyncAt { get; private set; }
@@ -80,5 +86,33 @@ public sealed class Node : Entity
public void UpdateStatus(NodeStatus status) => Status = status;
/// <summary>
/// Регистрирует результат фоновой пробы с гистерезисом и возвращает <c>true</c>, если статус
/// изменился (тогда вызывающий код шлёт уведомления). Любая удачная проба немедленно возвращает
/// ноду в <see cref="NodeStatus.Online"/> и обнуляет счётчик; в <see cref="NodeStatus.Offline"/>
/// нода уходит только после <paramref name="failureThreshold"/> подряд неудачных проб — так
/// единичные заминки HTTP-слоя панели не порождают ложных переходов и спама (см.
/// NodeHealthCheckService).
/// </summary>
public bool RecordProbe(bool reachable, int failureThreshold)
{
if (reachable)
{
ConsecutiveProbeFailures = 0;
if (Status == NodeStatus.Online)
return false;
Status = NodeStatus.Online;
return true;
}
ConsecutiveProbeFailures++;
if (ConsecutiveProbeFailures < failureThreshold || Status == NodeStatus.Offline)
return false;
Status = NodeStatus.Offline;
return true;
}
public void MarkSynced() => LastSyncAt = DateTimeOffset.UtcNow;
}
@@ -15,6 +15,11 @@ public sealed class NodeHealthCheckService(
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
/// <summary>Сколько подряд неудачных проб нужно, чтобы признать ноду Offline (гистерезис против
/// флапа на транзиентных HTTP-заминках панели, ICMP при этом обычно в норме) — см.
/// <see cref="Node.RecordProbe"/>. При <see cref="Interval"/> = 2 мин это ≈ 4 минуты.</summary>
private const int FailureThreshold = 2;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval);
@@ -44,27 +49,57 @@ public sealed class NodeHealthCheckService(
foreach (var node in nodes)
{
var probe = await gateway.ProbeAsync(node, cancellationToken);
var newStatus = probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline;
if (node.Status != newStatus)
// Причину проглатывает XuiPanelGateway.ProbeAsync — логируем её здесь, иначе переход в
// Offline остаётся без объяснения (таймаут? 401 на реавторизации? сброс TLS?).
if (!probe.IsReachable)
{
node.UpdateStatus(newStatus);
await notifier.NotifyNodeStatusChangedAsync(
logger.LogWarning(
"Node {NodeId} ({NodeName}) probe failed ({Failures}/{Threshold}): {Reason}",
node.Id,
newStatus,
node.LastSyncAt,
node.Name,
node.ConsecutiveProbeFailures + 1,
FailureThreshold,
probe.ErrorMessage ?? "unknown"
);
}
if (!node.RecordProbe(probe.IsReachable, FailureThreshold))
continue;
if (node.Status == NodeStatus.Offline)
{
logger.LogWarning(
"Node {NodeId} ({NodeName}) marked Offline after {Failures} consecutive failed probes",
node.Id,
node.Name,
node.ConsecutiveProbeFailures
);
}
else
{
logger.LogInformation(
"Node {NodeId} ({NodeName}) is back Online",
node.Id,
node.Name
);
}
await notifier.NotifyNodeStatusChangedAsync(
node.Id,
node.Status,
node.LastSyncAt,
cancellationToken
);
if (node.NotifyOnStatusChange)
{
await telegramNotifier.NotifyAdminsNodeStatusChangedAsync(
node.Id,
node.Name,
node.Status,
cancellationToken
);
if (node.NotifyOnStatusChange)
{
await telegramNotifier.NotifyAdminsNodeStatusChangedAsync(
node.Id,
node.Name,
newStatus,
cancellationToken
);
}
}
}
@@ -320,7 +320,9 @@ internal sealed class IdentityService(
user.IsBlocked,
user.ActivatedAt,
role.BillingEnabled,
user.BillingPaidUntil
user.BillingPaidUntil,
user.ConfigQuota,
user.PlanId
)
);
}
@@ -21,6 +21,7 @@ public class NodeConfiguration : IEntityTypeConfiguration<Node>
builder.Property(x => x.Location).HasMaxLength(100);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.ConsecutiveProbeFailures).IsRequired().HasDefaultValue(0);
builder.OwnsOne(
x => x.Credentials,
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddNodeConsecutiveProbeFailures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ConsecutiveProbeFailures",
table: "Nodes",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ConsecutiveProbeFailures",
table: "Nodes");
}
}
}
@@ -597,6 +597,11 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("ConsecutiveProbeFailures")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Plans;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Users;
@@ -12,8 +13,19 @@ public class ListUsersQueryHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static UserSummaryDto Summary(Guid id) =>
new(id, "alice", "premium", true, false, DateTimeOffset.UtcNow, true, DateTimeOffset.UtcNow.AddDays(-1));
private static UserSummaryDto Summary(Guid id, int configQuota = 3, Guid? planId = null) =>
new(
id,
"alice",
"premium",
true,
false,
DateTimeOffset.UtcNow,
true,
DateTimeOffset.UtcNow.AddDays(-1),
configQuota,
planId
);
[Fact]
public async Task Handle_WhenUserHasAwaitingConfirmationSubscriptionRequest_SetsBillingPendingReview()
@@ -78,4 +90,54 @@ public class ListUsersQueryHandlerTests
Assert.True(result.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview);
}
[Fact]
public async Task Handle_WhenUserHasCatalogPlan_FillsPlanName()
{
using var dbContext = InMemoryDbContextFactory.Create();
var plan = Plan.Create("Плюс", 6, 1);
dbContext.Plans.Add(plan);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(
new PagedList<UserSummaryDto>(
[Summary(Guid.NewGuid(), configQuota: 6, planId: plan.Id)],
1,
1,
20
)
);
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None);
Assert.True(result.IsSuccess);
var item = result.Value.Items.Single();
Assert.Equal("Плюс", item.PlanName);
Assert.Equal(6, item.ConfigQuota);
}
[Fact]
public async Task Handle_WhenQuotaIsCustom_LeavesPlanNameNull()
{
using var dbContext = InMemoryDbContextFactory.Create();
_identityService
.ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(
new PagedList<UserSummaryDto>([Summary(Guid.NewGuid(), configQuota: 12)], 1, 1, 20)
);
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None);
Assert.True(result.IsSuccess);
var item = result.Value.Items.Single();
Assert.Null(item.PlanName);
Assert.Equal(12, item.ConfigQuota);
}
}
@@ -120,6 +120,79 @@ public class NodeTests
Assert.Equal(NodeStatus.Online, node.Status);
}
[Fact]
public void RecordProbe_SingleFailure_DoesNotGoOffline_WhenThresholdNotReached()
{
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
node.UpdateStatus(NodeStatus.Online);
var changed = node.RecordProbe(reachable: false, failureThreshold: 2);
Assert.False(changed);
Assert.Equal(NodeStatus.Online, node.Status);
Assert.Equal(1, node.ConsecutiveProbeFailures);
}
[Fact]
public void RecordProbe_GoesOffline_OnlyAfterConsecutiveFailuresReachThreshold()
{
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
node.UpdateStatus(NodeStatus.Online);
Assert.False(node.RecordProbe(reachable: false, failureThreshold: 2));
Assert.Equal(NodeStatus.Online, node.Status);
var changed = node.RecordProbe(reachable: false, failureThreshold: 2);
Assert.True(changed);
Assert.Equal(NodeStatus.Offline, node.Status);
Assert.Equal(2, node.ConsecutiveProbeFailures);
}
[Fact]
public void RecordProbe_FlappingFailSuccess_NeverGoesOffline()
{
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
node.UpdateStatus(NodeStatus.Online);
// Чередование fail/success (ровно паттерн со скриншота) не должно ронять статус:
// порогу нужны ДВЕ подряд неудачи, а удачная проба обнуляет счётчик.
for (var i = 0; i < 5; i++)
{
Assert.False(node.RecordProbe(reachable: false, failureThreshold: 2));
Assert.False(node.RecordProbe(reachable: true, failureThreshold: 2));
Assert.Equal(NodeStatus.Online, node.Status);
Assert.Equal(0, node.ConsecutiveProbeFailures);
}
}
[Fact]
public void RecordProbe_SuccessAfterOffline_ReturnsOnlineImmediately_AndResetsCounter()
{
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
node.RecordProbe(reachable: false, failureThreshold: 2);
node.RecordProbe(reachable: false, failureThreshold: 2);
Assert.Equal(NodeStatus.Offline, node.Status);
var changed = node.RecordProbe(reachable: true, failureThreshold: 2);
Assert.True(changed);
Assert.Equal(NodeStatus.Online, node.Status);
Assert.Equal(0, node.ConsecutiveProbeFailures);
}
[Fact]
public void RecordProbe_SuccessWhenAlreadyOnline_ReportsNoChange()
{
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
node.UpdateStatus(NodeStatus.Online);
var changed = node.RecordProbe(reachable: true, failureThreshold: 2);
Assert.False(changed);
Assert.Equal(NodeStatus.Online, node.Status);
}
[Fact]
public void MarkSynced_SetsLastSyncAt()
{