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,14 +49,45 @@ 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);
|
||||
logger.LogWarning(
|
||||
"Node {NodeId} ({NodeName}) probe failed ({Failures}/{Threshold}): {Reason}",
|
||||
node.Id,
|
||||
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,
|
||||
newStatus,
|
||||
node.Status,
|
||||
node.LastSyncAt,
|
||||
cancellationToken
|
||||
);
|
||||
@@ -61,12 +97,11 @@ public sealed class NodeHealthCheckService(
|
||||
await telegramNotifier.NotifyAdminsNodeStatusChangedAsync(
|
||||
node.Id,
|
||||
node.Name,
|
||||
newStatus,
|
||||
node.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(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()
|
||||
{
|
||||
|
||||
@@ -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 }` — считается на лету при запросе,
|
||||
|
||||
@@ -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-ретеншн истории трафика).
|
||||
|
||||
@@ -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` в момент создания — с честной ошибкой и компенсацией
|
||||
> зарезервированной квоты, а не заранее закэшированным статусом.
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ frontend/
|
||||
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
||||
пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`,
|
||||
без TanStack Table. Блокировка пользователя — с подтверждением.
|
||||
- **Пользователи** (`/admin/users`): колонка «Конфигов» показывает квоту (`configQuota`, `∞` для
|
||||
безлимита) и рядом имя тарифа либо «своя квота». В `UserManageDialog` — блок квоты: выбор
|
||||
каталожного тарифа (применяется сразу, как смена роли) либо своё число конфигов через
|
||||
`PATCH /api/admin/users/{id}/plan`. Подпись явно говорит, что админская смена идёт **без доплаты**
|
||||
и что при понижении уже созданные конфиги не отзываются.
|
||||
- **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как
|
||||
«пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки).
|
||||
|
||||
|
||||
@@ -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
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.users.configQuota')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('admin.users.quotaCurrent', {
|
||||
quota: user.configQuota === -1 ? '∞' : user.configQuota,
|
||||
plan:
|
||||
user.configQuota === -1
|
||||
? t('admin.users.unlimitedQuota')
|
||||
: (user.planName ?? t('admin.users.customQuota')),
|
||||
})}
|
||||
</p>
|
||||
<Select value={user.planId ?? ''} onValueChange={(planId) => planMutation.mutate({ planId })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.users.selectPlan')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plansQuery.data?.map((plan) => (
|
||||
<SelectItem key={plan.id} value={plan.id}>
|
||||
{plan.name} · {t('admin.users.planConfigCount', { count: plan.configCount })}
|
||||
{/* Отключённые тарифы скрыты от пользователей, но админу назначить их можно —
|
||||
помечаем, чтобы это было осознанным выбором, а не случайным. */}
|
||||
{plan.isEnabled ? '' : ` · ${t('admin.users.planDisabled')}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={customConfigCount}
|
||||
onChange={(e) => setCustomConfigCount(e.target.value)}
|
||||
placeholder={t('admin.users.customQuotaPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!(Number(customConfigCount) > 0) || planMutation.isPending}
|
||||
onClick={() => planMutation.mutate({ customConfigCount: Number(customConfigCount) })}
|
||||
>
|
||||
{t('admin.users.applyQuota')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.users.quotaHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword">{t('admin.users.resetPassword')}</Label>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -43,6 +43,19 @@ export function changeUserRole(id: string, roleId: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
|
||||
}
|
||||
|
||||
/** Админский оверрайд тарифа/квоты: доплата не создаётся и лишние конфиги при понижении не
|
||||
* отзываются (грандфазеринг) — в отличие от самообслуживания `POST /api/plans/change`.
|
||||
* Передаётся ровно одно из двух: каталожный тариф либо произвольное число конфигов. */
|
||||
export function setUserPlan(id: string, plan: { planId: string } | { customConfigCount: number }) {
|
||||
return apiRequest<void>(`/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<void>(`/admin/users/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ function AdminUsersPage() {
|
||||
<th className="py-2 font-medium">{t('admin.users.userName')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.users.role')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.users.statusLabel')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.users.configQuota')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.users.billingLabel')}</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
@@ -148,6 +149,14 @@ function AdminUsersPage() {
|
||||
: t('admin.users.status.pending')}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span>{user.configQuota === -1 ? '∞' : user.configQuota}</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{user.configQuota === -1
|
||||
? t('admin.users.unlimitedQuota')
|
||||
: (user.planName ?? t('admin.users.customQuota'))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{user.billingEnabled ? (
|
||||
<PaidUntilBadge
|
||||
|
||||
@@ -177,9 +177,15 @@ export type UserSummaryDto = {
|
||||
activatedAt: string | null
|
||||
billingEnabled: boolean
|
||||
billingPaidUntil: string | null
|
||||
/** Квота конфигов пользователя; -1 = без лимита (только admin). */
|
||||
configQuota: number
|
||||
/** Выбранный каталожный тариф; null — квота задана вручную. */
|
||||
planId: string | null
|
||||
/** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не
|
||||
* решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */
|
||||
billingPendingReview: boolean
|
||||
/** Имя тарифа по planId; null для кастомной квоты. */
|
||||
planName: string | null
|
||||
}
|
||||
|
||||
export type RoleDto = {
|
||||
|
||||
@@ -307,6 +307,17 @@ const resources = {
|
||||
giftDaysPlaceholder: 'Дней',
|
||||
giftGrant: 'Подарить',
|
||||
giftGranted: 'Дни подписки подарены пользователю.',
|
||||
configQuota: 'Конфигов',
|
||||
customQuota: 'своя квота',
|
||||
unlimitedQuota: 'без лимита',
|
||||
planDisabled: 'отключён',
|
||||
customQuotaPlaceholder: 'Своё число конфигов',
|
||||
selectPlan: 'Выбрать тариф',
|
||||
planConfigCount: 'конфигов: {{count}}',
|
||||
quotaCurrent: 'Сейчас: {{quota}} — {{plan}}',
|
||||
applyQuota: 'Применить',
|
||||
quotaChanged: 'Квота конфигов изменена.',
|
||||
quotaHint: 'Смена админом — без доплаты; при понижении уже созданные конфиги не отзываются, новые не создать до входа в квоту.',
|
||||
},
|
||||
configs: {
|
||||
searchPlaceholder: 'Поиск по email в панели или метке',
|
||||
@@ -907,6 +918,17 @@ const resources = {
|
||||
giftDaysPlaceholder: 'Days',
|
||||
giftGrant: 'Grant',
|
||||
giftGranted: 'Subscription days granted to the user.',
|
||||
configQuota: 'Configs',
|
||||
customQuota: 'custom quota',
|
||||
unlimitedQuota: 'unlimited',
|
||||
planDisabled: 'disabled',
|
||||
customQuotaPlaceholder: 'Custom config count',
|
||||
selectPlan: 'Select plan',
|
||||
planConfigCount: 'configs: {{count}}',
|
||||
quotaCurrent: 'Current: {{quota}} — {{plan}}',
|
||||
applyQuota: 'Apply',
|
||||
quotaChanged: 'Config quota updated.',
|
||||
quotaHint: 'Admin change is free of charge; on downgrade existing configs are kept, new ones are blocked until the user is within quota.',
|
||||
},
|
||||
configs: {
|
||||
searchPlaceholder: 'Search by panel email or label',
|
||||
|
||||
Reference in New Issue
Block a user