Enhance user management and node health check features
- 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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1134
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 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user