Очистка удаленных

This commit is contained in:
Халимов Рустам
2026-03-17 15:55:41 +03:00
parent 67bf8319aa
commit 408cb446c3
13 changed files with 494 additions and 43 deletions

View File

@@ -157,9 +157,150 @@ public class AdminController : ControllerBase
}
});
}
[HttpGet("clean/dry-run")]
public async Task<IActionResult> CleanDryRun(
[FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
[FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb,
CancellationToken ct)
{
var orphanedMessages = await _chatsDbContext.Messages
.Include(m => m.Media)
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
var orphanedMessagesCount = orphanedMessages.Count;
// Fetch all current physical files from MinIO
var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList();
// Collect ALL active URLs that we must preserve
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct);
var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach(var u in activeMessageUrls) validUrls.Add(u!);
foreach(var u in activeChatUrls) validUrls.Add(u);
foreach(var u in activeUserUrls) validUrls.Add(u);
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
long safeBytes = 0;
foreach(var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
safeBytes += file.Size;
}
}
return Ok(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
}
[HttpPost("clean/run")]
public async Task<IActionResult> CleanRun(
[FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
[FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb,
CancellationToken ct)
{
var orphanMessages = await _chatsDbContext.Messages
.Include(m => m.Media)
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
// Fetch all current physical files from MinIO
var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList();
// Collect ALL active URLs that we must preserve
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct);
var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach(var u in activeMessageUrls) validUrls.Add(u!);
foreach(var u in activeChatUrls) validUrls.Add(u);
foreach(var u in activeUserUrls) validUrls.Add(u);
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
// Physically delete from Storage
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
await fileStorage.DeleteFileAsync(file.FileId);
}
}
if(orphanMessages.Any()) {
_chatsDbContext.Messages.RemoveRange(orphanMessages);
await _chatsDbContext.SaveChangesAsync(ct);
}
return Ok(new { message = "Cleanup completed successfully" });
}
}
public class ResetPasswordDto
{
public string NewPassword { get; set; } = string.Empty;
}
public class CleanupDryRunResultDto
{
public int OrphanedMessagesCount { get; set; }
public long OrphanedMediaBytes { get; set; }
}

View File

@@ -29,18 +29,38 @@ public class KlipyController : ControllerBase
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
return BadRequest(new { error = "Klipy is disabled or not configured." });
if (_cache.TryGetValue("klipy_trending", out JsonElement cachedResult))
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
{
return Ok(cachedResult);
}
try
{
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? "anonymous" : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={conf.KlipyCustomerId ?? "anonymous"}";
var result = await client.GetFromJsonAsync<JsonElement>(url);
var url = $"https://api.klipy.co/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={customerId}";
_cache.Set("klipy_trending", result, TimeSpan.FromMinutes(60)); // Cache trending for 60 minutes
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
// fallback to api.klipy.com if .co failed with 404 or something, though maybe not needed
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) {
url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={customerId}";
response = await client.GetAsync(url);
errorContent = await response.Content.ReadAsStringAsync();
}
if (!response.IsSuccessStatusCode)
return StatusCode(500, new { error = "Failed to fetch from Klipy", details = errorContent, url });
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
_cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(60)); // Cache trending for 60 minutes
return Ok(result);
}
catch (Exception ex)
@@ -59,7 +79,7 @@ public class KlipyController : ControllerBase
if (string.IsNullOrWhiteSpace(q))
return BadRequest(new { error = "Query is empty." });
var cacheKey = $"klipy_search_{q.ToLowerInvariant()}";
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{q.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
{
return Ok(cachedResult);
@@ -67,9 +87,26 @@ public class KlipyController : ControllerBase
try
{
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? "anonymous" : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={conf.KlipyCustomerId ?? "anonymous"}";
var result = await client.GetFromJsonAsync<JsonElement>(url);
var url = $"https://api.klipy.co/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={customerId}";
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) {
url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={customerId}";
response = await client.GetAsync(url);
errorContent = await response.Content.ReadAsStringAsync();
}
if (!response.IsSuccessStatusCode)
return StatusCode(500, new { error = "Failed to fetch from Klipy", details = errorContent, url });
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(15)); // Cache searches for 15 minutes
return Ok(result);

View File

@@ -25,7 +25,7 @@ var builder = WebApplication.CreateBuilder(args);
// Маппинг стандартных переменных окружения в иерархию .NET
var envMappings = new Dictionary<string, string?>
{
["ConnectionStrings:Database"] = builder.Configuration["DATABASE_URL"],
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],

View File

@@ -7,12 +7,13 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Database": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
"DefaultConnection": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
},
"Jwt": {
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440
}
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}

