diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs index dc06b93..ed30091 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs @@ -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(enrichedItems, result.Total, result.Page, result.PageSize) + new PagedList(withPlanNames, result.Total, result.Page, result.PageSize) ); } + + /// Имена тарифов по PlanId — тоже мимо IIdentityService: каталог Plans живёт в домене, + /// Identity знает только Id выбранного тарифа. + private async Task> WithPlanNamesAsync( + IReadOnlyList 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(); + } } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 60437ff..5db0c23 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -38,10 +38,17 @@ public sealed record UserSummaryDto( DateTimeOffset? ActivatedAt, bool BillingEnabled, DateTimeOffset? BillingPaidUntil, + /// Фактическая квота конфигов (AppUser.ConfigQuota), -1 = без лимита. + int ConfigQuota, + /// Выбранный каталожный тариф; null — квота задана вручную (оверрайд админа/кастом). + Guid? PlanId, /// Есть Subscription-заявка на оплату в AwaitingConfirmation — конфиги не гасятся, пока /// админ не решит (см. BillingService). Заполняется в ListUsersQueryHandler (не здесь — Identity /// не должен знать про PaymentRequests), false по умолчанию для мест, не подгружающих это поле. - bool BillingPendingReview = false + bool BillingPendingReview = false, + /// Имя тарифа по PlanId — тоже заполняется в ListUsersQueryHandler (Identity не знает + /// про каталог Plans), null для кастомной квоты и для мест, не подгружающих это поле. + string? PlanName = null ); public sealed record UserStatsDto(int Total, int Activated); diff --git a/backend/src/PnvPanel.Domain/Nodes/Node.cs b/backend/src/PnvPanel.Domain/Nodes/Node.cs index 966defd..4efa52d 100644 --- a/backend/src/PnvPanel.Domain/Nodes/Node.cs +++ b/backend/src/PnvPanel.Domain/Nodes/Node.cs @@ -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; } + + /// Счётчик подряд идущих неудачных проб (см. ) — обнуляется + /// первой же удачной пробой. Нужен для гистерезиса статуса: одна HTTP-заминка панели + /// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями. + 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; + /// + /// Регистрирует результат фоновой пробы с гистерезисом и возвращает true, если статус + /// изменился (тогда вызывающий код шлёт уведомления). Любая удачная проба немедленно возвращает + /// ноду в и обнуляет счётчик; в + /// нода уходит только после подряд неудачных проб — так + /// единичные заминки HTTP-слоя панели не порождают ложных переходов и спама (см. + /// NodeHealthCheckService). + /// + 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; } diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs index 8be7fce..585f78f 100644 --- a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs @@ -15,6 +15,11 @@ public sealed class NodeHealthCheckService( { private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2); + /// Сколько подряд неудачных проб нужно, чтобы признать ноду Offline (гистерезис против + /// флапа на транзиентных HTTP-заминках панели, ICMP при этом обычно в норме) — см. + /// . При = 2 мин это ≈ 4 минуты. + 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 - ); - } } } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index b867455..ff54b96 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -320,7 +320,9 @@ internal sealed class IdentityService( user.IsBlocked, user.ActivatedAt, role.BillingEnabled, - user.BillingPaidUntil + user.BillingPaidUntil, + user.ConfigQuota, + user.PlanId ) ); } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs index 8fb374a..d3a189f 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs @@ -21,6 +21,7 @@ public class NodeConfiguration : IEntityTypeConfiguration builder.Property(x => x.Location).HasMaxLength(100); builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + builder.Property(x => x.ConsecutiveProbeFailures).IsRequired().HasDefaultValue(0); builder.OwnsOne( x => x.Credentials, diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260805052038_AddNodeConsecutiveProbeFailures.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260805052038_AddNodeConsecutiveProbeFailures.Designer.cs new file mode 100644 index 0000000..1681af8 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260805052038_AddNodeConsecutiveProbeFailures.Designer.cs @@ -0,0 +1,1134 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260805052038_AddNodeConsecutiveProbeFailures")] + partial class AddNodeConsecutiveProbeFailures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Period") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Media.MediaImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UploadedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("MediaImages", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ConsecutiveProbeFailures") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NotifyOnStatusChange") + .HasColumnType("boolean"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigCount") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Plans", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("PricingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PricingSettingsId", "MinConfigs") + .IsUnique(); + + b.ToTable("PricingDiscountTiers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedDays") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ConfigQuota") + .HasColumnType("integer"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260805052038_AddNodeConsecutiveProbeFailures.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260805052038_AddNodeConsecutiveProbeFailures.cs new file mode 100644 index 0000000..7f31346 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260805052038_AddNodeConsecutiveProbeFailures.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNodeConsecutiveProbeFailures : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ConsecutiveProbeFailures", + table: "Nodes", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ConsecutiveProbeFailures", + table: "Nodes"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index b0d9e59..18ac2dd 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -597,6 +597,11 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("ConsecutiveProbeFailures") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs index 9513a43..ab6734d 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs @@ -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(); - 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()) + .Returns( + new PagedList( + [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()) + .Returns( + new PagedList([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); + } } diff --git a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs index 72a5d38..dda096e 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs @@ -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() { diff --git a/docs/api-design.md b/docs/api-design.md index 3582473..73941b7 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -382,6 +382,13 @@ approve/reject над `ActivationRequest`. отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }`. `search` матчится по `clientEmail`/`label`. +`UserSummaryDto`: `{ id, userName, role, isActivated, isBlocked, activatedAt, billingEnabled, +billingPaidUntil, configQuota, planId, billingPendingReview, planName }`. `configQuota` — фактическая +квота конфигов (`-1` = без лимита), `planName` — имя каталожного тарифа по `planId` (`null`, если +квота задана вручную); и `planName`, и `billingPendingReview` домешивает `ListUsersQueryHandler` — +`IIdentityService` не знает ни про `Plans`, ни про `PaymentRequests`. Менять квоту админ может через +`PATCH /api/admin/users/{id}/plan` (см. Admin — Roles & Plans), доплата при этом не создаётся. + **Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`. `StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes, totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе, diff --git a/docs/architecture.md b/docs/architecture.md index 1378c32..1583ade 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -227,9 +227,14 @@ POST /api/configs клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и `TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)). -- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`, - шлёт `nodeStatusChanged` группе `admins`. Дополнительно, если у ноды `NotifyOnStatusChange = true`, - при каждом переходе Online↔Offline шлёт админам ещё и Telegram-уведомление +- **NodeHealthCheckService** — health-probe нод раз в 2 минуты (`IXuiPanelGateway.ProbeAsync`), обновляет + `NodeStatus` через `Node.RecordProbe` с **гистерезисом**: Offline выставляется только после 2 подряд + неудачных проб (счётчик `Node.ConsecutiveProbeFailures`, обнуляется первой удачной пробой), обратно в + Online — по первой же удачной. Так единичные HTTP-заминки панели (таймаут/реавторизация/`No route to + host` при моргании сети, ICMP при этом в норме) не порождают ложных переходов и спама уведомлениями. + При смене статуса шлёт `nodeStatusChanged` группе `admins`; причина падения и счётчик неудач + логируются. Дополнительно, если у ноды `NotifyOnStatusChange = true`, при каждом переходе + Online↔Offline шлёт админам ещё и Telegram-уведомление (`ITelegramNotifier.NotifyAdminsNodeStatusChangedAsync`) — опция включается индивидуально на ноду (`PUT /api/admin/nodes/{id}`), по умолчанию выключена. - **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика). diff --git a/docs/domain-model.md b/docs/domain-model.md index fcb4153..93b8b3e 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -50,6 +50,7 @@ AppUser | `Credentials` | `NodeCredentials` (VO) | Логин + **зашифрованный** пароль (`ISecretProtector`) | | `Location` | `string?` | Страна/город/тег для выбора пользователем | | `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` | +| `ConsecutiveProbeFailures` | `int` | Счётчик подряд неудачных проб для гистерезиса статуса (`RecordProbe`); обнуляется первой удачной пробой | | `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания | | `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` | | `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация | @@ -111,8 +112,9 @@ AppUser не показывают вовсе, а админский список подставляет "?" вместо локации отсутствующего инбаунда. > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический -> индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать -> `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов +> индикатор для админа, не гейт для создания конфига: он кэшированный и, несмотря на гистерезис +> (`RecordProbe`: Offline лишь после 2 подряд неудачных проб), может отставать от реальности. Реальную +> недоступность ноды ловит вызов > `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией > зарезервированной квоты, а не заранее закэшированным статусом. diff --git a/docs/frontend.md b/docs/frontend.md index 9ea9f93..d4036b7 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -111,6 +111,11 @@ frontend/ - **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации, пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные ``, без TanStack Table. Блокировка пользователя — с подтверждением. +- **Пользователи** (`/admin/users`): колонка «Конфигов» показывает квоту (`configQuota`, `∞` для + безлимита) и рядом имя тарифа либо «своя квота». В `UserManageDialog` — блок квоты: выбор + каталожного тарифа (применяется сразу, как смена роли) либо своё число конфигов через + `PATCH /api/admin/users/{id}/plan`. Подпись явно говорит, что админская смена идёт **без доплаты** + и что при понижении уже созданные конфиги не отзываются. - **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как «пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки). diff --git a/frontend/src/features/admin/users/UserManageDialog.tsx b/frontend/src/features/admin/users/UserManageDialog.tsx index a81aea3..e908b9f 100644 --- a/frontend/src/features/admin/users/UserManageDialog.tsx +++ b/frontend/src/features/admin/users/UserManageDialog.tsx @@ -9,9 +9,11 @@ import { Label } from '@/shared/ui/label' import { Badge } from '@/shared/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { listRoles } from '@/features/admin/roles/api' +import { listAdminPlans } from '@/features/admin/plans/api' import { grantBillingGift } from '@/features/admin/billing/api' import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { useAuthStore } from '@/features/auth/store' +import { HttpError } from '@/shared/api/client' import type { UserSummaryDto } from '@/shared/api/types' import { blockUser, @@ -20,6 +22,7 @@ import { forceRevokeConfig, getUserConfigs, resetUserPassword, + setUserPlan, unblockUser, } from './api' @@ -28,9 +31,11 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma const queryClient = useQueryClient() const [newPassword, setNewPassword] = useState('') const [giftDays, setGiftDays] = useState('') + const [customConfigCount, setCustomConfigCount] = useState('') const currentUserId = useAuthStore((state) => state.user?.id) const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open }) + const plansQuery = useQuery({ queryKey: ['admin-plans'], queryFn: listAdminPlans, enabled: open }) const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open }) const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }) @@ -53,6 +58,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma onError: () => toast.error(t('auth.genericError')), }) + const planMutation = useMutation({ + mutationFn: (plan: { planId: string } | { customConfigCount: number }) => setUserPlan(user.id, plan), + onSuccess: async () => { + toast.success(t('admin.users.quotaChanged')) + setCustomConfigCount('') + await invalidateUsers() + }, + onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), + }) + const resetPasswordMutation = useMutation({ mutationFn: () => resetUserPassword(user.id, newPassword), onSuccess: () => { @@ -132,6 +147,51 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma +
+ +

