Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management.

This commit is contained in:
Leonid Pershin
2026-07-01 22:38:01 +03:00
parent d8930409fe
commit 1a8d33efa3
229 changed files with 9226 additions and 20 deletions
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Create;
public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label, int? DeviceLimit) : ICommand<Result<VpnConfigDto>>;
@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.Create;
public sealed class CreateVpnConfigCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<CreateVpnConfigCommand, Result<VpnConfigDto>>
{
public async Task<Result<VpnConfigDto>> Handle(CreateVpnConfigCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
if (!profile.IsActivated)
return Result.Failure<VpnConfigDto>(ConfigErrors.NotActivated);
var inbound = await dbContext.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
if (inbound is null || !inbound.IsPublished)
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
if (!inbound.AllowedRoleIds.Contains(profile.RoleId))
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAllowedForRole);
var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is null || !node.IsEnabled)
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label, command.DeviceLimit ?? 0);
var reserveResult = await ReserveQuotaSlotAsync(userId, profile.MaxConfigs, config, cancellationToken);
if (!reserveResult.IsSuccess)
return Result.Failure<VpnConfigDto>(reserveResult.Error);
var addResult = await gateway.AddClientAsync(
node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail,
config.Label ?? config.ClientEmail, config.DeviceLimit, cancellationToken);
if (!addResult.IsSuccess)
{
// Компенсация: квота была зарезервирована локально, но клиент в 3x-ui не создался —
// откатываем резервирование, наружу не оставляем "мёртвую" запись.
dbContext.VpnConfigs.Remove(config);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Failure<VpnConfigDto>(addResult.Error);
}
config.AssignRemoteClient(addResult.Value);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success(VpnConfigDto.FromDomain(config, inbound));
}
/// <summary>
/// Проверка квоты + резервирование строки — под pg_advisory_xact_lock (гонки параллельных
/// созданий, см. CLAUDE.md). Лок держится только на время короткой транзакции count+insert,
/// НЕ на время внешнего HTTP-вызова к 3x-ui — иначе рискуем держать соединение к БД открытым
/// на секунды под внешним I/O.
/// </summary>
private async Task<Result> ReserveQuotaSlotAsync(Guid userId, int maxConfigs, VpnConfig config, CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtext({userId.ToString()}))", cancellationToken);
var activeCount = await dbContext.VpnConfigs
.CountAsync(c => c.UserId == userId && c.Status == ConfigStatus.Active, cancellationToken);
if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs)
{
await transaction.RollbackAsync(cancellationToken);
return Result.Failure(ConfigErrors.QuotaExceeded);
}
dbContext.VpnConfigs.Add(config);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Configs.Create;
public sealed class CreateVpnConfigCommandValidator : AbstractValidator<CreateVpnConfigCommand>
{
public CreateVpnConfigCommandValidator()
{
RuleFor(x => x.InboundId).NotEmpty();
RuleFor(x => x.Label).MaximumLength(100);
RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue);
}
}