Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Component;
|
||||
|
||||
class Log extends Component
|
||||
{
|
||||
public $logContent = '';
|
||||
public $message = '';
|
||||
public $diskUsage = '';
|
||||
public $diskUsageClass = '';
|
||||
public $availableRam;
|
||||
public $availableRamClass;
|
||||
public $queueWorkers = [];
|
||||
public $queueWorkersCount = 0;
|
||||
public $reverbConnected = false;
|
||||
|
||||
|
||||
public function mount()
|
||||
{
|
||||
// Security check: user must be authenticated
|
||||
if (!Auth::check()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: activeProfileType must be 'App\Models\Admin'
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: user must own the active profile
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
if (!$this->hasLogsPermission()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Load log content - support both daily (laravel-YYYY-MM-DD.log) and single (laravel.log) drivers
|
||||
$logPath = storage_path('logs/laravel-' . date('Y-m-d') . '.log');
|
||||
if (!file_exists($logPath)) {
|
||||
$logPath = storage_path('logs/laravel.log');
|
||||
}
|
||||
if (file_exists($logPath)) {
|
||||
$logLines = (int) timebank_config('admin_settings.log_lines', 100);
|
||||
$this->logContent = shell_exec("tail -n {$logLines} " . escapeshellarg($logPath));
|
||||
// Use tail output to check for warnings/errors (avoid file() which loads entire file into memory)
|
||||
$recentLines = explode("\n", $this->logContent ?? '');
|
||||
|
||||
$messages = [];
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'WARNING') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-orange-500">Warning</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ERROR') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Error</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'CRITICAL') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Critical</span> issue detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ALERT') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Alert</span> detected in the recent log output - IMMEDIATE ACTION REQUIRED';
|
||||
}
|
||||
$this->message = implode('<br>', $messages);
|
||||
}
|
||||
|
||||
// Get disk usage
|
||||
$free = disk_free_space("/");
|
||||
$total = disk_total_space("/");
|
||||
$used = $total - $free;
|
||||
$percent = $total > 0 ? round(($used / $total) * 100, 1) : 0;
|
||||
|
||||
|
||||
// Determine disk usage color class
|
||||
if ($percent > 90) {
|
||||
$this->diskUsageClass = 'text-red-500';
|
||||
} elseif ($percent > 75) {
|
||||
$this->diskUsageClass = 'text-orange-500';
|
||||
} else {
|
||||
$this->diskUsageClass = 'text-green-500';
|
||||
}
|
||||
|
||||
$this->diskUsage = sprintf(
|
||||
'%s used of %s (%.1f%%)',
|
||||
$this->formatBytes($used),
|
||||
$this->formatBytes($total),
|
||||
$percent
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Get RAM memory information
|
||||
$meminfo = file_get_contents('/proc/meminfo');
|
||||
preg_match('/MemTotal:\s+(\d+)/', $meminfo, $matchesTotal);
|
||||
preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $matchesAvailable);
|
||||
|
||||
$totalKb = $matchesTotal[1] ?? 0;
|
||||
$availableKb = $matchesAvailable[1] ?? 0;
|
||||
$usedKb = $totalKb - $availableKb;
|
||||
|
||||
$percentRam = $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 1) : 0;
|
||||
|
||||
$this->availableRam = sprintf(
|
||||
'%s used of %s (%.1f%%)',
|
||||
$this->formatBytes($usedKb * 1024),
|
||||
$this->formatBytes($totalKb * 1024),
|
||||
$percentRam
|
||||
);
|
||||
|
||||
if ($percentRam > 90) {
|
||||
$this->availableRamClass = 'text-red-500';
|
||||
} elseif ($percentRam > 75) {
|
||||
$this->availableRamClass = 'text-orange-500';
|
||||
} else {
|
||||
$this->availableRamClass = 'text-green-500';
|
||||
}
|
||||
|
||||
|
||||
// Check running queue workers
|
||||
$queueWorkers = [];
|
||||
exec("ps aux | grep 'artisan queue:work' | grep -v grep", $output);
|
||||
foreach ($output as $line) {
|
||||
// Parse $line for more details
|
||||
$queueWorkers[] = $line;
|
||||
}
|
||||
$this->queueWorkers = $queueWorkers;
|
||||
$this->queueWorkersCount = count($queueWorkers);
|
||||
|
||||
// Check Reverb server connection (example: check if port is open)
|
||||
$reverbPort = env('REVERB_PORT', 6001);
|
||||
|
||||
// Check Reverb server connection
|
||||
$reverbConnected = false;
|
||||
$connection = @fsockopen('127.0.0.1', $reverbPort, $errno, $errstr, 1);
|
||||
if ($connection) {
|
||||
$reverbConnected = true;
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
$this->reverbConnected = $reverbConnected;
|
||||
|
||||
|
||||
}
|
||||
|
||||
protected function hasLogsPermission(): bool
|
||||
{
|
||||
$webUser = \Illuminate\Support\Facades\Auth::guard('web')->user();
|
||||
$activeId = session('activeProfileId');
|
||||
if (!$webUser || !$activeId) return false;
|
||||
$prefix = 'Admin\\' . $activeId . '\\';
|
||||
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
foreach ($scopedRoles as $scopedRole) {
|
||||
$roleName = str_replace($prefix, '', $scopedRole->name);
|
||||
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
|
||||
if ($adminRole && in_array('view application-log', $adminRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to format bytes
|
||||
public function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
public function downloadLog()
|
||||
{
|
||||
$logPath = storage_path('logs/laravel-' . date('Y-m-d') . '.log');
|
||||
if (!file_exists($logPath)) {
|
||||
$logPath = storage_path('logs/laravel.log');
|
||||
}
|
||||
if (file_exists($logPath)) {
|
||||
return response()->download($logPath, 'laravel-' . date('Y-m-d') . '.log');
|
||||
}
|
||||
|
||||
session()->flash('error', 'Log file not found');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$layout = Auth::check() ? 'app-layout' : 'guest-layout';
|
||||
return view('livewire.admin.log', [
|
||||
'layout' => $layout,
|
||||
'logContent' => $this->logContent,
|
||||
'message' => $this->message,
|
||||
'diskUsage' => $this->diskUsage,
|
||||
'diskUsageClass' => $this->diskUsageClass,
|
||||
'availableRam' => $this->availableRam,
|
||||
'availableRamClass' => $this->availableRamClass,
|
||||
'queueWorkers' => $this->queueWorkers,
|
||||
'queueWorkersCount' => $this->queueWorkersCount,
|
||||
'reverbConnected' => $this->reverbConnected,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Component;
|
||||
|
||||
class LogViewer extends Component
|
||||
{
|
||||
public $logFilename;
|
||||
public $logContent = '';
|
||||
public $message = '';
|
||||
public $fileSize = '';
|
||||
public $lastModified = '';
|
||||
public $logTitle = '';
|
||||
|
||||
public function mount($logFilename, $logTitle = null)
|
||||
{
|
||||
// Security check: user must be authenticated
|
||||
if (!Auth::check()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: activeProfileType must be 'App\Models\Admin'
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: user must own the active profile
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$this->logFilename = $logFilename;
|
||||
|
||||
if (!$this->hasLogsPermission()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
$this->logTitle = $logTitle ?? $logFilename;
|
||||
|
||||
$this->loadLogContent();
|
||||
}
|
||||
|
||||
protected function hasLogsPermission(): bool
|
||||
{
|
||||
$permission = match (true) {
|
||||
str_contains($this->logFilename, 'inactive-profiles') => 'view inactive-profiles-log',
|
||||
str_contains($this->logFilename, 'scout-reindex') => 'view search-reindex-log',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$permission) return false;
|
||||
|
||||
$webUser = \Illuminate\Support\Facades\Auth::guard('web')->user();
|
||||
$activeId = session('activeProfileId');
|
||||
if (!$webUser || !$activeId) return false;
|
||||
$prefix = 'Admin\\' . $activeId . '\\';
|
||||
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
foreach ($scopedRoles as $scopedRole) {
|
||||
$roleName = str_replace($prefix, '', $scopedRole->name);
|
||||
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
|
||||
if ($adminRole && in_array($permission, $adminRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function loadLogContent()
|
||||
{
|
||||
$logPath = storage_path('logs/' . $this->logFilename);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
$realPath = realpath($logPath);
|
||||
$logsDir = realpath(storage_path('logs'));
|
||||
|
||||
if (!$realPath || strpos($realPath, $logsDir) !== 0) {
|
||||
$this->message = '<span class="font-bold text-red-500">Error:</span> Invalid log file path';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($logPath)) {
|
||||
// Get file info
|
||||
$this->fileSize = $this->formatBytes(filesize($logPath));
|
||||
$this->lastModified = date('Y-m-d H:i:s', filemtime($logPath));
|
||||
|
||||
// Load log content using tail (avoid file() which loads entire file into memory)
|
||||
$logLines = (int) timebank_config('admin_settings.log_lines', 100);
|
||||
$this->logContent = shell_exec("tail -n {$logLines} " . escapeshellarg($logPath));
|
||||
|
||||
$recentLines = explode("\n", $this->logContent ?? '');
|
||||
|
||||
// Check for warnings/errors in log content
|
||||
$messages = [];
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'WARNING') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-orange-500">Warning</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ERROR') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Error</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'CRITICAL') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Critical</span> issue detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ALERT') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Alert</span> detected in the recent log output - IMMEDIATE ACTION REQUIRED';
|
||||
}
|
||||
|
||||
if (!empty($messages)) {
|
||||
$this->message = implode('<br>', $messages);
|
||||
}
|
||||
} else {
|
||||
$this->message = '<span class="font-bold text-gray-500">Log file not found or empty</span>';
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadLog()
|
||||
{
|
||||
$logPath = storage_path('logs/' . $this->logFilename);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
$realPath = realpath($logPath);
|
||||
$logsDir = realpath(storage_path('logs'));
|
||||
|
||||
if (!$realPath || strpos($realPath, $logsDir) !== 0) {
|
||||
session()->flash('error', 'Invalid log file path');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($logPath)) {
|
||||
return response()->download($logPath, $this->logFilename . '-' . date('Y-m-d') . '.log');
|
||||
}
|
||||
|
||||
session()->flash('error', 'Log file not found');
|
||||
}
|
||||
|
||||
public function refreshLog()
|
||||
{
|
||||
$this->loadLogContent();
|
||||
$this->dispatch('logRefreshed');
|
||||
}
|
||||
|
||||
// Helper to format bytes
|
||||
public function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.log-viewer');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class MaintenanceBanner extends Component
|
||||
{
|
||||
public $maintenanceMode = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->maintenanceMode = isMaintenanceMode();
|
||||
}
|
||||
|
||||
#[On('maintenance-mode-changed')]
|
||||
public function refreshMaintenanceMode()
|
||||
{
|
||||
$this->maintenanceMode = isMaintenanceMode();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.maintenance-banner');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class MaintenanceMode extends Component
|
||||
{
|
||||
public $maintenanceMode = false;
|
||||
public $showModal = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Only administrators can access this feature.');
|
||||
}
|
||||
|
||||
if (!$this->hasMaintenancePermission()) {
|
||||
abort(403, __('You do not have permission to perform this action.'));
|
||||
}
|
||||
|
||||
$this->maintenanceMode = $this->getMaintenanceMode();
|
||||
}
|
||||
|
||||
public function openModal()
|
||||
{
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function closeModal()
|
||||
{
|
||||
$this->showModal = false;
|
||||
}
|
||||
|
||||
public function toggleMaintenanceMode()
|
||||
{
|
||||
if (getActiveProfileType() !== 'Admin' || !$this->hasMaintenancePermission()) {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'message' => __('You do not have permission to perform this action.'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle the value
|
||||
$this->maintenanceMode = !$this->maintenanceMode;
|
||||
|
||||
// If enabling maintenance mode, log out all non-admin users
|
||||
if ($this->maintenanceMode) {
|
||||
$this->logoutNonAdminUsers();
|
||||
}
|
||||
|
||||
// Update in database
|
||||
DB::table('system_settings')
|
||||
->where('key', 'maintenance_mode')
|
||||
->update([
|
||||
'value' => $this->maintenanceMode ? 'true' : 'false',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Clear cache
|
||||
Cache::forget('system_setting_maintenance_mode');
|
||||
|
||||
// Close modal
|
||||
$this->showModal = false;
|
||||
|
||||
// Dispatch event to refresh the maintenance banner
|
||||
$this->dispatch('maintenance-mode-changed');
|
||||
|
||||
// Notify user
|
||||
$message = $this->maintenanceMode
|
||||
? 'Maintenance mode has been enabled. Only users with admin relationships can now log in.'
|
||||
: 'Maintenance mode has been disabled. All users can now log in.';
|
||||
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'success',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out all users who don't have admin relationships
|
||||
*/
|
||||
protected function logoutNonAdminUsers()
|
||||
{
|
||||
// Get the current authenticated user ID to exclude from logout
|
||||
$currentUserId = auth()->id();
|
||||
|
||||
// Get all users without admin relationships, excluding current user
|
||||
$usersToLogout = \App\Models\User::whereDoesntHave('admins')
|
||||
->where('id', '!=', $currentUserId)
|
||||
->get();
|
||||
|
||||
$logoutCount = 0;
|
||||
|
||||
// First, broadcast logout events to all users
|
||||
// This gives browsers a chance to receive the WebSocket message before sessions are deleted
|
||||
foreach ($usersToLogout as $user) {
|
||||
// Determine the guard for this user
|
||||
$guard = 'web'; // Default guard
|
||||
|
||||
// Broadcast forced logout event via WebSocket
|
||||
broadcast(new \App\Events\UserForcedLogout($user->id, $guard));
|
||||
|
||||
$logoutCount++;
|
||||
}
|
||||
|
||||
// Wait briefly for WebSocket messages to be delivered
|
||||
// This helps ensure browsers receive the logout event before sessions are deleted
|
||||
sleep(2);
|
||||
|
||||
// Now delete sessions and clear caches
|
||||
foreach ($usersToLogout as $user) {
|
||||
$guard = 'web';
|
||||
|
||||
// Delete all sessions for this user from database
|
||||
\DB::connection(config('session.connection'))
|
||||
->table(config('session.table', 'sessions'))
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
// Clear cached authentication data
|
||||
Cache::forget('auth_' . $guard . '_' . $user->id);
|
||||
|
||||
// Clear presence cache
|
||||
Cache::forget("presence_{$guard}_{$user->id}");
|
||||
}
|
||||
|
||||
// Clear online users cache to force refresh
|
||||
Cache::forget("online_users_web_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
// Log the action for debugging
|
||||
info("Maintenance mode enabled: Logged out {$logoutCount} non-admin users. Current admin user ID {$currentUserId} was preserved.");
|
||||
}
|
||||
|
||||
protected function hasMaintenancePermission(): bool
|
||||
{
|
||||
$webUser = \Illuminate\Support\Facades\Auth::guard('web')->user();
|
||||
$activeId = session('activeProfileId');
|
||||
if (!$webUser || !$activeId) {
|
||||
return false;
|
||||
}
|
||||
$prefix = 'Admin\\' . $activeId . '\\';
|
||||
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
foreach ($scopedRoles as $scopedRole) {
|
||||
$roleName = str_replace($prefix, '', $scopedRole->name);
|
||||
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
|
||||
if ($adminRole && in_array('manage maintenance', $adminRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function getMaintenanceMode()
|
||||
{
|
||||
return isMaintenanceMode();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.maintenance-mode');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use App\Helpers\SearchOptimizationHelper;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\District;
|
||||
use App\Models\Locations\Division;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class SearchMetrics extends Component
|
||||
{
|
||||
public array $metrics = [];
|
||||
public array $recentSearches = [];
|
||||
public string $period = 'last_year';
|
||||
|
||||
public array $periods = [
|
||||
'last_week' => 'Last week',
|
||||
'last_month' => 'Last month',
|
||||
'last_6months' => 'Last 6 months',
|
||||
'last_year' => 'Last year',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
if (!$this->hasLogsPermission()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$this->loadMetrics();
|
||||
}
|
||||
|
||||
protected function hasLogsPermission(): bool
|
||||
{
|
||||
$webUser = Auth::guard('web')->user();
|
||||
$activeId = session('activeProfileId');
|
||||
if (!$webUser || !$activeId) return false;
|
||||
$prefix = 'Admin\\' . $activeId . '\\';
|
||||
$scopedRoles = $webUser->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
foreach ($scopedRoles as $scopedRole) {
|
||||
$roleName = str_replace($prefix, '', $scopedRole->name);
|
||||
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
|
||||
if ($adminRole && in_array('view search-metrics', $adminRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function refresh()
|
||||
{
|
||||
$this->loadMetrics();
|
||||
}
|
||||
|
||||
public function updatedPeriod()
|
||||
{
|
||||
$this->loadMetrics();
|
||||
}
|
||||
|
||||
private function periodSince(): \Carbon\Carbon
|
||||
{
|
||||
return match ($this->period) {
|
||||
'last_week' => now()->subWeek(),
|
||||
'last_month' => now()->subMonth(),
|
||||
'last_6months' => now()->subMonths(6),
|
||||
default => now()->subMonths(12)->startOfMonth(),
|
||||
};
|
||||
}
|
||||
|
||||
public function downloadLog()
|
||||
{
|
||||
$csv = implode(',', ['time', 'query', 'categories', 'results', 'exec_s', 'lang', 'type', 'location']) . "\n";
|
||||
|
||||
foreach ($this->recentSearches as $row) {
|
||||
$categories = implode('|', array_column($row['category_tags'] ?? [], 'name'));
|
||||
$csv .= implode(',', [
|
||||
\Carbon\Carbon::parse($row['timestamp'])->format('Y-m-d'),
|
||||
'"' . str_replace('"', '""', $row['search_string'] ?? '') . '"',
|
||||
'"' . str_replace('"', '""', $categories) . '"',
|
||||
$row['total_results'] ?? 0,
|
||||
number_format($row['execution_time'] ?? 0, 4),
|
||||
$row['locale'] ?? '',
|
||||
$row['user_type'] ?? '',
|
||||
'"' . str_replace('"', '""', $row['location_label'] ?? '') . '"',
|
||||
]) . "\n";
|
||||
}
|
||||
|
||||
$filename = 'search-metrics-' . now()->format('Y-m') . '.csv';
|
||||
|
||||
return response()->streamDownload(fn () => print($csv), $filename, [
|
||||
'Content-Type' => 'text/csv',
|
||||
]);
|
||||
}
|
||||
|
||||
private function loadMetrics(): void
|
||||
{
|
||||
$since = $this->periodSince();
|
||||
$this->metrics = SearchOptimizationHelper::getSearchMetrics($since);
|
||||
|
||||
$raw = SearchOptimizationHelper::readCurrentMonthEntries(50, $since);
|
||||
|
||||
// Collect all unique category IDs across all entries, then resolve names once
|
||||
$allIds = array_values(array_unique(array_merge(...array_map(
|
||||
fn($s) => $s['category_ids'] ?? [],
|
||||
$raw
|
||||
))));
|
||||
|
||||
$tagMap = [];
|
||||
if (!empty($allIds)) {
|
||||
$locale = app()->getLocale();
|
||||
$categories = \App\Models\Category::whereIn('id', $allIds)
|
||||
->with(['translations' => fn($q) => $q->where('locale', $locale)])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($allIds as $id) {
|
||||
$cat = $categories->get($id);
|
||||
$tagMap[$id] = [
|
||||
'name' => $cat?->translation?->name ?? $cat?->name ?? $id,
|
||||
'color' => $cat?->relatedColor ?? 'gray',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all unique location IDs and resolve names in one query per type
|
||||
$locale = app()->getLocale();
|
||||
$locationNameMap = $this->resolveLocationNames($raw, $locale);
|
||||
|
||||
// Replace category_ids with resolved name+color tags, and add location label per entry
|
||||
$this->recentSearches = array_map(function ($search) use ($tagMap, $locationNameMap) {
|
||||
$search['category_tags'] = array_map(
|
||||
fn($id) => $tagMap[$id] ?? ['name' => $id, 'color' => 'gray'],
|
||||
$search['category_ids'] ?? []
|
||||
);
|
||||
|
||||
// Build location short label matching LocationTrait::name_short logic:
|
||||
// city + (district or division), or country if no city
|
||||
$city = $locationNameMap['city'][$search['city_id'] ?? null] ?? null;
|
||||
$district = $locationNameMap['district'][$search['district_id'] ?? null] ?? null;
|
||||
$division = $locationNameMap['division'][$search['division_id'] ?? null] ?? null;
|
||||
$country = $locationNameMap['country'][$search['country_id'] ?? null] ?? null;
|
||||
|
||||
$parts = [];
|
||||
if ($city) $parts[] = $city;
|
||||
if ($district && count($parts) < 2) $parts[] = $district;
|
||||
if ($division && count($parts) < 2) $parts[] = $division;
|
||||
if (!$city && $country) $parts[] = $country;
|
||||
|
||||
$search['location_label'] = preg_replace('/\s+,/', ',', implode(' ', $parts)) ?: null;
|
||||
|
||||
return $search;
|
||||
}, $raw);
|
||||
}
|
||||
|
||||
private function resolveLocationNames(array $entries, string $locale): array
|
||||
{
|
||||
$ids = ['country' => [], 'division' => [], 'city' => [], 'district' => []];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (!empty($entry['country_id'])) $ids['country'][] = $entry['country_id'];
|
||||
if (!empty($entry['division_id'])) $ids['division'][] = $entry['division_id'];
|
||||
if (!empty($entry['city_id'])) $ids['city'][] = $entry['city_id'];
|
||||
if (!empty($entry['district_id'])) $ids['district'][] = $entry['district_id'];
|
||||
}
|
||||
|
||||
$resolve = function ($model, array $uniqueIds) use ($locale): array {
|
||||
if (empty($uniqueIds)) return [];
|
||||
return $model::whereIn('id', array_unique($uniqueIds))
|
||||
->with(['translations' => fn($q) => $q->where('locale', $locale)])
|
||||
->get()
|
||||
->keyBy('id')
|
||||
->map(fn($r) => $r->translations->first()?->name ?? $r->name ?? null)
|
||||
->toArray();
|
||||
};
|
||||
|
||||
return [
|
||||
'country' => $resolve(Country::class, $ids['country']),
|
||||
'division' => $resolve(Division::class, $ids['division']),
|
||||
'city' => $resolve(City::class, $ids['city']),
|
||||
'district' => $resolve(District::class, $ids['district']),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.search-metrics');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user