/
REM11
/
FaceAccess
Обзор
Документация
Войти
/
REM11
/
FaceAccess
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
static/logs.html
878 строк
44 KB
soval
-
14 дек 2025, 17:04
14 дек 2025, 17:04
b7874d0
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="en" x-data="logsApp()" x-init="loadLogs()" class="h-full"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Access Logs | Face Access Control</title> <script src="https://cdn.tailwindcss.com"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.0/dist/cdn.min.js"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <style> .status-success { background-color: #d1fae5; color: #065f46; } .status-failure { background-color: #fee2e2; color: #991b1b; } .status-error { background-color: #fef3c7; color: #92400e; } .log-row:hover { background-color: #f9fafb; } </style> </head> <body class="h-full bg-gray-50"> <div class="flex h-screen"> <!-- Sidebar --> <aside class="w-64 bg-gray-900 text-white flex flex-col"> <div class="p-6"> <a href="/index.html" class="text-2xl font-bold flex items-center gap-2"> <i class="fas fa-lock"></i> <span>Face Access</span> </a> <p class="text-gray-400 text-sm mt-1">Administration Panel</p> </div> <nav class="flex-1 px-4"> <div class="mb-8"> <h3 class="text-xs uppercase tracking-wider text-gray-400 mb-4 px-2">Navigation</h3> <ul class="space-y-2"> <li> <a href="/index.html" class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800 rounded-lg transition"> <i class="fas fa-camera"></i> Face Scanner </a> </li> </ul> </div> <div class="mb-8"> <h3 class="text-xs uppercase tracking-wider text-gray-400 mb-4 px-2">User Management</h3> <ul class="space-y-2"> <li> <a href="/register.html" class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800 rounded-lg transition"> <i class="fas fa-user-plus"></i> Register User </a> </li> <li> <a href="/users.html" class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800 rounded-lg transition"> <i class="fas fa-users-cog"></i> Manage Users </a> </li> </ul> </div> <div> <h3 class="text-xs uppercase tracking-wider text-gray-400 mb-4 px-2">System</h3> <ul class="space-y-2"> <li> <a href="/logs.html" class="flex items-center gap-3 px-4 py-3 bg-gray-800 rounded-lg"> <i class="fas fa-history"></i> Access Logs </a> </li> </ul> </div> </nav> <div class="p-4 border-t border-gray-800"> <div class="flex items-center gap-3"> <div class="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center"> <i class="fas fa-user-shield"></i> </div> <div> <p class="font-medium">Administrator</p> <p class="text-xs text-gray-400">System Admin</p> </div> </div> </div> </aside> <!-- Main Content --> <main class="flex-1 overflow-auto"> <!-- Header --> <header class="bg-white border-b px-8 py-6"> <div class="flex justify-between items-center"> <div> <h2 class="text-2xl font-bold text-gray-800">Access Logs</h2> <p class="text-gray-600">Monitor and analyze access attempts</p> </div> <div class="flex items-center gap-4"> <button @click="exportLogs()" class="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition" > <i class="fas fa-file-export"></i> Export Logs </button> <button @click="clearOldLogs()" class="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition" > <i class="fas fa-trash"></i> Clear Old Logs </button> </div> </div> </header> <!-- Stats and Filters --> <div class="bg-white border-b px-8 py-6"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6"> <!-- Stats Cards --> <div class="bg-blue-50 rounded-xl p-4"> <div class="flex items-center justify-between"> <div> <p class="text-sm text-blue-700">Total Logs</p> <h3 class="text-2xl font-bold text-blue-900" x-text="stats.total || 0"></h3> </div> <div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center"> <i class="fas fa-database text-blue-600"></i> </div> </div> </div> <div class="bg-green-50 rounded-xl p-4"> <div class="flex items-center justify-between"> <div> <p class="text-sm text-green-700">Successful</p> <h3 class="text-2xl font-bold text-green-900" x-text="stats.success || 0"></h3> </div> <div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center"> <i class="fas fa-check-circle text-green-600"></i> </div> </div> </div> <div class="bg-red-50 rounded-xl p-4"> <div class="flex items-center justify-between"> <div> <p class="text-sm text-red-700">Failed</p> <h3 class="text-2xl font-bold text-red-900" x-text="stats.failure || 0"></h3> </div> <div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center"> <i class="fas fa-times-circle text-red-600"></i> </div> </div> </div> <div class="bg-purple-50 rounded-xl p-4"> <div class="flex items-center justify-between"> <div> <p class="text-sm text-purple-700">Success Rate</p> <h3 class="text-2xl font-bold text-purple-900" x-text="stats.success_rate ? stats.success_rate + '%' : '0%'"></h3> </div> <div class="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center"> <i class="fas fa-chart-line text-purple-600"></i> </div> </div> </div> </div> <!-- Filters --> <div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-2">Status</label> <select x-model="filters.status" @change="filterLogs()" class="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" > <option value="">All Statuses</option> <option value="success">Success</option> <option value="failure">Failure</option> <option value="error">Error</option> </select> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-2">Date From</label> <input type="datetime-local" x-model="filters.date_from" @change="filterLogs()" class="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" > </div> <div> <label class="block text-sm font-medium text-gray-700 mb-2">Date To</label> <input type="datetime-local" x-model="filters.date_to" @change="filterLogs()" class="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" > </div> <div> <label class="block text-sm font-medium text-gray-700 mb-2">Search</label> <div class="relative"> <span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"> <i class="fas fa-search"></i> </span> <input type="text" x-model="filters.search" @input="filterLogs()" placeholder="Search logs..." class="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" > </div> </div> </div> <!-- Active Filters --> <div x-show="hasActiveFilters()" class="mt-4"> <div class="flex flex-wrap gap-2"> <span class="text-sm text-gray-600">Active filters:</span> <template x-for="(value, key) in getActiveFilters()" :key="key"> <span class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm"> <span x-text="getFilterLabel(key, value)"></span> <button @click="clearFilter(key)" class="ml-1 hover:text-blue-900"> <i class="fas fa-times text-xs"></i> </button> </span> </template> <button @click="clearAllFilters()" class="text-sm text-red-600 hover:text-red-800" > Clear all </button> </div> </div> </div> <!-- Logs Table --> <div class="p-8"> <div class="bg-white rounded-xl shadow overflow-hidden"> <!-- Table Header --> <div class="px-6 py-4 border-b flex justify-between items-center"> <h3 class="text-lg font-semibold text-gray-800">Access Logs</h3> <div class="flex items-center gap-4"> <div class="flex items-center"> <span class="text-sm text-gray-600 mr-2">Show:</span> <select x-model="itemsPerPage" @change="filterLogs()" class="border rounded px-3 py-1 text-sm" > <option value="50">50</option> <option value="100">100</option> <option value="200">200</option> <option value="500">500</option> </select> </div> <div class="text-sm text-gray-600"> Showing <span x-text="filteredLogs.length"></span> of <span x-text="totalLogs"></span> logs </div> </div> </div> <!-- Table --> <div class="overflow-x-auto"> <table class="w-full"> <thead class="bg-gray-50"> <tr> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> ID </th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> User </th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Status </th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Confidence </th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> IP Address </th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Timestamp </th> <th class="px6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Details </th> </tr> </thead> <tbody> <template x-for="log in filteredLogs" :key="log.id"> <tr class="log-row border-t"> <td class="px-6 py-4 whitespace-nowrap"> <span class="text-sm font-mono text-gray-500" x-text="'#' + log.id"></span> </td> <td class="px-6 py-4 whitespace-nowrap"> <template x-if="log.user"> <div class="flex items-center"> <div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3"> <i class="fas fa-user text-blue-600 text-sm"></i> </div> <div> <div class="font-medium text-gray-900" x-text="log.user.full_name"></div> <div class="text-sm text-gray-500" x-text="log.user.email"></div> </div> </div> </template> <template x-if="!log.user"> <div class="text-gray-400 italic">Unknown</div> </template> </td> <td class="px-6 py-4 whitespace-nowrap"> <span class="px-3 py-1 rounded-full text-xs font-medium" :class="{ 'status-success': log.status === 'success', 'status-failure': log.status === 'failure', 'status-error': log.status === 'error' }" x-text="log.status" ></span> </td> <td class="px-6 py-4 whitespace-nowrap"> <template x-if="log.confidence"> <div class="flex items-center"> <div class="w-16 bg-gray-200 rounded-full h-2 mr-2"> <div class="h-2 rounded-full" :class="{ 'bg-green-500': log.confidence >= 0.8, 'bg-yellow-500': log.confidence >= 0.6 && log.confidence < 0.8, 'bg-red-500': log.confidence < 0.6 }" :style="'width: ' + (log.confidence * 100) + '%'" ></div> </div> <span class="text-sm font-medium" x-text="Math.round(log.confidence * 100) + '%'"></span> </div> </template> <template x-if="!log.confidence"> <span class="text-gray-400">N/A</span> </template> </td> <td class="px-6 py-4 whitespace-nowrap"> <span class="text-sm font-mono text-gray-600" x-text="log.ip_address || 'N/A'"></span> </td> <td class="px-6 py-4 whitespace-nowrap"> <div> <div class="text-sm text-gray-900" x-text="formatDate(log.created_at)"></div> <div class="text-xs text-gray-500" x-text="formatTimeAgo(log.created_at)"></div> </div> </td> <td class="px-6 py-4 whitespace-nowrap"> <button @click="showLogDetails(log)" class="text-blue-600 hover:text-blue-800" > <i class="fas fa-info-circle"></i> </button> </td> </tr> </template> <!-- Empty State --> <template x-if="filteredLogs.length === 0"> <tr> <td colspan="7" class="px-6 py-12 text-center"> <div class="text-gray-400"> <i class="fas fa-history text-4xl mb-4 opacity-20"></i> <p class="text-lg mb-2">No logs found</p> <p class="text-sm">Try adjusting your filters</p> </div> </td> </tr> </template> </tbody> </table> </div> <!-- Pagination --> <div class="px-6 py-4 border-t flex items-center justify-between"> <div class="text-sm text-gray-700"> Showing <span x-text="((currentPage - 1) * itemsPerPage) + 1"></span> to <span x-text="Math.min(currentPage * itemsPerPage, filteredLogs.length)"></span> of <span x-text="filteredLogs.length"></span> logs </div> <div class="flex gap-2"> <button @click="prevPage()" :disabled="currentPage === 1" class="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed" > <i class="fas fa-chevron-left"></i> </button> <template x-for="page in getPageNumbers()" :key="page"> <button @click="goToPage(page)" class="w-8 h-8 rounded" :class="page === currentPage ? 'bg-blue-600 text-white' : 'border text-gray-700 hover:bg-gray-50'" x-text="page" ></button> </template> <button @click="nextPage()" :disabled="currentPage === totalPages" class="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed" > <i class="fas fa-chevron-right"></i> </button> </div> </div> </div> </div> </main> </div> <!-- Log Details Modal --> <div x-show="showDetailsModal" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" @click.away="showDetailsModal = false" > <div class="bg-white rounded-2xl shadow-xl w-full max-w-2xl" @click.stop> <div class="p-6"> <div class="flex justify-between items-start mb-6"> <h3 class="text-xl font-bold text-gray-800">Log Details</h3> <button @click="showDetailsModal = false" class="text-gray-400 hover:text-gray-600"> <i class="fas fa-times text-xl"></i> </button> </div> <template x-if="selectedLog"> <div class="space-y-6"> <!-- Basic Info --> <div class="grid grid-cols-2 gap-4"> <div class="bg-gray-50 rounded-lg p-4"> <p class="text-sm text-gray-500">Log ID</p> <p class="font-mono font-semibold" x-text="'#' + selectedLog.id"></p> </div> <div class="bg-gray-50 rounded-lg p-4"> <p class="text-sm text-gray-500">Status</p> <span class="px-3 py-1 rounded-full text-sm font-medium" :class="{ 'status-success': selectedLog.status === 'success', 'status-failure': selectedLog.status === 'failure', 'status-error': selectedLog.status === 'error' }" x-text="selectedLog.status" ></span> </div> <div class="bg-gray-50 rounded-lg p-4"> <p class="text-sm text-gray-500">Confidence</p> <p class="font-semibold" x-text="selectedLog.confidence ? Math.round(selectedLog.confidence * 100) + '%' : 'N/A'"></p> </div> <div class="bg-gray-50 rounded-lg p-4"> <p class="text-sm text-gray-500">Timestamp</p> <p class="font-semibold" x-text="formatDate(selectedLog.created_at)"></p> </div> </div> <!-- User Info --> <div class="border rounded-lg p-4"> <h4 class="font-medium text-gray-800 mb-3">User Information</h4> <template x-if="selectedLog.user"> <div class="flex items-center gap-4"> <div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center"> <i class="fas fa-user text-blue-600 text-xl"></i> </div> <div> <p class="font-semibold text-lg" x-text="selectedLog.user.full_name"></p> <p class="text-gray-600" x-text="selectedLog.user.email"></p> <p class="text-sm text-gray-500">User ID: <span x-text="selectedLog.user.id" class="font-mono"></span></p> </div> </div> </template> <template x-if="!selectedLog.user"> <div class="text-gray-400 italic">No user information available</div> </template> </div> <!-- Technical Details --> <div class="border rounded-lg p-4"> <h4 class="font-medium text-gray-800 mb-3">Technical Information</h4> <div class="space-y-2"> <div class="flex justify-between"> <span class="text-gray-600">IP Address:</span> <span class="font-mono" x-text="selectedLog.ip_address || 'N/A'"></span> </div> <div class="flex justify-between"> <span class="text-gray-600">User Agent:</span> <span class="text-sm font-mono truncate max-w-xs" x-text="selectedLog.user_agent || 'N/A'"></span> </div> <div class="flex justify-between"> <span class="text-gray-600">Message:</span> <span class="text-sm" x-text="selectedLog.message || 'No message'"></span> </div> </div> </div> <!-- Additional Data --> <div class="border rounded-lg p-4"> <h4 class="font-medium text-gray-800 mb-3">Additional Data</h4> <pre class="bg-gray-900 text-gray-100 p-4 rounded text-sm overflow-auto max-h-40" x-text="JSON.stringify(selectedLog, null, 2)"></pre> </div> </div> </template> <div class="flex justify-end gap-3 mt-8"> <button @click="showDetailsModal = false" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700" > Close </button> </div> </div> </div> </div> <!-- Clear Logs Modal --> <div x-show="showClearModal" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" @click.away="showClearModal = false" > <div class="bg-white rounded-2xl shadow-xl w-full max-w-md" @click.stop> <div class="p-6"> <div class="text-center mb-6"> <div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4"> <i class="fas fa-exclamation-triangle text-red-600 text-2xl"></i> </div> <h3 class="text-xl font-bold text-gray-800 mb-2">Clear Old Logs</h3> <p class="text-gray-600">This will delete logs older than 30 days.</p> <p class="text-sm text-red-600 mt-2">This action cannot be undone.</p> </div> <div class="flex justify-center gap-3"> <button @click="showClearModal = false" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" > Cancel </button> <button @click="performClearLogs()" class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700" > Clear Logs </button> </div> </div> </div> </div> <script> function logsApp() { return { logs: [], filteredLogs: [], selectedLog: null, stats: {}, filters: { status: '', date_from: '', date_to: '', search: '' }, currentPage: 1, itemsPerPage: 50, totalPages: 0, showDetailsModal: false, showClearModal: false, statusChart: '', async loadLogs() { try { // Загрузка логов const [logsResponse, statsResponse] = await Promise.all([ fetch('/api/v1/logs/?limit=1000'), fetch('/api/v1/logs/stats') ]); if (logsResponse.ok) { this.logs = await logsResponse.json(); this.filterLogs(); } if (statsResponse.ok) { this.stats = await statsResponse.json(); this.generateCharts(); } } catch (error) { console.error('Error loading logs:', error); this.showNotification('Failed to load logs', 'error'); } }, filterLogs() { let filtered = this.logs; // Apply status filter if (this.filters.status) { filtered = filtered.filter(log => log.status === this.filters.status); } // Apply date filters if (this.filters.date_from) { const dateFrom = new Date(this.filters.date_from); filtered = filtered.filter(log => new Date(log.created_at) >= dateFrom); } if (this.filters.date_to) { const dateTo = new Date(this.filters.date_to); filtered = filtered.filter(log => new Date(log.created_at) <= dateTo); } // Apply search filter if (this.filters.search) { const query = this.filters.search.toLowerCase(); filtered = filtered.filter(log => (log.user?.full_name?.toLowerCase().includes(query)) || (log.user?.email?.toLowerCase().includes(query)) || (log.ip_address?.includes(query)) || (log.message?.toLowerCase().includes(query)) ); } // Sort by date (newest first) filtered.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); this.filteredLogs = filtered; this.totalPages = Math.ceil(this.filteredLogs.length / this.itemsPerPage); this.generateCharts(); }, generateCharts() { // Generate chart const statusCounts = { success: 0, failure: 0, error: 0 }; this.filteredLogs.forEach(log => { statusCounts[log.status] = (statusCounts[log.status] || 0) + 1; }); const total = this.filteredLogs.length; if (total > 0) { const successPercent = Math.round((statusCounts.success / total) * 100); const failurePercent = Math.round((statusCounts.failure / total) * 100); const errorPercent = Math.round((statusCounts.error / total) * 100); this.statusChart = ` <div class="relative w-48 h-48"> <svg viewBox="0 0 100 100" class="w-full h-full"> <circle cx="50" cy="50" r="40" fill="#f3f4f6" /> <path d="M50 50 L50 10 A40 40 0 ${successPercent > 50 ? 1 : 0} 1 ${50 + 40 * Math.sin(successPercent * 3.6 * Math.PI / 180)} ${50 - 40 * Math.cos(successPercent * 3.6 * Math.PI / 180)}" fill="#10b981" /> <path d="M50 50 L${50 + 40 * Math.sin(successPercent * 3.6 * Math.PI / 180)} ${50 - 40 * Math.cos(successPercent * 3.6 * Math.PI / 180)} A40 40 0 ${failurePercent > 50 ? 1 : 0} 1 ${50 + 40 * Math.sin((successPercent + failurePercent) * 3.6 * Math.PI / 180)} ${50 - 40 * Math.cos((successPercent + failurePercent) * 3.6 * Math.PI / 180)}" fill="#ef4444" /> <path d="M50 50 L${50 + 40 * Math.sin((successPercent + failurePercent) * 3.6 * Math.PI / 180)} ${50 - 40 * Math.cos((successPercent + failurePercent) * 3.6 * Math.PI / 180)} A40 40 0 ${errorPercent > 50 ? 1 : 0} 1 50 10" fill="#f59e0b" /> <text x="50" y="50" text-anchor="middle" dy="0.35em" class="text-2xl font-bold" fill="#1f2937"> ${total} </text> <text x="50" y="60" text-anchor="middle" dy="0.35em" class="text-sm" fill="#6b7280"> logs </text> </svg> </div> `; } }, showLogDetails(log) { this.selectedLog = log; this.showDetailsModal = true; }, async exportLogs() { try { const data = { exportDate: new Date().toISOString(), totalLogs: this.filteredLogs.length, filters: this.getActiveFilters(), logs: this.filteredLogs }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `access-logs-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showNotification('Logs exported successfully', 'success'); } catch (error) { console.error('Error exporting logs:', error); this.showNotification('Failed to export logs', 'error'); } }, clearOldLogs() { this.showClearModal = true; }, async performClearLogs() { try { // Note: This would call a backend endpoint to delete old logs // For now, just show a success message this.showClearModal = false; this.showNotification('Logs older than 30 days have been cleared', 'success'); // Reload logs after clearing await this.loadLogs(); } catch (error) { console.error('Error clearing logs:', error); this.showNotification('Failed to clear logs', 'error'); } }, prevPage() { if (this.currentPage > 1) { this.currentPage--; } }, nextPage() { if (this.currentPage < this.totalPages) { this.currentPage++; } }, goToPage(page) { this.currentPage = page; }, getPageNumbers() { const pages = []; const maxPages = 5; if (this.totalPages <= maxPages) { for (let i = 1; i <= this.totalPages; i++) { pages.push(i); } } else { let start = Math.max(1, this.currentPage - 2); let end = Math.min(this.totalPages, start + maxPages - 1); if (end - start + 1 < maxPages) { start = end - maxPages + 1; } for (let i = start; i <= end; i++) { pages.push(i); } } return pages; }, formatDate(dateString) { if (!dateString) return 'N/A'; const date = new Date(dateString); return date.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }); }, formatTimeAgo(dateString) { if (!dateString) return ''; const date = new Date(dateString); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 30) return `${diffDays}d ago`; return Math.floor(diffDays / 30) + 'mo ago'; }, hasActiveFilters() { return Object.values(this.filters).some(value => value !== ''); }, getActiveFilters() { const active = {}; for (const [key, value] of Object.entries(this.filters)) { if (value !== '') { active[key] = value; } } return active; }, getFilterLabel(key, value) { const labels = { status: `Status: ${value}`, date_from: `From: ${value}`, date_to: `To: ${value}`, search: `Search: ${value}` }; return labels[key] || `${key}: ${value}`; }, clearFilter(key) { this.filters[key] = ''; this.filterLogs(); }, clearAllFilters() { this.filters = { status: '', date_from: '', date_to: '', search: '' }; this.filterLogs(); }, showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 translate-x-full ${ type === 'success' ? 'bg-green-600 text-white' : type === 'error' ? 'bg-red-600 text-white' : 'bg-blue-600 text-white' }`; notification.innerHTML = ` <div class="flex items-center"> <i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-3"></i> <span>${message}</span> </div> `; document.body.appendChild(notification); setTimeout(() => { notification.classList.remove('translate-x-full'); notification.classList.add('translate-x-0'); }, 10); setTimeout(() => { notification.classList.add('opacity-0', 'translate-x-full'); setTimeout(() => notification.remove(), 300); }, 3000); } } } </script> </body> </html>