Enhance admin maintenance functionality with new endpoints and response types
- Added new DELETE endpoints for managing audit logs and disabled apps in the admin maintenance section. - Updated existing endpoint for closed tickets to use a unified response type, `MaintenanceCleanupResponseDto`. - Enhanced API documentation to reflect the new operations and their expected request/response formats. - Improved frontend integration with new functions for deleting old audit logs and disabled apps, including user confirmation prompts. - Added localization support for new maintenance actions in both Russian and English.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Maintenance;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
@@ -15,7 +16,13 @@ public static class AdminMaintenanceEndpoints
|
||||
|
||||
admin
|
||||
.MapDelete("/tickets/closed", DeleteClosedTickets)
|
||||
.Produces<DeleteClosedTicketsResponseDto>();
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
admin
|
||||
.MapDelete("/audit-logs", DeleteOldAuditLogs)
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
admin
|
||||
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -26,11 +33,35 @@ public static class AdminMaintenanceEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
return Results.Ok(new DeleteClosedTicketsResponseDto(result.Value));
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOldAuditLogs(
|
||||
int olderThanDays,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new DeleteOldAuditLogsCommand(olderThanDays),
|
||||
cancellationToken
|
||||
);
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteDisabledApps(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteDisabledAppsCommand(), cancellationToken);
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static IResult ToResponse(Result<int> result) =>
|
||||
result.IsSuccess
|
||||
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
public sealed record DeleteClosedTicketsResponseDto(int DeletedCount);
|
||||
public sealed record MaintenanceCleanupResponseDto(int DeletedCount);
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ public sealed class DeleteClosedTicketsCommandHandler(
|
||||
"ClosedTicketsCleanedUp",
|
||||
"SupportTicket",
|
||||
"bulk",
|
||||
metadata: $"count={ticketIds.Count}",
|
||||
metadata: $"{{\"count\":{ticketIds.Count}}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
/// <summary>Удаляет все ClientApp с IsEnabled = false. Value — число удалённых записей.</summary>
|
||||
public sealed record DeleteDisabledAppsCommand : ICommand<Result<int>>;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
public sealed class DeleteDisabledAppsCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteDisabledAppsCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> Handle(
|
||||
DeleteDisabledAppsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure<int>(AuthErrors.Unauthorized);
|
||||
|
||||
var apps = await dbContext
|
||||
.ClientApps.Where(a => !a.IsEnabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (apps.Count == 0)
|
||||
return Result.Success(0);
|
||||
|
||||
dbContext.ClientApps.RemoveRange(apps);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"DisabledAppsCleanedUp",
|
||||
"ClientApp",
|
||||
"bulk",
|
||||
metadata: $"{{\"count\":{apps.Count}}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(apps.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
/// <summary>Удаляет записи AuditLog старше OlderThanDays дней. Value — число удалённых записей.</summary>
|
||||
public sealed record DeleteOldAuditLogsCommand(int OlderThanDays) : ICommand<Result<int>>;
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
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.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
public sealed class DeleteOldAuditLogsCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteOldAuditLogsCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> Handle(
|
||||
DeleteOldAuditLogsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure<int>(AuthErrors.Unauthorized);
|
||||
|
||||
var threshold = DateTimeOffset.UtcNow.AddDays(-command.OlderThanDays);
|
||||
|
||||
var logs = await dbContext
|
||||
.AuditLogs.Where(l => l.CreatedAt < threshold)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (logs.Count == 0)
|
||||
return Result.Success(0);
|
||||
|
||||
dbContext.AuditLogs.RemoveRange(logs);
|
||||
|
||||
// Новая запись создаётся уже после выборки старых — её CreatedAt = UtcNow не попадает
|
||||
// под тот же порог и не удаляется этой же операцией.
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"AuditLogsCleanedUp",
|
||||
"AuditLog",
|
||||
"bulk",
|
||||
metadata: $"{{\"count\":{logs.Count},\"olderThanDays\":{command.OlderThanDays}}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(logs.Count);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
public sealed class DeleteOldAuditLogsCommandValidator
|
||||
: AbstractValidator<DeleteOldAuditLogsCommand>
|
||||
{
|
||||
public DeleteOldAuditLogsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.OlderThanDays).GreaterThanOrEqualTo(1).LessThanOrEqualTo(3650);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using PnvPanel.Application.Admin.Maintenance;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Apps;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Maintenance;
|
||||
|
||||
public class DeleteDisabledAppsCommandHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_DeletesDisabledApps_KeepsEnabled()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var disabledApp = ClientApp.Create(
|
||||
"Old Client",
|
||||
new Uri("https://example.com/old"),
|
||||
OsPlatform.Android,
|
||||
null,
|
||||
null,
|
||||
0
|
||||
);
|
||||
disabledApp.Update(
|
||||
disabledApp.Name,
|
||||
disabledApp.DownloadUrl,
|
||||
disabledApp.OperatingSystem,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
isEnabled: false
|
||||
);
|
||||
|
||||
var enabledApp = ClientApp.Create(
|
||||
"Active Client",
|
||||
new Uri("https://example.com/active"),
|
||||
OsPlatform.IOS,
|
||||
null,
|
||||
null,
|
||||
0
|
||||
);
|
||||
|
||||
dbContext.ClientApps.AddRange(disabledApp, enabledApp);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
|
||||
var handler = new DeleteDisabledAppsCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(new DeleteDisabledAppsCommand(), CancellationToken.None);
|
||||
// Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно.
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value);
|
||||
Assert.False(dbContext.ClientApps.Any(a => a.Id == disabledApp.Id));
|
||||
Assert.True(dbContext.ClientApps.Any(a => a.Id == enabledApp.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNoDisabledApps_ReturnsZero()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var enabledApp = ClientApp.Create(
|
||||
"Active Client",
|
||||
new Uri("https://example.com/active"),
|
||||
OsPlatform.IOS,
|
||||
null,
|
||||
null,
|
||||
0
|
||||
);
|
||||
dbContext.ClientApps.Add(enabledApp);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
|
||||
var handler = new DeleteDisabledAppsCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(new DeleteDisabledAppsCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(0, result.Value);
|
||||
Assert.True(dbContext.ClientApps.Any(a => a.Id == enabledApp.Id));
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using PnvPanel.Application.Admin.Maintenance;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Maintenance;
|
||||
|
||||
public class DeleteOldAuditLogsCommandHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_DeletesOldLogs_KeepsItsOwnCleanupEntry()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var oldLog = AuditLog.Create(
|
||||
Guid.NewGuid(),
|
||||
"OldAction",
|
||||
"Test",
|
||||
"1",
|
||||
null,
|
||||
AuditSource.Web
|
||||
);
|
||||
dbContext.AuditLogs.Add(oldLog);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Гарантируем, что порог (UtcNow внутри хендлера) окажется позже CreatedAt старой записи.
|
||||
await Task.Delay(5, CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
|
||||
var handler = new DeleteOldAuditLogsCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new DeleteOldAuditLogsCommand(OlderThanDays: 0),
|
||||
CancellationToken.None
|
||||
);
|
||||
// Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно.
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value);
|
||||
Assert.False(dbContext.AuditLogs.Any(l => l.Id == oldLog.Id));
|
||||
// Собственная запись об очистке создаётся уже после выборки порога и не попадает под удаление.
|
||||
Assert.True(dbContext.AuditLogs.Any(l => l.Action == "AuditLogsCleanedUp"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNothingOlderThanThreshold_ReturnsZero()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var recentLog = AuditLog.Create(
|
||||
Guid.NewGuid(),
|
||||
"RecentAction",
|
||||
"Test",
|
||||
"1",
|
||||
null,
|
||||
AuditSource.Web
|
||||
);
|
||||
dbContext.AuditLogs.Add(recentLog);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
|
||||
var handler = new DeleteOldAuditLogsCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new DeleteOldAuditLogsCommand(OlderThanDays: 36500),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(0, result.Value);
|
||||
Assert.True(dbContext.AuditLogs.Any(l => l.Id == recentLog.Id));
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -178,13 +178,19 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
|
||||
Группа `/api/admin/maintenance`, `RequireAuthorization(RoleNames.Admin)`. Вкладка «Обслуживание» —
|
||||
разовые операции подчистки, задумана расширяемой (следующие кандидаты: очистка старых новостей и т.п.).
|
||||
|
||||
| Метод | Путь | Тело ответа |
|
||||
| ------ | -------------------------------------- | ------------- |
|
||||
| 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/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
|
||||
|
||||
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
||||
удаляет вручную в порядке вложения → комментарии → тикеты.
|
||||
|
||||
Очистка аудита пишет собственную запись `AuditLogsCleanedUp` уже **после** выборки старых записей —
|
||||
её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет
|
||||
осознанно — `AuditLog` в проекте append-only, доступна только очистка по возрасту.
|
||||
|
||||
## Admin — Activation, Roles
|
||||
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
|
||||
@@ -154,6 +154,8 @@ AppUser
|
||||
|
||||
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
|
||||
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
|
||||
Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание»,
|
||||
`DELETE /api/admin/maintenance/apps/disabled`.
|
||||
|
||||
### NewsPost — новости для пользователей
|
||||
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
|
||||
@@ -184,7 +186,10 @@ AppUser
|
||||
| `Source` | `AuditSource` | `Web` / `Telegram` / `System` |
|
||||
| `CreatedAt` | `DateTimeOffset` | |
|
||||
|
||||
Пишется из хендлеров (или обработчиков доменных событий), append-only.
|
||||
Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не
|
||||
удаляет и не редактирует записи. Единственное исключение — retention-очистка по возрасту (вкладка
|
||||
«Обслуживание», `DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`), доступная только
|
||||
админу; полного удаления журнала осознанно нет.
|
||||
|
||||
### AppUser — расширения (Identity)
|
||||
`AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный).
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { DeleteClosedTicketsResponseDto } from '@/shared/api/types'
|
||||
import type { MaintenanceCleanupResponseDto } from '@/shared/api/types'
|
||||
|
||||
export function deleteClosedTickets() {
|
||||
return apiRequest<DeleteClosedTicketsResponseDto>('/admin/maintenance/tickets/closed', { method: 'DELETE' })
|
||||
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/tickets/closed', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function deleteOldAuditLogs(olderThanDays: number) {
|
||||
return apiRequest<MaintenanceCleanupResponseDto>(`/admin/maintenance/audit-logs?olderThanDays=${olderThanDays}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDisabledApps() {
|
||||
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { deleteClosedTickets } from '@/features/admin/maintenance/api'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api'
|
||||
|
||||
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
|
||||
|
||||
const DEFAULT_AUDIT_RETENTION_DAYS = 90
|
||||
|
||||
function AdminMaintenancePage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [auditRetentionDays, setAuditRetentionDays] = useState(DEFAULT_AUDIT_RETENTION_DAYS)
|
||||
|
||||
const deleteClosedTicketsMutation = useMutation({
|
||||
mutationFn: deleteClosedTickets,
|
||||
@@ -21,6 +26,24 @@ function AdminMaintenancePage() {
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const deleteOldAuditLogsMutation = useMutation({
|
||||
mutationFn: () => deleteOldAuditLogs(auditRetentionDays),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('admin.maintenance.audit.deleted', { count: data.deletedCount }))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-audit'] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const deleteDisabledAppsMutation = useMutation({
|
||||
mutationFn: deleteDisabledApps,
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('admin.maintenance.apps.deleted', { count: data.deletedCount }))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
@@ -42,6 +65,59 @@ function AdminMaintenancePage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('admin.maintenance.audit.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t('admin.maintenance.audit.description')}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{t('admin.maintenance.audit.olderThan')}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={auditRetentionDays}
|
||||
onChange={(e) => setAuditRetentionDays(Number(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
{t('admin.maintenance.audit.days')}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={deleteOldAuditLogsMutation.isPending || auditRetentionDays < 1}
|
||||
onClick={() => {
|
||||
if (confirm(t('admin.maintenance.audit.confirm', { days: auditRetentionDays })))
|
||||
deleteOldAuditLogsMutation.mutate()
|
||||
}}
|
||||
>
|
||||
{t('admin.maintenance.audit.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('admin.maintenance.apps.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t('admin.maintenance.apps.description')}</p>
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={deleteDisabledAppsMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('admin.maintenance.apps.confirm'))) deleteDisabledAppsMutation.mutate()
|
||||
}}
|
||||
>
|
||||
{t('admin.maintenance.apps.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export type TicketSummaryDto = {
|
||||
messagePreview: string | null
|
||||
}
|
||||
|
||||
export type DeleteClosedTicketsResponseDto = {
|
||||
export type MaintenanceCleanupResponseDto = {
|
||||
deletedCount: number
|
||||
}
|
||||
|
||||
|
||||
@@ -354,6 +354,22 @@ const resources = {
|
||||
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
|
||||
deleted: 'Удалено обращений: {{count}}.',
|
||||
},
|
||||
audit: {
|
||||
title: 'Журнал аудита',
|
||||
description: 'Удалить записи журнала аудита старше указанного числа дней. Действие необратимо.',
|
||||
olderThan: 'Старше',
|
||||
days: 'дней',
|
||||
action: 'Удалить старые записи',
|
||||
confirm: 'Удалить записи аудита старше {{days}} дней? Действие необратимо.',
|
||||
deleted: 'Удалено записей аудита: {{count}}.',
|
||||
},
|
||||
apps: {
|
||||
title: 'Отключённые приложения',
|
||||
description: 'Удалить все отключённые приложения из каталога. Действие необратимо.',
|
||||
action: 'Удалить отключённые приложения',
|
||||
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
||||
deleted: 'Удалено приложений: {{count}}.',
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
totalUsers: 'Всего пользователей',
|
||||
@@ -720,6 +736,22 @@ const resources = {
|
||||
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
|
||||
deleted: 'Deleted tickets: {{count}}.',
|
||||
},
|
||||
audit: {
|
||||
title: 'Audit log',
|
||||
description: 'Delete audit log entries older than the given number of days. This cannot be undone.',
|
||||
olderThan: 'Older than',
|
||||
days: 'days',
|
||||
action: 'Delete old entries',
|
||||
confirm: 'Delete audit entries older than {{days}} days? This cannot be undone.',
|
||||
deleted: 'Deleted audit entries: {{count}}.',
|
||||
},
|
||||
apps: {
|
||||
title: 'Disabled apps',
|
||||
description: 'Delete all disabled apps from the catalog. This cannot be undone.',
|
||||
action: 'Delete disabled apps',
|
||||
confirm: 'Delete all disabled apps? This cannot be undone.',
|
||||
deleted: 'Deleted apps: {{count}}.',
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
totalUsers: 'Total users',
|
||||
|
||||
Reference in New Issue
Block a user