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 committed 2026-08-05 08:34:17 +03:00
1 parent c2ed3240bd
commit 4b34c37ce3
20 files changed
+1563 -28

No files matched your search

@@ -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");