View File

@@ -19,7 +19,7 @@ public static class DependencyInjection
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("Database")!;
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString));

View File

@@ -63,8 +63,15 @@ public sealed class Message : AggregateRoot<Guid>
{
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false);
}
public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null, Guid? forwardedFromId = null)
public static Message Import(
Guid chatId,
Guid senderId,
string? content,
string type,
DateTime createdAt,
Guid? replyToId = null,
Guid? forwardedFromId = null)
{
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, forwardedFromId, null, null, null, createdAt, true);
}
@@ -105,7 +112,7 @@ public sealed class Message : AggregateRoot<Guid>
{
Content = null;
IsDeleted = true;
_media.Clear();
// _media.Clear(); // DO NOT CLEAR! Data cleanup needs to know the URLs to delete from S3
_reactions.Clear();
}
@@ -133,9 +140,9 @@ public sealed class Reaction : Entity<Guid>
Emoji = emoji;
}
private Reaction() : base(Guid.Empty)
{
Emoji = string.Empty;
private Reaction() : base(Guid.Empty)
{
Emoji = string.Empty;
}
}
@@ -159,8 +166,8 @@ public sealed class Media : Entity<Guid>
Size = size;
}
private Media() : base(Guid.Empty)
{
private Media() : base(Guid.Empty)
{
Type = string.Empty;
Url = string.Empty;
}

View File

@@ -19,7 +19,7 @@ public static class DependencyInjection
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("Database")!;
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(connectionString));

View File

@@ -149,4 +149,38 @@ public class S3FileStorageService : IFileStorageService
return (msResult, contentType, fileName);
}
public async Task DeleteFileAsync(string fileId)
{
try
{
var removeArgs = new RemoveObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId);
await _minioClient.RemoveObjectAsync(removeArgs).ConfigureAwait(false);
}
catch (MinioException e)
{
Console.WriteLine($"[Bucket] Error deleting file {fileId}: {e.Message}");
}
}
public async Task<IEnumerable<(string FileId, long Size)>> ListFilesAsync()
{
var result = new List<(string FileId, long Size)>();
try
{
var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true);
await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false))
{
result.Add((item.Key, (long)item.Size));
}
}
catch (Exception ex)
{
Console.WriteLine($"[Bucket] Error listing files: {ex.Message}");
}
return result;
}
}

View File

@@ -10,4 +10,10 @@ public interface IFileStorageService
// Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
// Удаляет файл из хранилища.
Task DeleteFileAsync(string fileId);
// Получает список всех файлов в хранилище с их размерами.
Task<System.Collections.Generic.IEnumerable<(string FileId, long Size)>> ListFilesAsync();
}

View File

