diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs index 908dec9..5638c90 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs @@ -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(); + .Produces(); + admin + .MapDelete("/audit-logs", DeleteOldAuditLogs) + .Produces(); + admin + .MapDelete("/apps/disabled", DeleteDisabledApps) + .Produces(); 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 DeleteOldAuditLogs( + int olderThanDays, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new DeleteOldAuditLogsCommand(olderThanDays), + cancellationToken + ); + return ToResponse(result); + } + + private static async Task DeleteDisabledApps( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteDisabledAppsCommand(), cancellationToken); + return ToResponse(result); + } + + private static IResult ToResponse(Result result) => + result.IsSuccess + ? Results.Ok(new MaintenanceCleanupResponseDto(result.Value)) + : result.ToHttpResult(); } -public sealed record DeleteClosedTicketsResponseDto(int DeletedCount); +public sealed record MaintenanceCleanupResponseDto(int DeletedCount); diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs index dc8e25d..e81b3e6 100644 --- a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs @@ -59,7 +59,7 @@ public sealed class DeleteClosedTicketsCommandHandler( "ClosedTicketsCleanedUp", "SupportTicket", "bulk", - metadata: $"count={ticketIds.Count}", + metadata: $"{{\"count\":{ticketIds.Count}}}", AuditSource.Web ) ); diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteDisabledAppsCommand.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteDisabledAppsCommand.cs new file mode 100644 index 0000000..6c80af6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteDisabledAppsCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// Удаляет все ClientApp с IsEnabled = false. Value — число удалённых записей. +public sealed record DeleteDisabledAppsCommand : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteDisabledAppsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteDisabledAppsCommandHandler.cs new file mode 100644 index 0000000..de89476 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteDisabledAppsCommandHandler.cs @@ -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> +{ + public async Task> Handle( + DeleteDisabledAppsCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(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); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommand.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommand.cs new file mode 100644 index 0000000..32ccc42 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// Удаляет записи AuditLog старше OlderThanDays дней. Value — число удалённых записей. +public sealed record DeleteOldAuditLogsCommand(int OlderThanDays) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommandHandler.cs new file mode 100644 index 0000000..32b02da --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommandHandler.cs @@ -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> +{ + public async Task> Handle( + DeleteOldAuditLogsCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(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); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommandValidator.cs new file mode 100644 index 0000000..4f1d740 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOldAuditLogsCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Maintenance; + +public sealed class DeleteOldAuditLogsCommandValidator + : AbstractValidator +{ + public DeleteOldAuditLogsCommandValidator() + { + RuleFor(x => x.OlderThanDays).GreaterThanOrEqualTo(1).LessThanOrEqualTo(3650); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs new file mode 100644 index 0000000..32f84d3 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs @@ -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)); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteOldAuditLogsCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteOldAuditLogsCommandHandlerTests.cs new file mode 100644 index 0000000..0e6ec5b --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteOldAuditLogsCommandHandlerTests.cs @@ -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)); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 03ecf9a..a614913 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -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 | Метод | Путь | Роль | Тело запроса | Тело ответа | diff --git a/docs/domain-model.md b/docs/domain-model.md index 02b98cf..e6dd35f 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -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`** (уникальный, обязательный). diff --git a/frontend/src/features/admin/maintenance/api.ts b/frontend/src/features/admin/maintenance/api.ts index 585f0fc..3f05fcc 100644 --- a/frontend/src/features/admin/maintenance/api.ts +++ b/frontend/src/features/admin/maintenance/api.ts @@ -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('/admin/maintenance/tickets/closed', { method: 'DELETE' }) + return apiRequest('/admin/maintenance/tickets/closed', { method: 'DELETE' }) +} + +export function deleteOldAuditLogs(olderThanDays: number) { + return apiRequest(`/admin/maintenance/audit-logs?olderThanDays=${olderThanDays}`, { + method: 'DELETE', + }) +} + +export function deleteDisabledApps() { + return apiRequest('/admin/maintenance/apps/disabled', { method: 'DELETE' }) } diff --git a/frontend/src/routes/admin/maintenance.tsx b/frontend/src/routes/admin/maintenance.tsx index 022fe7b..2824f8a 100644 --- a/frontend/src/routes/admin/maintenance.tsx +++ b/frontend/src/routes/admin/maintenance.tsx @@ -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 (
@@ -42,6 +65,59 @@ function AdminMaintenancePage() {
+ + + + {t('admin.maintenance.audit.title')} + + +

{t('admin.maintenance.audit.description')}

+
+ + +
+
+
+ + + + {t('admin.maintenance.apps.title')} + + +

{t('admin.maintenance.apps.description')}

+
+ +
+
+
) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 5d95d36..4a03234 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -270,7 +270,7 @@ export type TicketSummaryDto = { messagePreview: string | null } -export type DeleteClosedTicketsResponseDto = { +export type MaintenanceCleanupResponseDto = { deletedCount: number } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 04569d5..c4f6dc3 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -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',