Add maintenance cleanup for revoked and orphaned configs
Two new maintenance actions remove VpnConfig records (with their traffic samples) from the database: all revoked configs, and configs whose inbound or node no longer exists. 3x-ui is not touched; both write an AuditLog entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bc86dc426
commit
2c20fb0427
12 files changed
+367
-1
No files matched your search
@@ -23,6 +23,12 @@ public static class AdminMaintenanceEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||||
.Produces<MaintenanceCleanupResponseDto>();
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/configs/revoked", DeleteRevokedConfigs)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/configs/orphaned", DeleteOrphanedConfigs)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -59,6 +65,24 @@ public static class AdminMaintenanceEndpoints
|
|||||||
return ToResponse(result);
|
return ToResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteRevokedConfigs(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteRevokedConfigsCommand(), cancellationToken);
|
||||||
|
return ToResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteOrphanedConfigs(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteOrphanedConfigsCommand(), cancellationToken);
|
||||||
|
return ToResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> FactoryReset(
|
private static async Task<IResult> FactoryReset(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Maintenance;
|
||||||
|
|
||||||
|
/// <summary>Общее удаление конфигов для команд обслуживания: TrafficSample ссылается на конфиг без
|
||||||
|
/// FK-каскада, поэтому историю трафика удаляем явно вместе с конфигами.</summary>
|
||||||
|
internal static class ConfigCleanup
|
||||||
|
{
|
||||||
|
public static async Task RemoveWithTrafficAsync(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IReadOnlyCollection<VpnConfig> configs,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var configIds = configs.Select(c => c.Id).ToList();
|
||||||
|
var samples = await dbContext
|
||||||
|
.TrafficSamples.Where(s => configIds.Contains(s.ConfigId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
dbContext.TrafficSamples.RemoveRange(samples);
|
||||||
|
dbContext.VpnConfigs.RemoveRange(configs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Maintenance;
|
||||||
|
|
||||||
|
/// <summary>Удаляет из БД конфиги, чей инбаунд или нода уже удалены (в списке — «? · ?»), в любом
|
||||||
|
/// статусе, вместе с их историей трафика. Достучаться до 3x-ui для них некуда, поэтому панель не
|
||||||
|
/// трогаем. Value — число удалённых конфигов.</summary>
|
||||||
|
public sealed record DeleteOrphanedConfigsCommand : ICommand<Result<int>>;
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Maintenance;
|
||||||
|
|
||||||
|
public sealed class DeleteOrphanedConfigsCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<DeleteOrphanedConfigsCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
DeleteOrphanedConfigsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure<int>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
// Инбаунд жив только вместе со своей нодой — «сиротой» считается и конфиг на инбаунде
|
||||||
|
// удалённой ноды.
|
||||||
|
var liveInboundIds = dbContext
|
||||||
|
.Inbounds.Where(i => dbContext.Nodes.Any(n => n.Id == i.NodeId))
|
||||||
|
.Select(i => i.Id);
|
||||||
|
|
||||||
|
var configs = await dbContext
|
||||||
|
.VpnConfigs.Where(c => !liveInboundIds.Contains(c.InboundId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (configs.Count == 0)
|
||||||
|
return Result.Success(0);
|
||||||
|
|
||||||
|
await ConfigCleanup.RemoveWithTrafficAsync(dbContext, configs, cancellationToken);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"OrphanedConfigsCleanedUp",
|
||||||
|
"VpnConfig",
|
||||||
|
"bulk",
|
||||||
|
metadata: $"{{\"count\":{configs.Count}}}",
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(configs.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Maintenance;
|
||||||
|
|
||||||
|
/// <summary>Удаляет из БД все конфиги в статусе Revoked вместе с их историей трафика. В 3x-ui их
|
||||||
|
/// клиентов уже нет (отзыв удаляет клиента), поэтому панель не трогаем. Value — число удалённых
|
||||||
|
/// конфигов.</summary>
|
||||||
|
public sealed record DeleteRevokedConfigsCommand : ICommand<Result<int>>;
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Maintenance;
|
||||||
|
|
||||||
|
public sealed class DeleteRevokedConfigsCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<DeleteRevokedConfigsCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
DeleteRevokedConfigsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure<int>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var configs = await dbContext
|
||||||
|
.VpnConfigs.Where(c => c.Status == ConfigStatus.Revoked)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (configs.Count == 0)
|
||||||
|
return Result.Success(0);
|
||||||
|
|
||||||
|
await ConfigCleanup.RemoveWithTrafficAsync(dbContext, configs, cancellationToken);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"RevokedConfigsCleanedUp",
|
||||||
|
"VpnConfig",
|
||||||
|
"bulk",
|
||||||
|
metadata: $"{{\"count\":{configs.Count}}}",
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(configs.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
using PnvPanel.Application.Admin.Maintenance;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Admin.Maintenance;
|
||||||
|
|
||||||
|
public class DeleteConfigsCommandHandlerTests
|
||||||
|
{
|
||||||
|
private static Node CreateNode() =>
|
||||||
|
Node.Register(
|
||||||
|
"node-1",
|
||||||
|
new Uri("https://node-1.example.com"),
|
||||||
|
new NodeCredentials("admin", "protected"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
private static VpnConfig CreateConfig(Guid inboundId) =>
|
||||||
|
VpnConfig.Create(Guid.NewGuid(), inboundId, VpnProtocol.Vless, null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteRevoked_DeletesRevokedConfigsAndTheirTraffic_KeepsOthers()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var node = CreateNode();
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
|
var revoked = CreateConfig(inbound.Id);
|
||||||
|
revoked.Revoke();
|
||||||
|
var active = CreateConfig(inbound.Id);
|
||||||
|
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.AddRange(revoked, active);
|
||||||
|
dbContext.TrafficSamples.AddRange(
|
||||||
|
TrafficSample.Create(revoked.Id, DateTimeOffset.UtcNow, 1, 2),
|
||||||
|
TrafficSample.Create(active.Id, DateTimeOffset.UtcNow, 3, 4)
|
||||||
|
);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new DeleteRevokedConfigsCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteRevokedConfigsCommand(), CancellationToken.None);
|
||||||
|
// Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно.
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(1, result.Value);
|
||||||
|
Assert.Equal([active.Id], dbContext.VpnConfigs.Select(c => c.Id).ToList());
|
||||||
|
Assert.Equal([active.Id], dbContext.TrafficSamples.Select(s => s.ConfigId).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteOrphaned_DeletesConfigsWithoutInboundOrNode_KeepsLiveOnes()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var node = CreateNode();
|
||||||
|
var liveInbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "live", 443);
|
||||||
|
// Инбаунд, чья нода удалена.
|
||||||
|
var orphanInbound = Inbound.FromRemote(Guid.NewGuid(), "2", VpnProtocol.Vless, "orphan", 444);
|
||||||
|
|
||||||
|
var live = CreateConfig(liveInbound.Id);
|
||||||
|
var onDeletedNode = CreateConfig(orphanInbound.Id);
|
||||||
|
var onDeletedInbound = CreateConfig(Guid.NewGuid());
|
||||||
|
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.AddRange(liveInbound, orphanInbound);
|
||||||
|
dbContext.VpnConfigs.AddRange(live, onDeletedNode, onDeletedInbound);
|
||||||
|
dbContext.TrafficSamples.Add(
|
||||||
|
TrafficSample.Create(onDeletedInbound.Id, DateTimeOffset.UtcNow, 1, 2)
|
||||||
|
);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new DeleteOrphanedConfigsCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteOrphanedConfigsCommand(), CancellationToken.None);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value);
|
||||||
|
Assert.Equal([live.Id], dbContext.VpnConfigs.Select(c => c.Id).ToList());
|
||||||
|
Assert.Empty(dbContext.TrafficSamples);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteOrphaned_WhenNothingToDelete_ReturnsZero()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var node = CreateNode();
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "live", 443);
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.Add(CreateConfig(inbound.Id));
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new DeleteOrphanedConfigsCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new DeleteOrphanedConfigsCommand(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(0, result.Value);
|
||||||
|
Assert.Single(dbContext.VpnConfigs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,6 +241,8 @@ TicketAttachmentDto[] }`.
|
|||||||
| DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) |
|
| DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) |
|
||||||
| DELETE | `/api/admin/maintenance/audit-logs` | query: `olderThanDays` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней |
|
| DELETE | `/api/admin/maintenance/audit-logs` | query: `olderThanDays` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней |
|
||||||
| DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
|
| DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
|
||||||
|
| DELETE | `/api/admin/maintenance/configs/revoked` | — | `{ deletedCount }` — удаляет из БД все `VpnConfig` в статусе `Revoked` вместе с их `TrafficSample` (3x-ui не трогает — клиентов там уже нет) |
|
||||||
|
| DELETE | `/api/admin/maintenance/configs/orphaned` | — | `{ deletedCount }` — удаляет из БД `VpnConfig` (в любом статусе), чей `Inbound` или его `Node` уже удалены, вместе с `TrafficSample` |
|
||||||
| DELETE | `/api/admin/maintenance/factory-reset` | — | `204 No Content` — полный сброс панели к состоянию свежего деплоя |
|
| DELETE | `/api/admin/maintenance/factory-reset` | — | `204 No Content` — полный сброс панели к состоянию свежего деплоя |
|
||||||
|
|
||||||
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ AppUser
|
|||||||
ниже), не ролью. См. `Plan` ниже.
|
ниже), не ролью. См. `Plan` ниже.
|
||||||
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
||||||
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя).
|
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя).
|
||||||
|
- Отозванный конфиг (`Revoked`) остаётся в БД, пока админ не почистит его во вкладке «Обслуживание»
|
||||||
|
(`DELETE /api/admin/maintenance/configs/revoked`). Там же — удаление «сирот»: конфигов, чей
|
||||||
|
`Inbound` или его `Node` удалены (`DELETE /api/admin/maintenance/configs/orphaned`, в любом статусе).
|
||||||
|
Обе очистки удаляют и `TrafficSample` этих конфигов, 3x-ui не трогают.
|
||||||
|
|
||||||
> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него
|
> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него
|
||||||
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см.
|
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см.
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export function deleteDisabledApps() {
|
|||||||
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
|
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteRevokedConfigs() {
|
||||||
|
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/configs/revoked', { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOrphanedConfigs() {
|
||||||
|
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/configs/orphaned', { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
export function factoryReset() {
|
export function factoryReset() {
|
||||||
return apiRequest<void>('/admin/maintenance/factory-reset', { method: 'DELETE' })
|
return apiRequest<void>('/admin/maintenance/factory-reset', { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,13 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api'
|
import {
|
||||||
|
deleteClosedTickets,
|
||||||
|
deleteDisabledApps,
|
||||||
|
deleteOldAuditLogs,
|
||||||
|
deleteOrphanedConfigs,
|
||||||
|
deleteRevokedConfigs,
|
||||||
|
} from '@/features/admin/maintenance/api'
|
||||||
import { FactoryResetDialog } from '@/features/admin/maintenance/FactoryResetDialog'
|
import { FactoryResetDialog } from '@/features/admin/maintenance/FactoryResetDialog'
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
|
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
|
||||||
@@ -45,6 +51,23 @@ function AdminMaintenancePage() {
|
|||||||
onError: () => toast.error(t('auth.genericError')),
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onConfigsDeleted = async (data: { deletedCount: number }) => {
|
||||||
|
toast.success(t('admin.maintenance.configs.deleted', { count: data.deletedCount }))
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-configs'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRevokedConfigsMutation = useMutation({
|
||||||
|
mutationFn: deleteRevokedConfigs,
|
||||||
|
onSuccess: onConfigsDeleted,
|
||||||
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteOrphanedConfigsMutation = useMutation({
|
||||||
|
mutationFn: deleteOrphanedConfigs,
|
||||||
|
onSuccess: onConfigsDeleted,
|
||||||
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -120,6 +143,35 @@ function AdminMaintenancePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('admin.maintenance.configs.title')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.maintenance.configs.description')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={deleteRevokedConfigsMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('admin.maintenance.configs.revokedConfirm'))) deleteRevokedConfigsMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.configs.revokedAction')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={deleteOrphanedConfigsMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('admin.maintenance.configs.orphanedConfirm'))) deleteOrphanedConfigsMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.configs.orphanedAction')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<details className="group rounded-md border border-red-900/50">
|
<details className="group rounded-md border border-red-900/50">
|
||||||
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-red-400">
|
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-red-400">
|
||||||
{t('admin.maintenance.factoryReset.dangerZone')}
|
{t('admin.maintenance.factoryReset.dangerZone')}
|
||||||
|
|||||||
@@ -586,6 +586,17 @@ const resources = {
|
|||||||
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
||||||
deleted: 'Удалено приложений: {{count}}.',
|
deleted: 'Удалено приложений: {{count}}.',
|
||||||
},
|
},
|
||||||
|
configs: {
|
||||||
|
title: 'Конфиги',
|
||||||
|
description:
|
||||||
|
'Удалить из базы отозванные конфиги или конфиги, чей инбаунд/нода уже удалены (в списке — «? · ?»). Вместе с конфигами удаляется история трафика. Действие необратимо.',
|
||||||
|
revokedAction: 'Удалить все отозванные конфиги',
|
||||||
|
revokedConfirm: 'Удалить все отозванные конфиги? Действие необратимо.',
|
||||||
|
orphanedAction: 'Удалить все конфиги без ноды',
|
||||||
|
orphanedConfirm:
|
||||||
|
'Удалить все конфиги, чей инбаунд или нода удалены, в любом статусе? Действие необратимо.',
|
||||||
|
deleted: 'Удалено конфигов: {{count}}.',
|
||||||
|
},
|
||||||
factoryReset: {
|
factoryReset: {
|
||||||
dangerZone: 'Опасная зона',
|
dangerZone: 'Опасная зона',
|
||||||
title: 'Полный сброс панели',
|
title: 'Полный сброс панели',
|
||||||
@@ -1205,6 +1216,17 @@ const resources = {
|
|||||||
confirm: 'Delete all disabled apps? This cannot be undone.',
|
confirm: 'Delete all disabled apps? This cannot be undone.',
|
||||||
deleted: 'Deleted apps: {{count}}.',
|
deleted: 'Deleted apps: {{count}}.',
|
||||||
},
|
},
|
||||||
|
configs: {
|
||||||
|
title: 'Configs',
|
||||||
|
description:
|
||||||
|
'Delete revoked configs, or configs whose inbound/node no longer exists (shown as "? · ?"), from the database along with their traffic history. This cannot be undone.',
|
||||||
|
revokedAction: 'Delete all revoked configs',
|
||||||
|
revokedConfirm: 'Delete all revoked configs? This cannot be undone.',
|
||||||
|
orphanedAction: 'Delete all configs without a node',
|
||||||
|
orphanedConfirm:
|
||||||
|
'Delete all configs whose inbound or node was deleted, in any status? This cannot be undone.',
|
||||||
|
deleted: 'Deleted configs: {{count}}.',
|
||||||
|
},
|
||||||
factoryReset: {
|
factoryReset: {
|
||||||
dangerZone: 'Danger zone',
|
dangerZone: 'Danger zone',
|
||||||
title: 'Full panel reset',
|
title: 'Full panel reset',
|
||||||
|
|||||||
Reference in new issue
Block a user