+ {t('admin.users.quotaCurrent', { + quota: user.configQuota === -1 ? '∞' : user.configQuota, + plan: + user.configQuota === -1 + ? t('admin.users.unlimitedQuota') + : (user.planName ?? t('admin.users.customQuota')), + })} +

+ +
+ setCustomConfigCount(e.target.value)} + placeholder={t('admin.users.customQuotaPlaceholder')} + /> + +
+

{t('admin.users.quotaHint')}

+
+
diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts index 17b255b..304e778 100644 --- a/frontend/src/features/admin/users/api.ts +++ b/frontend/src/features/admin/users/api.ts @@ -43,6 +43,19 @@ export function changeUserRole(id: string, roleId: string) { return apiRequest(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } }) } +/** Админский оверрайд тарифа/квоты: доплата не создаётся и лишние конфиги при понижении не + * отзываются (грандфазеринг) — в отличие от самообслуживания `POST /api/plans/change`. + * Передаётся ровно одно из двух: каталожный тариф либо произвольное число конфигов. */ +export function setUserPlan(id: string, plan: { planId: string } | { customConfigCount: number }) { + return apiRequest(`/admin/users/${id}/plan`, { + method: 'PATCH', + body: { + planId: 'planId' in plan ? plan.planId : null, + customConfigCount: 'customConfigCount' in plan ? plan.customConfigCount : null, + }, + }) +} + export function deleteUser(id: string) { return apiRequest(`/admin/users/${id}`, { method: 'DELETE' }) } diff --git a/frontend/src/routes/admin/users.tsx b/frontend/src/routes/admin/users.tsx index 0f85910..c828bc7 100644 --- a/frontend/src/routes/admin/users.tsx +++ b/frontend/src/routes/admin/users.tsx @@ -130,6 +130,7 @@ function AdminUsersPage() {
+ @@ -148,6 +149,14 @@ function AdminUsersPage() { : t('admin.users.status.pending')} +
{t('admin.users.userName')} {t('admin.users.role')} {t('admin.users.statusLabel')}{t('admin.users.configQuota')} {t('admin.users.billingLabel')}
+ {user.configQuota === -1 ? '∞' : user.configQuota} + + {user.configQuota === -1 + ? t('admin.users.unlimitedQuota') + : (user.planName ?? t('admin.users.customQuota'))} + + {user.billingEnabled ? (