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.Subscriptions;
public sealed record GetConfigSubscriptionQuery(string Token) : IQuery<Result<SubscriptionDto>>;
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Subscriptions;
public sealed class GetConfigSubscriptionQueryHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
: IQueryHandler<GetConfigSubscriptionQuery, Result<SubscriptionDto>>
{
public async Task<Result<SubscriptionDto>> Handle(GetConfigSubscriptionQuery query, CancellationToken cancellationToken)
{
var config = await dbContext.VpnConfigs.AsNoTracking()
.FirstOrDefaultAsync(c => c.SubscriptionToken == query.Token && c.Status == ConfigStatus.Active, cancellationToken);
if (config is null)
return Result.Failure<SubscriptionDto>(SubscriptionErrors.NotFound);
return await SubscriptionAssembler.BuildAsync([config], dbContext, gateway, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Subscriptions;
public sealed record GetUserSubscriptionQuery(string Token) : IQuery<Result<SubscriptionDto>>;
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Subscriptions;
public sealed class GetUserSubscriptionQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway)
: IQueryHandler<GetUserSubscriptionQuery, Result<SubscriptionDto>>
{
public async Task<Result<SubscriptionDto>> Handle(GetUserSubscriptionQuery query, CancellationToken cancellationToken)
{
var userId = await identityService.FindUserIdBySubscriptionTokenAsync(query.Token, cancellationToken);
if (userId is null)
return Result.Failure<SubscriptionDto>(SubscriptionErrors.NotFound);
var configs = await dbContext.VpnConfigs.AsNoTracking()
.Where(c => c.UserId == userId && c.Status == ConfigStatus.Active)
.ToListAsync(cancellationToken);
return await SubscriptionAssembler.BuildAsync(configs, dbContext, gateway, cancellationToken);
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Subscriptions;
/// <summary>Общая сборка подписки: и агрегированная (все конфиги юзера), и по одному конфигу.</summary>
internal static class SubscriptionAssembler
{
public static async Task<Result<SubscriptionDto>> BuildAsync(
IReadOnlyList<VpnConfig> configs, IAppDbContext dbContext, IXuiPanelGateway gateway, CancellationToken cancellationToken)
{
var lines = new List<string>();
long usedUp = 0;
long usedDown = 0;
DateTimeOffset? expiresAt = null;
foreach (var config in configs)
{
var inbound = await dbContext.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
if (inbound is null)
continue;
var node = await dbContext.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is null || !node.IsEnabled)
continue;
var linkResult = await gateway.BuildConnectionStringAsync(
node, inbound, config.ClientExternalId, config.Label ?? config.ClientEmail, node.BaseAddress.Host, cancellationToken);
if (linkResult.IsSuccess)
lines.Add(linkResult.Value);
usedUp += config.UsedUpBytes;
usedDown += config.UsedDownBytes;
if (config.ExpiresAt is { } exp && (expiresAt is null || exp > expiresAt))
expiresAt = exp;
}
return Result.Success(new SubscriptionDto(lines, usedUp, usedDown, expiresAt));
}
}
@@ -0,0 +1,4 @@
namespace PnvPanel.Application.Subscriptions;
public sealed record SubscriptionDto(
IReadOnlyList<string> ConnectionStrings, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt);
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Subscriptions;
public static class SubscriptionErrors
{
public static readonly Error NotFound = Error.NotFound("Subscription.NotFound", "Подписка не найдена.");
}