@@ -362,10 +362,11 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0);
useEffect(() => {
if (loadedTabs.size === tabsConfig.length && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
const expected = chatId ? 5 : 1;
if (loadedTabs.size === expected && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
setActiveTab(availableTabs[0].key as any);
}
}, [loadedTabs, activeTab, tabsConfig.length]);
}, [loadedTabs, activeTab, chatId, availableTabs]);
return (
<>
@@ -903,7 +904,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
)}
</div>
</div>
) : loadedTabs.size === tabsConfig.length ? (
) : loadedTabs.size === (chatId ? 5 : 1) ? (
<div className="m-4 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
<ImageIcon size={32} className="text-zinc-600 mb-3" />
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>

View File

@@ -1,9 +1,22 @@
// Notification sound using Web Audio API — generates a pleasant chime
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext {
function getAudioContext(): AudioContext | null {
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
if (!(navigator as any).userActivation.hasBeenActive) {
return null;
}
}
if (!audioContext) {
audioContext = new AudioContext();
try {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
if (AudioCtx) {
audioContext = new AudioCtx();
}
} catch {
return null;
}
}
return audioContext;
}
@@ -11,6 +24,7 @@ function getAudioContext(): AudioContext {
export function playNotificationSound() {
try {
const ctx = getAudioContext();
if (!ctx) return;
if (ctx.state === 'suspended') {
ctx.resume();
}

View File

@@ -100,13 +100,24 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
const cached = waveformCache.get(url);
if (cached) return cached;
let audioCtx: AudioContext | undefined;
let audioCtx: any;
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
await audioCtx.close();
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
if (OfflineCtx) {
audioCtx = new OfflineCtx(1, 1, 44100);
} else {
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
// We only need the buffer, so decode it
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
// If it was a regular AudioContext, close it.
if (audioCtx.close) {
await audioCtx.close();
}
audioCtx = undefined;
const channelData = audioBuffer.getChannelData(0);
@@ -132,7 +143,7 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
return normalized;
} catch {
// Close leaked AudioContext if any
if (audioCtx) audioCtx.close().catch(() => {});
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
// On error, return uniform bars
return Array(bars).fill(0.5);
}

View File

@@ -108,8 +108,28 @@ const translations = {
copied: 'Copied!',
createUserSuccess: 'User created successfully',
passwordResetSuccess: 'Password reset successfully',
resetPasswordDesc: 'Generate a new strong password for this user and invalidate the old one.',
usernamePlaceholder: 'Ex: johndoe',
displayNamePlaceholder: 'Ex: John Doe',
passwordPlaceholder: 'Strong password...',
generate: 'Generate',
cancel: 'Cancel',
displayName: 'Display Name',
createUserError: 'Error creating user',
createUser: 'Create User',
dataCleanup: 'Data Cleanup',
dataCleanupDesc: 'Calculate and remove unreachable messages (e.g. from deleted groups) and media to free up disk space.',
calcCleanupBtn: 'Calculate GC',
runCleanupBtn: 'Run Clean',
cleanMessages: 'Orphaned Messages',
cleanMedia: 'Orphaned Media Size',
cleanSuccess: 'Cleanup completed successfully!',
testConnection: 'Test Connection',
turnSuccess: 'TURN Connection Successful!',
turnFailed: 'TURN Test Failed: ',
klipySuccess: 'Klipy connection successful!',
klipyFailed: 'Klipy Test Failed: ',
apiRequired: 'API Key is required',
},
ru: {
loginTitle: 'Вход администратора',
@@ -173,8 +193,28 @@ const translations = {
copied: 'Скопировано!',
createUserSuccess: 'Пользователь успешно создан',
passwordResetSuccess: 'Пароль успешно сброшен',
resetPasswordDesc: 'Сгенерировать новый надежный пароль для этого пользователя и аннулировать старый.',
usernamePlaceholder: 'Например: johndoe',
displayNamePlaceholder: 'Например: Иван Иванов',
passwordPlaceholder: 'Надежный пароль...',
generate: 'Сгенерировать',
cancel: 'Отмена',
displayName: 'Имя профиля',
createUserError: 'Ошибка при создании',
createUser: 'Создать',
dataCleanup: 'Очистка данных',
dataCleanupDesc: 'Вычисление и удаление недостижимых сообщений (например, из удаленных групп) и файлов для освобождения места.',
calcCleanupBtn: 'Оценить объем',
runCleanupBtn: 'Очистить данные',
cleanMessages: 'Сообщений к удалению',
cleanMedia: 'Медиа к удалению',
cleanSuccess: 'Очистка успешно завершена!',
testConnection: 'Проверить соединение',
turnSuccess: 'Успешное подключение к TURN!',
turnFailed: 'Ошибка TURN: ',
klipySuccess: 'Успешное подключение к Klipy!',
klipyFailed: 'Ошибка Klipy: ',
apiRequired: 'API ключ обязателен',
}
};
@@ -199,18 +239,26 @@ export default function AdminPage() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
interface CleanStats {
orphanedMessagesCount: number;
orphanedMediaBytes: number;
}
const [stats, setStats] = useState<Stats | null>(null);
const [config, setConfig] = useState<Conf | null>(null);
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
const [authHeader, setAuthHeader] = useState('');
const [creds, setCreds] = useState({ user: '', pass: '' });
const [authenticated, setAuthenticated] = useState(false);
// Users Tab
const [users, setUsers] = useState<AppUser[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<AppUser | null>(null);
const [isTestingTurn, setIsTestingTurn] = useState<boolean>(false);
const [isTestingKlipy, setIsTestingKlipy] = useState<boolean>(false);
const [domainInput, setDomainInput] = useState('');
// Add User State
@@ -278,6 +326,95 @@ export default function AdminPage() {
} catch {}
};
const handleTestTurn = async () => {
if (!config || !config.turnHost) return;
setIsTestingTurn(true);
let resolved = false;
try {
const pc = new RTCPeerConnection({
iceServers: [{
urls: `turn:${config.turnHost}:${config.turnPort}`,
username: config.turnUser,
credential: config.turnSecret
}]
});
pc.addTransceiver('audio');
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error("Timeout gathering relay candidates"));
}
}, 10000);
pc.onicecandidate = (e) => {
if (e.candidate && (e.candidate.type === 'relay' || e.candidate.type === 'srflx')) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve();
}
}
if (e.candidate === null) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error("Failed to gather candidates. Check credentials or network."));
}
}
};
});
showToast(t.turnSuccess, 'success');
pc.close();
} catch (err: any) {
showToast(t.turnFailed + err.message, 'error');
} finally {
setIsTestingTurn(false);
}
};
const handleTestKlipy = async () => {
if (!config || !config.klipyApiKey) {
showToast(t.apiRequired, 'error');
return;
}
setIsTestingKlipy(true);
try {
const customerId = config.klipyCustomerId || "anonymous";
let url = `https://api.klipy.co/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
let res = await fetch(url);
if (!res.ok && res.status === 404) {
url = `https://api.klipy.com/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
res = await fetch(url);
}
if (res.ok) {
const data = await res.json().catch(() => null);
if (data && !data.error && (Array.isArray(data) || data.gifs || data.data)) {
showToast(t.klipySuccess, 'success');
} else {
showToast(t.klipyFailed + (data?.error || "Invalid response format"), 'error');
}
} else {
const err = await res.json().catch(() => ({}));
showToast(t.klipyFailed + (err?.error || err?.message || res.statusText), 'error');
}
} catch (err: any) {
showToast(t.klipyFailed + err.message, 'error');
} finally {
setIsTestingKlipy(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
@@ -330,6 +467,24 @@ export default function AdminPage() {
}
};
const handleCalcCleanup = async () => {
try {
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } });
if (res.ok) setCleanStats(await res.json());
} catch {}
};
const handleRunCleanup = async () => {
try {
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } });
if (res.ok) {
showToast(t.cleanSuccess, 'success');
setCleanStats(null);
fetchDashboard(authHeader);
}
} catch {}
};
const fetchUserDetails = async (id: string) => {
try {
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
@@ -416,7 +571,7 @@ export default function AdminPage() {
const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0;
return (
<div className="min-h-screen bg-black text-white font-sans flex overflow-hidden">
<div className="h-[100dvh] w-full bg-black text-white font-sans flex overflow-hidden">
<aside className="w-64 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0 overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-accent flex items-center gap-2">
@@ -482,6 +637,38 @@ export default function AdminPage() {
</div>
</div>
</div>
{/* DATA CLEANUP WIDGET */}
<div className="bg-surface border border-white/10 rounded-2xl p-6 lg:col-span-2 relative overflow-hidden">
<h3 className="text-gray-400 font-medium mb-2 flex items-center gap-2 relative z-10">
<Trash2 className="w-5 h-5"/> {t.dataCleanup}
</h3>
<div className="text-sm text-gray-500 mb-6 relative z-10 max-w-lg">{t.dataCleanupDesc}</div>
{!cleanStats ? (
<button onClick={handleCalcCleanup} className="bg-accent/10 hover:bg-accent/20 text-accent font-semibold px-4 py-2 rounded-xl transition-colors shrink-0">
{t.calcCleanupBtn}
</button>
) : (
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
<span className="text-gray-400">{t.cleanMessages}:</span>
<span className="text-white font-mono">{cleanStats.orphanedMessagesCount}</span>
</div>
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
<span className="text-gray-400">{t.cleanMedia}:</span>
<span className="text-white font-mono">{formatBytes(cleanStats.orphanedMediaBytes)}</span>
</div>
<div className="flex justify-start gap-4 mt-2">
<button onClick={handleRunCleanup} className="bg-red-500 hover:bg-red-600 text-white font-semibold px-6 py-2 rounded-xl transition-colors">
{t.runCleanupBtn}
</button>
<button onClick={() => setCleanStats(null)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
@@ -544,6 +731,12 @@ export default function AdminPage() {
{t.turnSecret}
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
</label>
<div className="flex justify-start mt-2">
<button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
{isTestingTurn ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
{t.testConnection}
</button>
</div>
</motion.div>
)}
</div>
@@ -566,11 +759,17 @@ export default function AdminPage() {
Customer ID (for tracking/analytics)
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyCustomerId || ''} onChange={e => setConfig({...config, klipyCustomerId: e.target.value})} />
</label>
<div className="flex justify-start mt-2">
<button onClick={handleTestKlipy} disabled={isTestingKlipy} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
{isTestingKlipy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
{t.testConnection}
</button>
</div>
</motion.div>
)}
</div>
<div className="sticky bottom-8 self-end">
<div className="mt-8 flex justify-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
@@ -635,7 +834,7 @@ export default function AdminPage() {
)}
</div>
<div className="sticky bottom-8 self-end">
<div className="mt-8 flex justify-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
@@ -761,7 +960,7 @@ export default function AdminPage() {
<div className="bg-red-400/5 border border-red-400/20 p-5 rounded-xl flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex-1">
<h4 className="font-medium text-white">{t.resetPassword}</h4>
<p className="text-sm text-gray-400 mt-1">Generate a new strong password for this user and invalidate the old one.</p>
<p className="text-sm text-gray-400 mt-1">{t.resetPasswordDesc}</p>
</div>
<button onClick={() => handleResetPassword(selectedUser.id)} className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-6 py-2 rounded-xl transition-colors font-semibold whitespace-nowrap">
{t.resetPassword}
@@ -797,45 +996,45 @@ export default function AdminPage() {
<h2 className="text-2xl font-bold text-white mb-2">{t.addUser}</h2>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Username
{t.username}
<input
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={newUser.username}
onChange={e => setNewUser({...newUser, username: e.target.value.toLowerCase()})}
placeholder="Ex: john_doe"
placeholder={t.usernamePlaceholder}
/>
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Display Name
{t.displayName}
<input
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={newUser.displayName}
onChange={e => setNewUser({...newUser, displayName: e.target.value})}
placeholder="Ex: John Doe"
placeholder={t.displayNamePlaceholder}
/>
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Password
{t.password}
<div className="flex gap-2">
<input
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent flex-1 font-mono"
value={newUser.password}
onChange={e => setNewUser({...newUser, password: e.target.value})}
placeholder="Strong password..."
placeholder={t.passwordPlaceholder}
/>
<button
onClick={() => setNewUser({...newUser, password: generatePassword()})}
className="bg-white/10 hover:bg-white/20 px-4 rounded-xl transition-colors shrink-0"
>
Generate
{t.generate}
</button>
</div>
</label>
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-white/10">
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">Cancel</button>
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.displayName || !newUser.password} className="bg-accent text-black font-semibold px-6 py-2 rounded-xl hover:bg-accentLight transition-colors disabled:opacity-50">
{t.createUser}
</button>