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:
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user