Enhance user management and node health check features
CI / Backend (build + test) (push) Failing after 2m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 51s

- Updated `ListUsersQueryHandler` to include plan names and config quotas in `UserSummaryDto`, enriching user data retrieval.
- Implemented `WithPlanNamesAsync` method to fetch plan names based on user plan IDs, improving user experience in the admin interface.
- Enhanced `Node` class with a `ConsecutiveProbeFailures` property for better status management during health checks.
- Modified `NodeHealthCheckService` to utilize the new `RecordProbe` method, implementing a hysteresis mechanism for node status changes.
- Updated frontend components to display user config quotas and plan names, improving clarity in user management.
- Enhanced tests for user listing and node status handling to ensure robust functionality and coverage.
- Updated documentation to reflect changes in user and node management features.
This commit is contained in:
Leonid Pershin
2026-08-05 08:34:17 +03:00
parent c2ed3240bd
commit 4b34c37ce3
20 changed files with 1563 additions and 28 deletions
@@ -40,16 +40,40 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService, IApp
) )
.Select(r => r.UserId) .Select(r => r.UserId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (pendingUserIds.Count == 0)
return Result.Success(result);
var pendingSet = pendingUserIds.ToHashSet(); var pendingSet = pendingUserIds.ToHashSet();
var enrichedItems = result var enrichedItems = result
.Items.Select(u => pendingSet.Contains(u.Id) ? u with { BillingPendingReview = true } : u) .Items.Select(u => pendingSet.Contains(u.Id) ? u with { BillingPendingReview = true } : u)
.ToList(); .ToList();
var withPlanNames = await WithPlanNamesAsync(enrichedItems, cancellationToken);
return Result.Success( 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, DateTimeOffset? ActivatedAt,
bool BillingEnabled, bool BillingEnabled,
DateTimeOffset? BillingPaidUntil, DateTimeOffset? BillingPaidUntil,
/// <summary>Фактическая квота конфигов (AppUser.ConfigQuota), -1 = без лимита.</summary>
int ConfigQuota,
/// <summary>Выбранный каталожный тариф; null — квота задана вручную (оверрайд админа/кастом).</summary>
Guid? PlanId,
/// <summary>Есть Subscription-заявка на оплату в AwaitingConfirmation — конфиги не гасятся, пока /// <summary>Есть Subscription-заявка на оплату в AwaitingConfirmation — конфиги не гасятся, пока
/// админ не решит (см. BillingService). Заполняется в ListUsersQueryHandler (не здесь — Identity /// админ не решит (см. BillingService). Заполняется в ListUsersQueryHandler (не здесь — Identity
/// не должен знать про PaymentRequests), false по умолчанию для мест, не подгружающих это поле.</summary> /// не должен знать про 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); public sealed record UserStatsDto(int Total, int Activated);
+34
View File
@@ -14,6 +14,12 @@ public sealed class Node : Entity
public NodeCredentials Credentials { get; private set; } = null!; public NodeCredentials Credentials { get; private set; } = null!;
public string? Location { get; private set; } public string? Location { get; private set; }
public NodeStatus Status { 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 IsEnabled { get; private set; }
public bool NotifyOnStatusChange { get; private set; } public bool NotifyOnStatusChange { get; private set; }
public DateTimeOffset? LastSyncAt { 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; 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; public void MarkSynced() => LastSyncAt = DateTimeOffset.UtcNow;
} }
@@ -15,6 +15,11 @@ public sealed class NodeHealthCheckService(
{ {
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2); 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) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
using var timer = new PeriodicTimer(Interval); using var timer = new PeriodicTimer(Interval);
@@ -44,27 +49,57 @@ public sealed class NodeHealthCheckService(
foreach (var node in nodes) foreach (var node in nodes)
{ {
var probe = await gateway.ProbeAsync(node, cancellationToken); 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(
await notifier.NotifyNodeStatusChangedAsync( "Node {NodeId} ({NodeName}) probe failed ({Failures}/{Threshold}): {Reason}",
node.Id, node.Id,
newStatus, node.Name,
node.LastSyncAt, 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 cancellationToken
); );
if (node.NotifyOnStatusChange)
{
await telegramNotifier.NotifyAdminsNodeStatusChangedAsync(
node.Id,
node.Name,
newStatus,
cancellationToken
);
}
} }
} }
@@ -320,7 +320,9 @@ internal sealed class IdentityService(
user.IsBlocked, user.IsBlocked,
user.ActivatedAt, user.ActivatedAt,
role.BillingEnabled, 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.Location).HasMaxLength(100);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32); builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.ConsecutiveProbeFailures).IsRequired().HasDefaultValue(0);
builder.OwnsOne( builder.OwnsOne(
x => x.Credentials, x => x.Credentials,
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddNodeConsecutiveProbeFailures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ConsecutiveProbeFailures",
table: "Nodes",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ConsecutiveProbeFailures",
table: "Nodes");
}
}
}
@@ -597,6 +597,11 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
b.Property<int>("ConsecutiveProbeFailures")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing; using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Plans;
using Xunit; using Xunit;
namespace PnvPanel.Application.Tests.Admin.Users; namespace PnvPanel.Application.Tests.Admin.Users;
@@ -12,8 +13,19 @@ public class ListUsersQueryHandlerTests
{ {
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>(); private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static UserSummaryDto Summary(Guid id) => 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)); new(
id,
"alice",
"premium",
true,
false,
DateTimeOffset.UtcNow,
true,
DateTimeOffset.UtcNow.AddDays(-1),
configQuota,
planId
);
[Fact] [Fact]
public async Task Handle_WhenUserHasAwaitingConfirmationSubscriptionRequest_SetsBillingPendingReview() public async Task Handle_WhenUserHasAwaitingConfirmationSubscriptionRequest_SetsBillingPendingReview()
@@ -78,4 +90,54 @@ public class ListUsersQueryHandlerTests
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview); 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); 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] [Fact]
public void MarkSynced_SetsLastSyncAt() public void MarkSynced_SetsLastSyncAt()
{ {
+7
View File
@@ -382,6 +382,13 @@ approve/reject над `ActivationRequest`.
отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName, отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName,
usedUpBytes, usedDownBytes, expiresAt, status, createdAt }`. `search` матчится по `clientEmail`/`label`. 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`. **Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`.
`StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes, `StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes,
totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе, totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе,
+8 -3
View File
@@ -227,9 +227,14 @@ POST /api/configs
клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и
`TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты `TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)). и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`, - **NodeHealthCheckService** — health-probe нод раз в 2 минуты (`IXuiPanelGateway.ProbeAsync`), обновляет
шлёт `nodeStatusChanged` группе `admins`. Дополнительно, если у ноды `NotifyOnStatusChange = true`, `NodeStatus` через `Node.RecordProbe` с **гистерезисом**: Offline выставляется только после 2 подряд
при каждом переходе Online↔Offline шлёт админам ещё и Telegram-уведомление неудачных проб (счётчик `Node.ConsecutiveProbeFailures`, обнуляется первой удачной пробой), обратно в
Online — по первой же удачной. Так единичные HTTP-заминки панели (таймаут/реавторизация/`No route to
host` при моргании сети, ICMP при этом в норме) не порождают ложных переходов и спама уведомлениями.
При смене статуса шлёт `nodeStatusChanged` группе `admins`; причина падения и счётчик неудач
логируются. Дополнительно, если у ноды `NotifyOnStatusChange = true`, при каждом переходе
Online↔Offline шлёт админам ещё и Telegram-уведомление
(`ITelegramNotifier.NotifyAdminsNodeStatusChangedAsync`) — опция включается индивидуально на ноду (`ITelegramNotifier.NotifyAdminsNodeStatusChangedAsync`) — опция включается индивидуально на ноду
(`PUT /api/admin/nodes/{id}`), по умолчанию выключена. (`PUT /api/admin/nodes/{id}`), по умолчанию выключена.
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика). - **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
+4 -2
View File
@@ -50,6 +50,7 @@ AppUser
| `Credentials` | `NodeCredentials` (VO) | Логин + **зашифрованный** пароль (`ISecretProtector`) | | `Credentials` | `NodeCredentials` (VO) | Логин + **зашифрованный** пароль (`ISecretProtector`) |
| `Location` | `string?` | Страна/город/тег для выбора пользователем | | `Location` | `string?` | Страна/город/тег для выбора пользователем |
| `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` | | `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` |
| `ConsecutiveProbeFailures` | `int` | Счётчик подряд неудачных проб для гистерезиса статуса (`RecordProbe`); обнуляется первой удачной пробой |
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания | | `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` | | `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` |
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация | | `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
@@ -111,8 +112,9 @@ AppUser
не показывают вовсе, а админский список подставляет "?" вместо локации отсутствующего инбаунда. не показывают вовсе, а админский список подставляет "?" вместо локации отсутствующего инбаунда.
> `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический
> индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > индикатор для админа, не гейт для создания конфига: он кэшированный и, несмотря на гистерезис
> `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов > (`RecordProbe`: Offline лишь после 2 подряд неудачных проб), может отставать от реальности. Реальную
> недоступность ноды ловит вызов
> `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией > `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией
> зарезервированной квоты, а не заранее закэшированным статусом. > зарезервированной квоты, а не заранее закэшированным статусом.
+5
View File
@@ -111,6 +111,11 @@ frontend/
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации, - **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`, пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`,
без TanStack Table. Блокировка пользователя — с подтверждением. без TanStack Table. Блокировка пользователя — с подтверждением.
- **Пользователи** (`/admin/users`): колонка «Конфигов» показывает квоту (`configQuota`, `∞` для
безлимита) и рядом имя тарифа либо «своя квота». В `UserManageDialog` — блок квоты: выбор
каталожного тарифа (применяется сразу, как смена роли) либо своё число конфигов через
`PATCH /api/admin/users/{id}/plan`. Подпись явно говорит, что админская смена идёт **без доплаты**
и что при понижении уже созданные конфиги не отзываются.
- **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как - **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как
«пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки). «пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки).
@@ -9,9 +9,11 @@ import { Label } from '@/shared/ui/label'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listRoles } from '@/features/admin/roles/api' import { listRoles } from '@/features/admin/roles/api'
import { listAdminPlans } from '@/features/admin/plans/api'
import { grantBillingGift } from '@/features/admin/billing/api' import { grantBillingGift } from '@/features/admin/billing/api'
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
import { useAuthStore } from '@/features/auth/store' import { useAuthStore } from '@/features/auth/store'
import { HttpError } from '@/shared/api/client'
import type { UserSummaryDto } from '@/shared/api/types' import type { UserSummaryDto } from '@/shared/api/types'
import { import {
blockUser, blockUser,
@@ -20,6 +22,7 @@ import {
forceRevokeConfig, forceRevokeConfig,
getUserConfigs, getUserConfigs,
resetUserPassword, resetUserPassword,
setUserPlan,
unblockUser, unblockUser,
} from './api' } from './api'
@@ -28,9 +31,11 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [giftDays, setGiftDays] = useState('') const [giftDays, setGiftDays] = useState('')
const [customConfigCount, setCustomConfigCount] = useState('')
const currentUserId = useAuthStore((state) => state.user?.id) const currentUserId = useAuthStore((state) => state.user?.id)
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open }) 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 configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }) 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')), 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({ const resetPasswordMutation = useMutation({
mutationFn: () => resetUserPassword(user.id, newPassword), mutationFn: () => resetUserPassword(user.id, newPassword),
onSuccess: () => { onSuccess: () => {
@@ -132,6 +147,51 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
</Select> </Select>
</div> </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"> <div className="flex flex-col gap-1.5">
<Label htmlFor="newPassword">{t('admin.users.resetPassword')}</Label> <Label htmlFor="newPassword">{t('admin.users.resetPassword')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
+13
View File
@@ -43,6 +43,19 @@ export function changeUserRole(id: string, roleId: string) {
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } }) 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) { export function deleteUser(id: string) {
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' }) return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
} }
+9
View File
@@ -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.userName')}</th>
<th className="py-2 font-medium">{t('admin.users.role')}</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.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 font-medium">{t('admin.users.billingLabel')}</th>
<th className="py-2" /> <th className="py-2" />
</tr> </tr>
@@ -148,6 +149,14 @@ function AdminUsersPage() {
: t('admin.users.status.pending')} : t('admin.users.status.pending')}
</Badge> </Badge>
</td> </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"> <td className="py-2">
{user.billingEnabled ? ( {user.billingEnabled ? (
<PaidUntilBadge <PaidUntilBadge
+6
View File
@@ -177,9 +177,15 @@ export type UserSummaryDto = {
activatedAt: string | null activatedAt: string | null
billingEnabled: boolean billingEnabled: boolean
billingPaidUntil: string | null billingPaidUntil: string | null
/** Квота конфигов пользователя; -1 = без лимита (только admin). */
configQuota: number
/** Выбранный каталожный тариф; null — квота задана вручную. */
planId: string | null
/** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не /** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не
* решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */ * решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */
billingPendingReview: boolean billingPendingReview: boolean
/** Имя тарифа по planId; null для кастомной квоты. */
planName: string | null
} }
export type RoleDto = { export type RoleDto = {
+22
View File
@@ -307,6 +307,17 @@ const resources = {
giftDaysPlaceholder: 'Дней', giftDaysPlaceholder: 'Дней',
giftGrant: 'Подарить', giftGrant: 'Подарить',
giftGranted: 'Дни подписки подарены пользователю.', giftGranted: 'Дни подписки подарены пользователю.',
configQuota: 'Конфигов',
customQuota: 'своя квота',
unlimitedQuota: 'без лимита',
planDisabled: 'отключён',
customQuotaPlaceholder: 'Своё число конфигов',
selectPlan: 'Выбрать тариф',
planConfigCount: 'конфигов: {{count}}',
quotaCurrent: 'Сейчас: {{quota}} — {{plan}}',
applyQuota: 'Применить',
quotaChanged: 'Квота конфигов изменена.',
quotaHint: 'Смена админом — без доплаты; при понижении уже созданные конфиги не отзываются, новые не создать до входа в квоту.',
}, },
configs: { configs: {
searchPlaceholder: 'Поиск по email в панели или метке', searchPlaceholder: 'Поиск по email в панели или метке',
@@ -907,6 +918,17 @@ const resources = {
giftDaysPlaceholder: 'Days', giftDaysPlaceholder: 'Days',
giftGrant: 'Grant', giftGrant: 'Grant',
giftGranted: 'Subscription days granted to the user.', 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: { configs: {
searchPlaceholder: 'Search by panel email or label', searchPlaceholder: 'Search by panel email or label',