From f12f55423445a6029970f4428a9fe8db0a8c10ea Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 28 Oct 2025 11:19:47 +0100 Subject: [PATCH] Update activity tracking --- .../Controllers/Admin/AdminPageController.php | 127 ++--- app/Models/UserActivity.php | 49 ++ app/Observers/UserActivityObserver.php | 129 +++++ app/Providers/UserServiceProvider.php | 2 + ...27_165328_create_user_activities_table.php | 35 ++ resources/css/csp-safe.css | 33 +- resources/js/csp-safe.js | 475 +++--------------- resources/views/admin/dashboard.blade.php | 15 +- routes/web.php | 1 + 9 files changed, 371 insertions(+), 495 deletions(-) create mode 100644 app/Models/UserActivity.php create mode 100644 app/Observers/UserActivityObserver.php create mode 100644 database/migrations/2025_10_27_165328_create_user_activities_table.php diff --git a/app/Http/Controllers/Admin/AdminPageController.php b/app/Http/Controllers/Admin/AdminPageController.php index 680bc0e91..f848de79b 100644 --- a/app/Http/Controllers/Admin/AdminPageController.php +++ b/app/Http/Controllers/Admin/AdminPageController.php @@ -48,109 +48,48 @@ class AdminPageController extends BasePageController } /** - * Get recent user activity (registrations, deletions, activations, role changes) + * Get recent user activity from the user_activities table */ protected function getRecentUserActivity(): array { - $activities = []; - - // Get newly registered users (last 7 days) - $newUsers = \App\Models\User::select('id', 'username', 'created_at') - ->where('created_at', '>=', now()->subDays(7)) - ->orderBy('created_at', 'desc') + $activities = \App\Models\UserActivity::orderBy('created_at', 'desc') ->limit(10) ->get(); - foreach ($newUsers as $user) { - $activities[] = (object) [ - 'type' => 'registration', - 'message' => "New user registered: {$user->username}", - 'icon' => 'user-plus', - 'icon_bg' => 'bg-green-100 dark:bg-green-900', - 'icon_color' => 'text-green-600 dark:text-green-400', - 'created_at' => $user->created_at, - 'username' => $user->username, + return $activities->map(function ($activity) { + return (object) [ + 'type' => $activity->activity_type, + 'message' => $activity->description, + 'icon' => $activity->icon, + 'icon_bg' => 'bg-'.$activity->color_class.'-100 dark:bg-'.$activity->color_class.'-900', + 'icon_color' => 'text-'.$activity->color_class.'-600 dark:text-'.$activity->color_class.'-400', + 'created_at' => $activity->created_at, + 'username' => $activity->username, ]; - } + })->toArray(); + } - // Get recently deleted users (last 7 days) - only soft deleted - $deletedUsers = \App\Models\User::onlyTrashed() - ->select('id', 'username', 'deleted_at') - ->where('deleted_at', '>=', now()->subDays(7)) - ->orderBy('deleted_at', 'desc') - ->limit(10) - ->get(); + /** + * API endpoint to get recent user activity (for auto-refresh) + */ + public function getRecentActivity() + { + $activities = $this->getRecentUserActivity(); - foreach ($deletedUsers as $user) { - $activities[] = (object) [ - 'type' => 'deletion', - 'message' => "User deleted: {$user->username}", - 'icon' => 'user-minus', - 'icon_bg' => 'bg-red-100 dark:bg-red-900', - 'icon_color' => 'text-red-600 dark:text-red-400', - 'created_at' => $user->deleted_at, - 'username' => $user->username, - ]; - } - - // Get recently activated users (last 7 days) - $activatedUsers = \App\Models\User::select('id', 'username', 'email_verified_at') - ->whereNotNull('email_verified_at') - ->where('email_verified_at', '>=', now()->subDays(7)) - ->orderBy('email_verified_at', 'desc') - ->limit(10) - ->get(); - - foreach ($activatedUsers as $user) { - $activities[] = (object) [ - 'type' => 'activation', - 'message' => "User activated: {$user->username}", - 'icon' => 'user-check', - 'icon_bg' => 'bg-blue-100 dark:bg-blue-900', - 'icon_color' => 'text-blue-600 dark:text-blue-400', - 'created_at' => $user->email_verified_at, - 'username' => $user->username, - ]; - } - - // Get users with recent role changes (last 7 days) - $roleChanges = \App\Models\User::select('id', 'username', 'rolechangedate', 'roles_id') - ->whereNotNull('rolechangedate') - ->where('rolechangedate', '>=', now()->subDays(7)) - ->orderBy('rolechangedate', 'desc') - ->limit(10) - ->get(); - - foreach ($roleChanges as $user) { - // Get role name safely - $roleName = 'Unknown'; - if ($user->roles_id) { - try { - $role = \Spatie\Permission\Models\Role::find($user->roles_id); - $roleName = $role ? $role->name : 'Unknown'; - } catch (\Exception $e) { - $roleName = 'Role #'.$user->roles_id; - } - } - - $activities[] = (object) [ - 'type' => 'role_change', - 'message' => "User role changed: {$user->username} → {$roleName}", - 'icon' => 'user-tag', - 'icon_bg' => 'bg-purple-100 dark:bg-purple-900', - 'icon_color' => 'text-purple-600 dark:text-purple-400', - 'created_at' => \Carbon\Carbon::parse($user->rolechangedate), - 'username' => $user->username, - ]; - } - - // Sort all activities by date (most recent first) - usort($activities, function ($a, $b) { - return $b->created_at <=> $a->created_at; - }); - - // Return only the 10 most recent - return array_slice($activities, 0, 10); + return response()->json([ + 'success' => true, + 'activities' => array_map(function ($activity) { + return [ + 'type' => $activity->type, + 'message' => $activity->message, + 'icon' => $activity->icon, + 'icon_bg' => $activity->icon_bg, + 'icon_color' => $activity->icon_color, + 'created_at' => $activity->created_at->diffForHumans(), + 'username' => $activity->username, + ]; + }, $activities), + ]); } /** diff --git a/app/Models/UserActivity.php b/app/Models/UserActivity.php new file mode 100644 index 000000000..1091a8ec3 --- /dev/null +++ b/app/Models/UserActivity.php @@ -0,0 +1,49 @@ + 'array', + 'created_at' => 'datetime', + ]; + + /** + * Get icon class based on activity type + */ + public function getIconAttribute(): string + { + return match ($this->activity_type) { + 'registered' => 'user-plus', + 'deleted' => 'user-times', + 'role_updated' => 'user-shield', + default => 'info-circle', + }; + } + + /** + * Get color class based on activity type + */ + public function getColorClassAttribute(): string + { + return match ($this->activity_type) { + 'registered' => 'green', + 'deleted' => 'red', + 'role_updated' => 'blue', + default => 'gray', + }; + } +} diff --git a/app/Observers/UserActivityObserver.php b/app/Observers/UserActivityObserver.php new file mode 100644 index 000000000..a3b32178b --- /dev/null +++ b/app/Observers/UserActivityObserver.php @@ -0,0 +1,129 @@ + $user->id, + 'username' => $user->username, + 'activity_type' => 'registered', + 'description' => "New user registered: {$user->username}", + 'metadata' => [ + 'email' => $user->email, + 'ip_address' => request()->ip(), + ], + ]); + } + + /** + * Handle the User "updated" event. + */ + public function updated(User $user): void + { + // Check if the role was changed + if ($user->isDirty('roles_id')) { + $oldRoleId = $user->getOriginal('roles_id'); + $newRoleId = $user->roles_id; + + // Get role names + $oldRoleName = $this->getRoleName($oldRoleId); + $newRoleName = $this->getRoleName($newRoleId); + + UserActivity::create([ + 'user_id' => $user->id, + 'username' => $user->username, + 'activity_type' => 'role_updated', + 'description' => "User role updated: {$user->username} → {$newRoleName}", + 'metadata' => [ + 'old_role_id' => $oldRoleId, + 'new_role_id' => $newRoleId, + 'old_role_name' => $oldRoleName, + 'new_role_name' => $newRoleName, + 'updated_by' => auth()->user()?->username ?? 'System', + ], + ]); + } + } + + /** + * Handle the User "deleted" event (soft delete). + */ + public function deleted(User $user): void + { + // Only log if it's a soft delete (not force delete) + if ($user->trashed()) { + UserActivity::create([ + 'user_id' => $user->id, + 'username' => $user->username, + 'activity_type' => 'deleted', + 'description' => "User deleted: {$user->username}", + 'metadata' => [ + 'email' => $user->email, + 'deleted_by' => auth()->user()?->username ?? 'System', + ], + ]); + } + } + + /** + * Handle the User "restored" event. + */ + public function restored(User $user): void + { + UserActivity::create([ + 'user_id' => $user->id, + 'username' => $user->username, + 'activity_type' => 'registered', + 'description' => "User restored: {$user->username}", + 'metadata' => [ + 'restored_by' => auth()->user()?->username ?? 'System', + ], + ]); + } + + /** + * Handle the User "force deleted" event. + */ + public function forceDeleted(User $user): void + { + // Log permanent deletion + UserActivity::create([ + 'user_id' => null, // User no longer exists + 'username' => $user->username, + 'activity_type' => 'deleted', + 'description' => "User permanently deleted: {$user->username}", + 'metadata' => [ + 'email' => $user->email, + 'deleted_by' => auth()->user()?->username ?? 'System', + 'permanent' => true, + ], + ]); + } + + /** + * Get role name by ID + */ + private function getRoleName(?int $roleId): string + { + if (! $roleId) { + return 'None'; + } + + try { + $role = \Spatie\Permission\Models\Role::find($roleId); + + return $role ? $role->name : "Role #{$roleId}"; + } catch (\Exception $e) { + return "Role #{$roleId}"; + } + } +} diff --git a/app/Providers/UserServiceProvider.php b/app/Providers/UserServiceProvider.php index 9953756fa..a9c8ddf98 100644 --- a/app/Providers/UserServiceProvider.php +++ b/app/Providers/UserServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Models\User; +use App\Observers\UserActivityObserver; use App\Observers\UserServiceObserver; use Illuminate\Support\ServiceProvider; @@ -22,5 +23,6 @@ class UserServiceProvider extends ServiceProvider public function boot(): void { User::observe(UserServiceObserver::class); + User::observe(UserActivityObserver::class); } } diff --git a/database/migrations/2025_10_27_165328_create_user_activities_table.php b/database/migrations/2025_10_27_165328_create_user_activities_table.php new file mode 100644 index 000000000..5f5fab7fe --- /dev/null +++ b/database/migrations/2025_10_27_165328_create_user_activities_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('username', 255); + $table->string('activity_type', 50); // 'registered', 'deleted', 'role_updated' + $table->text('description'); + $table->json('metadata')->nullable(); // Additional data like old/new role + $table->timestamp('created_at')->useCurrent(); + + $table->index(['activity_type', 'created_at']); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_activities'); + } +}; diff --git a/resources/css/csp-safe.css b/resources/css/csp-safe.css index 48cd9cc45..cb20fe0b7 100644 --- a/resources/css/csp-safe.css +++ b/resources/css/csp-safe.css @@ -1,12 +1,43 @@ /* Admin Dashboard - Recent Activity Styles */ .activity-item { - transition: background-color 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out; } .activity-item:hover { transform: translateX(2px); } +/* Activity refresh animation */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.activity-item { + animation: fadeIn 0.3s ease-in-out; +} + +/* Recent activity loading state */ +#recent-activity-container.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Last updated timestamp subtle animation */ +#activity-last-updated { + transition: color 0.3s ease-in-out; +} + +#activity-last-updated:hover { + color: rgb(99, 102, 241); /* indigo-500 */ +} + /* Chart Container Responsive Sizing */ .chart-container { position: relative; diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index 4533a0912..42caf1ae1 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -27,6 +27,7 @@ document.addEventListener('DOMContentLoaded', function() { initAdminGroups(); initTinyMCE(); initAdminSpecificFeatures(); + initRecentActivityRefresh(); // Initialize page-specific functionality from inline scripts initMyMovies(); @@ -3263,7 +3264,8 @@ function startSystemMetricsAutoRefresh() { const timestampElem = document.querySelector('[data-metric="last-updated"]'); if (timestampElem) { const now = new Date(); - timestampElem.textContent = now.toLocaleTimeString(); + const timeString = now.toLocaleTimeString(); + timestampElem.textContent = 'Last updated: ' + timeString; } }) .catch(error => { @@ -4255,419 +4257,102 @@ function initAddToCart() { }; } -// Note: Main DOMContentLoaded event listener is already defined at the top of the file -// Just adding the new initializations to the existing initialization list above - -// Show validation error message for deleted users page -window.showValidationError = function(message) { - const errorDiv = document.getElementById('validationError'); - const errorMessage = document.getElementById('validationErrorMessage'); - if (errorDiv && errorMessage) { - errorMessage.textContent = message; - errorDiv.classList.remove('hidden'); - - // Scroll to the error message - errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - - // Auto-hide after 5 seconds - setTimeout(() => { - errorDiv.classList.add('hidden'); - }, 5000); - } -}; - -// Hide validation error -window.hideValidationError = function() { - const errorDiv = document.getElementById('validationError'); - if (errorDiv) { - errorDiv.classList.add('hidden'); - } -}; - -// Confirm bulk action for deleted users -window.confirmBulkAction = function(event) { - const action = document.getElementById('bulkAction')?.value; - const checkedBoxes = document.querySelectorAll('.user-checkbox:checked'); - - if (!action) { - event.preventDefault(); - showValidationError('Please select an action from the dropdown.'); - return false; +// Recent Activity Auto-Refresh (20 minutes) +function initRecentActivityRefresh() { + const activityContainer = document.getElementById('recent-activity-container'); + if (!activityContainer) { + return; // Not on admin dashboard page } - if (checkedBoxes.length === 0) { - event.preventDefault(); - showValidationError('Please select at least one user to perform this action.'); - return false; + const refreshUrl = activityContainer.getAttribute('data-refresh-url'); + if (!refreshUrl) { + return; } - // Show confirmation dialog with appropriate message - let confirmMessage = ''; - if (action === 'delete') { - confirmMessage = `Are you sure you want to PERMANENTLY delete ${checkedBoxes.length} user(s)?\n\nThis action cannot be undone!`; - } else if (action === 'restore') { - confirmMessage = `Are you sure you want to restore ${checkedBoxes.length} user(s)?`; - } - - if (!confirm(confirmMessage)) { - event.preventDefault(); - return false; - } - - return true; -}; - -// Movies Layout Toggle Functionality -function initMoviesLayoutToggle() { - const layoutToggle = document.getElementById('layoutToggle'); - const layoutToggleText = document.getElementById('layoutToggleText'); - const moviesGrid = document.getElementById('moviesGrid'); - - if (!layoutToggle || !layoutToggleText || !moviesGrid) { - return; // Not on movies page - } - - // Get saved layout preference from data attribute (from database) - // 1 = 1-column, 2 = 2-columns - let currentLayout = parseInt(moviesGrid.dataset.userLayout) || 2; - - // Apply the saved layout on page load - applyLayout(currentLayout); - - // Toggle layout on button click - layoutToggle.addEventListener('click', function() { - if (currentLayout === 2) { - currentLayout = 1; - } else { - currentLayout = 2; - } - - // Save preference to database via AJAX - fetch('/movies/update-layout', { - method: 'POST', + // Function to refresh activity data + function refreshRecentActivity() { + fetch(refreshUrl, { + method: 'GET', headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' }, - body: JSON.stringify({ - layout: currentLayout - }) + credentials: 'same-origin' + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); }) - .then(response => response.json()) .then(data => { - if (data.success) { - // Apply layout after successful save - applyLayout(currentLayout); + if (data.success && data.activities) { + updateActivityDisplay(data.activities); + updateLastRefreshTime(); } }) .catch(error => { - console.error('Error saving layout preference:', error); - // Still apply layout even if save failed - applyLayout(currentLayout); + console.error('Error refreshing recent activity:', error); }); - }); - - function applyLayout(layout) { - // Get all movie poster images and placeholder divs - const posterImages = moviesGrid.querySelectorAll('img[alt], .bg-gray-200.dark\\:bg-gray-700'); - - // Get all release card containers - const releaseCardContainers = moviesGrid.querySelectorAll('.release-card-container'); - - if (layout === 1) { - // 1-column layout: larger images (w-48 h-72, original size) - moviesGrid.classList.remove('lg:grid-cols-2'); - moviesGrid.classList.add('grid-cols-1'); - layoutToggleText.textContent = '1 Column'; - layoutToggle.querySelector('i').className = 'fas fa-th-list mr-2'; - - // Update image sizes to larger - posterImages.forEach(el => { - el.classList.remove('w-32', 'h-48'); - el.classList.add('w-48', 'h-72'); - }); - - // Update release card layout for 1-column view - side by side on all screens - releaseCardContainers.forEach(container => { - // Change container to flex-row with items on sides - container.classList.remove('space-y-2'); - container.classList.add('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3'); - - // Make info wrapper take available space - const infoWrapper = container.querySelector('.release-info-wrapper'); - if (infoWrapper) { - infoWrapper.classList.add('flex-1', 'min-w-0'); - } - - // Make actions wrapper stay on the right in a horizontal row - const actionsWrapper = container.querySelector('.release-actions'); - if (actionsWrapper) { - actionsWrapper.classList.remove('flex-wrap'); - actionsWrapper.classList.add('flex-shrink-0', 'flex-row', 'items-center'); - } - }); - } else { - // 2-column layout: smaller images (w-32 h-48) - moviesGrid.classList.remove('grid-cols-1'); - moviesGrid.classList.add('lg:grid-cols-2'); - layoutToggleText.textContent = '2 Columns'; - layoutToggle.querySelector('i').className = 'fas fa-th-large mr-2'; - - // Update image sizes to smaller - posterImages.forEach(el => { - el.classList.remove('w-48', 'h-72'); - el.classList.add('w-32', 'h-48'); - }); - - // Update release card layout for 2-column view - stacked vertically - releaseCardContainers.forEach(container => { - // Change back to stacked layout - container.classList.add('space-y-2'); - container.classList.remove('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3'); - - // Remove special styling from info wrapper - const infoWrapper = container.querySelector('.release-info-wrapper'); - if (infoWrapper) { - infoWrapper.classList.remove('flex-1', 'min-w-0'); - } - - // Restore normal flex-wrap for actions wrapper - const actionsWrapper = container.querySelector('.release-actions'); - if (actionsWrapper) { - actionsWrapper.classList.add('flex-wrap', 'items-center'); - actionsWrapper.classList.remove('flex-shrink-0', 'flex-row'); - } - }); - } - } -} - -/** - * Profile Charts - User Download and API Request Charts - * Initialize charts, progress bars, and tab switching for profile page - */ -// Store chart instances globally to prevent recreation -let profileDownloadsChartInstance = null; -let profileApiRequestsChartInstance = null; - -function initProfileCharts() { - initProfileProgressBars(); - // Note: initProfileChartsData() is called when the API tab is shown for the first time -} - -/** - * Initialize progress bar animations - */ -function initProfileProgressBars() { - const progressBars = document.querySelectorAll('.progress-bar'); - progressBars.forEach(bar => { - const width = bar.dataset.width; - if (width) { - // Small delay to trigger CSS transition - setTimeout(() => { - bar.style.width = width + '%'; - }, 100); - } - }); -} - -/** - * Initialize Chart.js charts for profile page - */ -function initProfileChartsData() { - // Get data from data attributes - const chartContainer = document.getElementById('profile-charts-container'); - if (!chartContainer) return; - - const downloadsHourlyData = JSON.parse(chartContainer.dataset.downloadsHourly || '{}'); - const apiRequestsHourlyData = JSON.parse(chartContainer.dataset.apiRequestsHourly || '{}'); - const downloadLimit = parseInt(chartContainer.dataset.downloadLimit || '0'); - const apiLimit = parseInt(chartContainer.dataset.apiLimit || '0'); - - const labels = Object.keys(downloadsHourlyData); - const downloadsValues = Object.values(downloadsHourlyData); - const apiValues = Object.values(apiRequestsHourlyData); - - // Check if user is in dark mode - const isDarkMode = document.documentElement.classList.contains('dark'); - const gridColor = isDarkMode ? 'rgba(75, 85, 99, 0.3)' : 'rgba(200, 200, 200, 0.3)'; - const textColor = isDarkMode ? 'rgba(156, 163, 175, 1)' : 'rgba(75, 85, 99, 1)'; - - // Create Downloads Chart - createProfileDownloadsChart(labels, downloadsValues, downloadLimit, gridColor, textColor); - - // Create API Requests Chart - createProfileApiRequestsChart(labels, apiValues, apiLimit, gridColor, textColor); -} - -/** - * Create Downloads Chart for profile page - */ -function createProfileDownloadsChart(labels, data, limit, gridColor, textColor) { - const downloadsCtx = document.getElementById('downloadsChart'); - if (!downloadsCtx) return; - - // Destroy existing chart instance if it exists - if (profileDownloadsChartInstance) { - profileDownloadsChartInstance.destroy(); } - profileDownloadsChartInstance = new Chart(downloadsCtx, { - type: 'line', - data: { - labels: labels, - datasets: [{ - label: 'Downloads', - data: data, - borderColor: 'rgb(59, 130, 246)', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - borderWidth: 2, - fill: true, - tension: 0.4, - pointBackgroundColor: 'rgb(59, 130, 246)', - pointBorderColor: '#fff', - pointBorderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5 - }, { - label: 'Limit', - data: Array(labels.length).fill(limit), - borderColor: 'rgba(239, 68, 68, 0.5)', - borderWidth: 2, - borderDash: [5, 5], - fill: false, - pointRadius: 0, - pointHoverRadius: 0 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - labels: { - color: textColor - } - }, - tooltip: { - mode: 'index', - intersect: false, - } - }, - scales: { - y: { - beginAtZero: true, - ticks: { - stepSize: 1, - color: textColor - }, - grid: { - color: gridColor - } - }, - x: { - ticks: { - color: textColor, - maxRotation: 45, - minRotation: 45 - }, - grid: { - color: gridColor - } - } - }, - interaction: { - mode: 'nearest', - axis: 'x', - intersect: false - } + // Function to update the activity display + function updateActivityDisplay(activities) { + const container = document.getElementById('recent-activity-container'); + if (!container) return; + + // Clear existing content + container.innerHTML = ''; + + if (activities.length === 0) { + container.innerHTML = '

No recent activity

'; + return; } - }); -} -/** - * Create API Requests Chart for profile page - */ -function createProfileApiRequestsChart(labels, data, limit, gridColor, textColor) { - const apiCtx = document.getElementById('apiRequestsChart'); - if (!apiCtx) return; + // Add each activity item + activities.forEach(activity => { + const activityItem = document.createElement('div'); + activityItem.className = 'flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors'; - // Destroy existing chart instance if it exists - if (profileApiRequestsChartInstance) { - profileApiRequestsChartInstance.destroy(); + activityItem.innerHTML = ` +
+ +
+
+

${escapeHtml(activity.message)}

+

${escapeHtml(activity.created_at)}

+
+ `; + + container.appendChild(activityItem); + }); } - profileApiRequestsChartInstance = new Chart(apiCtx, { - type: 'line', - data: { - labels: labels, - datasets: [{ - label: 'API Requests', - data: data, - borderColor: 'rgb(168, 85, 247)', - backgroundColor: 'rgba(168, 85, 247, 0.1)', - borderWidth: 2, - fill: true, - tension: 0.4, - pointBackgroundColor: 'rgb(168, 85, 247)', - pointBorderColor: '#fff', - pointBorderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5 - }, { - label: 'Limit', - data: Array(labels.length).fill(limit), - borderColor: 'rgba(239, 68, 68, 0.5)', - borderWidth: 2, - borderDash: [5, 5], - fill: false, - pointRadius: 0, - pointHoverRadius: 0 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - labels: { - color: textColor - } - }, - tooltip: { - mode: 'index', - intersect: false, - } - }, - scales: { - y: { - beginAtZero: true, - ticks: { - stepSize: 1, - color: textColor - }, - grid: { - color: gridColor - } - }, - x: { - ticks: { - color: textColor, - maxRotation: 45, - minRotation: 45 - }, - grid: { - color: gridColor - } - } - }, - interaction: { - mode: 'nearest', - axis: 'x', - intersect: false - } + // Function to update last refresh time + function updateLastRefreshTime() { + const lastUpdatedElement = document.getElementById('activity-last-updated'); + if (lastUpdatedElement) { + const now = new Date(); + const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + lastUpdatedElement.innerHTML = ` Last updated: ${timeString}`; + } + } + + // Helper function to escape HTML to prevent XSS + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Set up auto-refresh every 20 minutes (1200000 milliseconds) + const refreshInterval = 20 * 60 * 1000; // 20 minutes in milliseconds + setInterval(refreshRecentActivity, refreshInterval); + + // Also refresh on page visibility change (when user comes back to tab) + document.addEventListener('visibilitychange', function() { + if (!document.hidden) { + refreshRecentActivity(); } }); } diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index f18c8cc31..dfd0735ef 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -442,11 +442,16 @@
-

- - Recent User Activity +

+ + + Recent User Activity + + + Auto-refreshes every 20 minutes +

-
+
@if(isset($recent_activity) && count($recent_activity) > 0) @foreach($recent_activity as $activity)
@@ -460,7 +465,7 @@
@endforeach @else -

No recent activity

+

No recent activity

@endif
diff --git a/routes/web.php b/routes/web.php index 7d78dceec..bc57dbe01 100644 --- a/routes/web.php +++ b/routes/web.php @@ -181,6 +181,7 @@ Route::middleware('role:Admin', '2fa')->prefix('admin')->group(function () { // User Activity API endpoints Route::get('api/user-activity/minutes', [AdminPageController::class, 'getUserActivityMinutes'])->name('admin.api.user-activity.minutes'); + Route::get('api/user-activity/recent', [AdminPageController::class, 'getRecentActivity'])->name('admin.api.user-activity.recent'); Route::post('anidb-delete/{id}', [AdminAnidbController::class, 'destroy'])->name('admin.anidb-delete'); Route::match(['GET', 'POST'], 'anidb-edit/{id}', [AdminAnidbController::class, 'edit'])->name('admin.anidb-edit');