phase 7
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ApiLog;
|
||||||
|
use App\Models\JobFetchLog;
|
||||||
|
use App\Services\MetricsService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MetricsService $metricsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the admin dashboard
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$days = $request->input('days', 7);
|
||||||
|
|
||||||
|
$metrics = [
|
||||||
|
'api' => $this->metricsService->getApiMetrics($days),
|
||||||
|
'fetch' => $this->metricsService->getJobFetchMetrics($days),
|
||||||
|
'analytics' => $this->metricsService->getAnalyticsMetrics($days),
|
||||||
|
'health' => $this->metricsService->getSystemHealth(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('admin.dashboard', compact('metrics', 'days'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display API logs
|
||||||
|
*/
|
||||||
|
public function apiLogs(Request $request)
|
||||||
|
{
|
||||||
|
$query = ApiLog::with('source')
|
||||||
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if ($request->has('source_id')) {
|
||||||
|
$query->where('job_source_id', $request->source_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('status')) {
|
||||||
|
$status = $request->status === 'success';
|
||||||
|
$query->where('success', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->paginate(50);
|
||||||
|
|
||||||
|
return view('admin.api-logs', compact('logs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display job fetch logs
|
||||||
|
*/
|
||||||
|
public function fetchLogs(Request $request)
|
||||||
|
{
|
||||||
|
$query = JobFetchLog::with('source')
|
||||||
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if ($request->has('source_id')) {
|
||||||
|
$query->where('job_source_id', $request->source_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('status')) {
|
||||||
|
$status = $request->status === 'success';
|
||||||
|
$query->where('success', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->paginate(50);
|
||||||
|
|
||||||
|
return view('admin.fetch-logs', compact('logs'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AnalyticsEvent;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\JobCategory;
|
use App\Models\JobCategory;
|
||||||
use App\Repositories\JobRepository;
|
use App\Repositories\JobRepository;
|
||||||
@@ -35,6 +36,11 @@ class JobController extends Controller
|
|||||||
'sort',
|
'sort',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Track search event if keywords are provided
|
||||||
|
if (!empty($filters['keywords'])) {
|
||||||
|
AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_SEARCH, null, $filters);
|
||||||
|
}
|
||||||
|
|
||||||
$perPage = $request->input('per_page', 20);
|
$perPage = $request->input('per_page', 20);
|
||||||
$jobs = $this->searchService->search($filters, $perPage);
|
$jobs = $this->searchService->search($filters, $perPage);
|
||||||
|
|
||||||
@@ -52,6 +58,9 @@ class JobController extends Controller
|
|||||||
$job = Job::with(['source', 'category', 'jobLocation'])
|
$job = Job::with(['source', 'category', 'jobLocation'])
|
||||||
->findOrFail($id);
|
->findOrFail($id);
|
||||||
|
|
||||||
|
// Track job view event
|
||||||
|
AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_JOB_VIEW, $job->id);
|
||||||
|
|
||||||
$similarJobs = $this->jobRepository->getSimilarJobs($job, 5);
|
$similarJobs = $this->jobRepository->getSimilarJobs($job, 5);
|
||||||
|
|
||||||
return view('jobs.show', compact('job', 'similarJobs'));
|
return view('jobs.show', compact('job', 'similarJobs'));
|
||||||
@@ -64,6 +73,9 @@ class JobController extends Controller
|
|||||||
{
|
{
|
||||||
$category = JobCategory::where('slug', $slug)->firstOrFail();
|
$category = JobCategory::where('slug', $slug)->firstOrFail();
|
||||||
|
|
||||||
|
// Track category view event
|
||||||
|
AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_CATEGORY_VIEW, null, ['category' => $category->name]);
|
||||||
|
|
||||||
$filters = $request->only([
|
$filters = $request->only([
|
||||||
'keywords',
|
'keywords',
|
||||||
'location',
|
'location',
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AnalyticsEvent extends Model
|
||||||
|
{
|
||||||
|
const UPDATED_AT = null; // Only created_at timestamp
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'event_type',
|
||||||
|
'job_id',
|
||||||
|
'session_id',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'referer',
|
||||||
|
'event_data',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'event_data' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Event types
|
||||||
|
const EVENT_JOB_VIEW = 'job_view';
|
||||||
|
const EVENT_JOB_APPLY_CLICK = 'job_apply_click';
|
||||||
|
const EVENT_SEARCH = 'search';
|
||||||
|
const EVENT_FILTER = 'filter';
|
||||||
|
const EVENT_CATEGORY_VIEW = 'category_view';
|
||||||
|
|
||||||
|
public function job(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Job::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get events by type
|
||||||
|
*/
|
||||||
|
public function scopeOfType($query, string $type)
|
||||||
|
{
|
||||||
|
return $query->where('event_type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get events within a date range
|
||||||
|
*/
|
||||||
|
public function scopeWithinDays($query, int $days)
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an event
|
||||||
|
*/
|
||||||
|
public static function logEvent(string $eventType, ?int $jobId = null, array $eventData = []): void
|
||||||
|
{
|
||||||
|
self::create([
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'job_id' => $jobId,
|
||||||
|
'session_id' => session()->getId(),
|
||||||
|
'ip_address' => request()->ip(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
'referer' => request()->header('referer'),
|
||||||
|
'event_data' => $eventData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ApiLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'job_source_id',
|
||||||
|
'endpoint',
|
||||||
|
'method',
|
||||||
|
'status_code',
|
||||||
|
'response_time_ms',
|
||||||
|
'success',
|
||||||
|
'error_message',
|
||||||
|
'request_params',
|
||||||
|
'response_data',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'success' => 'boolean',
|
||||||
|
'request_params' => 'array',
|
||||||
|
'response_data' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function source(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(JobSource::class, 'job_source_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get only successful logs
|
||||||
|
*/
|
||||||
|
public function scopeSuccessful($query)
|
||||||
|
{
|
||||||
|
return $query->where('success', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get only failed logs
|
||||||
|
*/
|
||||||
|
public function scopeFailed($query)
|
||||||
|
{
|
||||||
|
return $query->where('success', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get logs within a date range
|
||||||
|
*/
|
||||||
|
public function scopeWithinDays($query, int $days)
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class JobFetchLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'job_source_id',
|
||||||
|
'success',
|
||||||
|
'jobs_fetched',
|
||||||
|
'jobs_created',
|
||||||
|
'jobs_updated',
|
||||||
|
'jobs_skipped',
|
||||||
|
'duration_seconds',
|
||||||
|
'error_message',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'success' => 'boolean',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function source(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(JobSource::class, 'job_source_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get only successful logs
|
||||||
|
*/
|
||||||
|
public function scopeSuccessful($query)
|
||||||
|
{
|
||||||
|
return $query->where('success', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get only failed logs
|
||||||
|
*/
|
||||||
|
public function scopeFailed($query)
|
||||||
|
{
|
||||||
|
return $query->where('success', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to get logs within a date range
|
||||||
|
*/
|
||||||
|
public function scopeWithinDays($query, int $days)
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total jobs processed
|
||||||
|
*/
|
||||||
|
public function getTotalJobsProcessedAttribute(): int
|
||||||
|
{
|
||||||
|
return $this->jobs_created + $this->jobs_updated + $this->jobs_skipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ApiLog;
|
||||||
|
use App\Models\JobSource;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ApiClientService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Make an API request with logging and retry logic
|
||||||
|
*/
|
||||||
|
public function makeRequest(
|
||||||
|
JobSource $source,
|
||||||
|
string $endpoint,
|
||||||
|
string $method = 'GET',
|
||||||
|
array $params = [],
|
||||||
|
int $maxRetries = 3
|
||||||
|
): array {
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$attempt = 0;
|
||||||
|
$lastException = null;
|
||||||
|
|
||||||
|
while ($attempt < $maxRetries) {
|
||||||
|
$attempt++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->executeRequest($method, $endpoint, $params);
|
||||||
|
$responseTime = (int)((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// Log successful request
|
||||||
|
$this->logApiRequest($source, $endpoint, $method, $params, $response, $responseTime, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => $response->json(),
|
||||||
|
'status_code' => $response->status(),
|
||||||
|
'response_time' => $responseTime,
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$lastException = $e;
|
||||||
|
|
||||||
|
// Log failed attempt
|
||||||
|
Log::warning("API request failed (attempt {$attempt}/{$maxRetries})", [
|
||||||
|
'source' => $source->name,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($attempt < $maxRetries) {
|
||||||
|
// Exponential backoff: wait 2^attempt seconds
|
||||||
|
sleep(pow(2, $attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseTime = (int)((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// Log final failure
|
||||||
|
$this->logApiRequest(
|
||||||
|
$source,
|
||||||
|
$endpoint,
|
||||||
|
$method,
|
||||||
|
$params,
|
||||||
|
null,
|
||||||
|
$responseTime,
|
||||||
|
false,
|
||||||
|
$lastException->getMessage() ?? 'Unknown error'
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $lastException->getMessage() ?? 'Unknown error',
|
||||||
|
'response_time' => $responseTime,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the HTTP request
|
||||||
|
*/
|
||||||
|
private function executeRequest(string $method, string $endpoint, array $params): \Illuminate\Http\Client\Response
|
||||||
|
{
|
||||||
|
$http = Http::timeout(30)
|
||||||
|
->retry(1, 100)
|
||||||
|
->withHeaders([
|
||||||
|
'User-Agent' => 'JobifyGarden/1.0',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return match (strtoupper($method)) {
|
||||||
|
'GET' => $http->get($endpoint, $params),
|
||||||
|
'POST' => $http->post($endpoint, $params),
|
||||||
|
'PUT' => $http->put($endpoint, $params),
|
||||||
|
'DELETE' => $http->delete($endpoint, $params),
|
||||||
|
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log API request to database
|
||||||
|
*/
|
||||||
|
private function logApiRequest(
|
||||||
|
JobSource $source,
|
||||||
|
string $endpoint,
|
||||||
|
string $method,
|
||||||
|
array $params,
|
||||||
|
$response,
|
||||||
|
int $responseTime,
|
||||||
|
bool $success,
|
||||||
|
?string $errorMessage = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
ApiLog::create([
|
||||||
|
'job_source_id' => $source->id,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => $method,
|
||||||
|
'status_code' => $response?->status(),
|
||||||
|
'response_time_ms' => $responseTime,
|
||||||
|
'success' => $success,
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
'request_params' => !empty($params) ? $params : null,
|
||||||
|
'response_data' => $success && $response ? [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'size' => strlen($response->body()),
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to log API request', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if source is rate limited
|
||||||
|
*/
|
||||||
|
public function isRateLimited(JobSource $source): bool
|
||||||
|
{
|
||||||
|
if (!$source->rate_limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check requests in the last hour
|
||||||
|
$recentRequests = ApiLog::where('job_source_id', $source->id)
|
||||||
|
->where('created_at', '>=', now()->subHour())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return $recentRequests >= $source->rate_limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit info for a source
|
||||||
|
*/
|
||||||
|
public function getRateLimitInfo(JobSource $source): array
|
||||||
|
{
|
||||||
|
$recentRequests = ApiLog::where('job_source_id', $source->id)
|
||||||
|
->where('created_at', '>=', now()->subHour())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $source->rate_limit,
|
||||||
|
'used' => $recentRequests,
|
||||||
|
'remaining' => max(0, $source->rate_limit - $recentRequests),
|
||||||
|
'reset_at' => now()->addHour()->startOfHour(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ApiLog;
|
||||||
|
use App\Models\AnalyticsEvent;
|
||||||
|
use App\Models\JobFetchLog;
|
||||||
|
use App\Models\JobSource;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class MetricsService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get API performance metrics
|
||||||
|
*/
|
||||||
|
public function getApiMetrics(int $days = 7): array
|
||||||
|
{
|
||||||
|
$startDate = now()->subDays($days);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_requests' => ApiLog::where('created_at', '>=', $startDate)->count(),
|
||||||
|
'successful_requests' => ApiLog::successful()->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'failed_requests' => ApiLog::failed()->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'average_response_time' => ApiLog::where('created_at', '>=', $startDate)
|
||||||
|
->whereNotNull('response_time_ms')
|
||||||
|
->avg('response_time_ms'),
|
||||||
|
'success_rate' => $this->calculateSuccessRate($days),
|
||||||
|
'by_source' => $this->getApiMetricsBySource($days),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job fetch metrics
|
||||||
|
*/
|
||||||
|
public function getJobFetchMetrics(int $days = 7): array
|
||||||
|
{
|
||||||
|
$startDate = now()->subDays($days);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_fetches' => JobFetchLog::where('created_at', '>=', $startDate)->count(),
|
||||||
|
'successful_fetches' => JobFetchLog::successful()->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'failed_fetches' => JobFetchLog::failed()->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'total_jobs_fetched' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_fetched'),
|
||||||
|
'total_jobs_created' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_created'),
|
||||||
|
'total_jobs_updated' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_updated'),
|
||||||
|
'total_jobs_skipped' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_skipped'),
|
||||||
|
'average_duration' => JobFetchLog::where('created_at', '>=', $startDate)
|
||||||
|
->whereNotNull('duration_seconds')
|
||||||
|
->avg('duration_seconds'),
|
||||||
|
'by_source' => $this->getJobFetchMetricsBySource($days),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get analytics metrics
|
||||||
|
*/
|
||||||
|
public function getAnalyticsMetrics(int $days = 7): array
|
||||||
|
{
|
||||||
|
$startDate = now()->subDays($days);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_events' => AnalyticsEvent::where('created_at', '>=', $startDate)->count(),
|
||||||
|
'job_views' => AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_VIEW)
|
||||||
|
->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'job_apply_clicks' => AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_APPLY_CLICK)
|
||||||
|
->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'searches' => AnalyticsEvent::ofType(AnalyticsEvent::EVENT_SEARCH)
|
||||||
|
->where('created_at', '>=', $startDate)->count(),
|
||||||
|
'unique_sessions' => AnalyticsEvent::where('created_at', '>=', $startDate)
|
||||||
|
->distinct('session_id')->count('session_id'),
|
||||||
|
'click_through_rate' => $this->calculateClickThroughRate($days),
|
||||||
|
'popular_jobs' => $this->getPopularJobs($days),
|
||||||
|
'popular_searches' => $this->getPopularSearches($days),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system health metrics
|
||||||
|
*/
|
||||||
|
public function getSystemHealth(): array
|
||||||
|
{
|
||||||
|
$recentFailures = ApiLog::failed()
|
||||||
|
->where('created_at', '>=', now()->subHours(1))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$recentFetchFailures = JobFetchLog::failed()
|
||||||
|
->where('created_at', '>=', now()->subHours(24))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $this->determineSystemStatus($recentFailures, $recentFetchFailures),
|
||||||
|
'api_failures_last_hour' => $recentFailures,
|
||||||
|
'fetch_failures_last_24h' => $recentFetchFailures,
|
||||||
|
'last_successful_fetch' => JobFetchLog::successful()
|
||||||
|
->latest()
|
||||||
|
->first()?->created_at,
|
||||||
|
'sources_status' => $this->getSourcesStatus(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate API success rate
|
||||||
|
*/
|
||||||
|
private function calculateSuccessRate(int $days): float
|
||||||
|
{
|
||||||
|
$total = ApiLog::withinDays($days)->count();
|
||||||
|
if ($total === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$successful = ApiLog::successful()->withinDays($days)->count();
|
||||||
|
return round(($successful / $total) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API metrics by source
|
||||||
|
*/
|
||||||
|
private function getApiMetricsBySource(int $days): array
|
||||||
|
{
|
||||||
|
return ApiLog::select('job_source_id')
|
||||||
|
->selectRaw('COUNT(*) as total')
|
||||||
|
->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful')
|
||||||
|
->selectRaw('AVG(response_time_ms) as avg_response_time')
|
||||||
|
->withinDays($days)
|
||||||
|
->with('source:id,name')
|
||||||
|
->groupBy('job_source_id')
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'source' => $item->source->name ?? 'Unknown',
|
||||||
|
'total_requests' => $item->total,
|
||||||
|
'successful' => $item->successful,
|
||||||
|
'success_rate' => $item->total > 0 ? round(($item->successful / $item->total) * 100, 2) : 0,
|
||||||
|
'avg_response_time' => round($item->avg_response_time, 2),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job fetch metrics by source
|
||||||
|
*/
|
||||||
|
private function getJobFetchMetricsBySource(int $days): array
|
||||||
|
{
|
||||||
|
return JobFetchLog::select('job_source_id')
|
||||||
|
->selectRaw('COUNT(*) as total_fetches')
|
||||||
|
->selectRaw('SUM(jobs_fetched) as jobs_fetched')
|
||||||
|
->selectRaw('SUM(jobs_created) as jobs_created')
|
||||||
|
->selectRaw('SUM(jobs_updated) as jobs_updated')
|
||||||
|
->selectRaw('AVG(duration_seconds) as avg_duration')
|
||||||
|
->withinDays($days)
|
||||||
|
->with('source:id,name')
|
||||||
|
->groupBy('job_source_id')
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'source' => $item->source->name ?? 'Unknown',
|
||||||
|
'total_fetches' => $item->total_fetches,
|
||||||
|
'jobs_fetched' => $item->jobs_fetched,
|
||||||
|
'jobs_created' => $item->jobs_created,
|
||||||
|
'jobs_updated' => $item->jobs_updated,
|
||||||
|
'avg_duration' => round($item->avg_duration, 2),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate click-through rate
|
||||||
|
*/
|
||||||
|
private function calculateClickThroughRate(int $days): float
|
||||||
|
{
|
||||||
|
$views = AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_VIEW)
|
||||||
|
->withinDays($days)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($views === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clicks = AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_APPLY_CLICK)
|
||||||
|
->withinDays($days)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return round(($clicks / $views) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get popular jobs
|
||||||
|
*/
|
||||||
|
private function getPopularJobs(int $days, int $limit = 10): array
|
||||||
|
{
|
||||||
|
return AnalyticsEvent::select('job_id')
|
||||||
|
->selectRaw('COUNT(*) as view_count')
|
||||||
|
->selectRaw('SUM(CASE WHEN event_type = ? THEN 1 ELSE 0 END) as apply_clicks', [AnalyticsEvent::EVENT_JOB_APPLY_CLICK])
|
||||||
|
->whereNotNull('job_id')
|
||||||
|
->withinDays($days)
|
||||||
|
->with('job:id,title,company')
|
||||||
|
->groupBy('job_id')
|
||||||
|
->orderByDesc('view_count')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'job_id' => $item->job_id,
|
||||||
|
'title' => $item->job->title ?? 'Unknown',
|
||||||
|
'company' => $item->job->company ?? 'Unknown',
|
||||||
|
'views' => $item->view_count,
|
||||||
|
'apply_clicks' => $item->apply_clicks,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get popular search terms
|
||||||
|
*/
|
||||||
|
private function getPopularSearches(int $days, int $limit = 10): array
|
||||||
|
{
|
||||||
|
return AnalyticsEvent::select(DB::raw('JSON_EXTRACT(event_data, "$.keywords") as search_term'))
|
||||||
|
->selectRaw('COUNT(*) as search_count')
|
||||||
|
->ofType(AnalyticsEvent::EVENT_SEARCH)
|
||||||
|
->withinDays($days)
|
||||||
|
->whereNotNull(DB::raw('JSON_EXTRACT(event_data, "$.keywords")'))
|
||||||
|
->groupBy('search_term')
|
||||||
|
->orderByDesc('search_count')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'term' => trim($item->search_term, '"'),
|
||||||
|
'count' => $item->search_count,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine system status
|
||||||
|
*/
|
||||||
|
private function determineSystemStatus(int $apiFailures, int $fetchFailures): string
|
||||||
|
{
|
||||||
|
if ($apiFailures > 10 || $fetchFailures > 5) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apiFailures > 5 || $fetchFailures > 2) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status of all sources
|
||||||
|
*/
|
||||||
|
private function getSourcesStatus(): array
|
||||||
|
{
|
||||||
|
return JobSource::get()->map(function ($source) {
|
||||||
|
$lastFetch = JobFetchLog::where('job_source_id', $source->id)
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $source->id,
|
||||||
|
'name' => $source->name,
|
||||||
|
'is_active' => $source->is_active,
|
||||||
|
'last_fetch_success' => $lastFetch?->success ?? null,
|
||||||
|
'last_fetch_at' => $lastFetch?->created_at ?? null,
|
||||||
|
'jobs_in_last_fetch' => $lastFetch?->jobs_created ?? 0,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use App\Models\JobFetchLog;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
trait HandlesJobErrors
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle job fetch errors gracefully
|
||||||
|
*/
|
||||||
|
protected function handleFetchError(
|
||||||
|
int $sourceId,
|
||||||
|
\Exception $exception,
|
||||||
|
array $metadata = []
|
||||||
|
): void {
|
||||||
|
// Log the error
|
||||||
|
Log::error('Job fetch failed', [
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
'trace' => $exception->getTraceAsString(),
|
||||||
|
'metadata' => $metadata,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Record in database
|
||||||
|
JobFetchLog::create([
|
||||||
|
'job_source_id' => $sourceId,
|
||||||
|
'success' => false,
|
||||||
|
'jobs_fetched' => 0,
|
||||||
|
'jobs_created' => 0,
|
||||||
|
'jobs_updated' => 0,
|
||||||
|
'jobs_skipped' => 0,
|
||||||
|
'error_message' => $exception->getMessage(),
|
||||||
|
'metadata' => array_merge($metadata, [
|
||||||
|
'exception_class' => get_class($exception),
|
||||||
|
'file' => $exception->getFile(),
|
||||||
|
'line' => $exception->getLine(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if we need to disable the source due to repeated failures
|
||||||
|
$this->checkAndDisableSource($sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for repeated failures and disable source if necessary
|
||||||
|
*/
|
||||||
|
protected function checkAndDisableSource(int $sourceId): void
|
||||||
|
{
|
||||||
|
$recentFailures = JobFetchLog::where('job_source_id', $sourceId)
|
||||||
|
->where('success', false)
|
||||||
|
->where('created_at', '>=', now()->subHours(24))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Disable source after 5 consecutive failures in 24 hours
|
||||||
|
if ($recentFailures >= 5) {
|
||||||
|
$source = \App\Models\JobSource::find($sourceId);
|
||||||
|
if ($source && $source->is_active) {
|
||||||
|
$source->update(['is_active' => false]);
|
||||||
|
|
||||||
|
Log::warning("Source {$source->name} has been disabled due to repeated failures", [
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'failures' => $recentFailures,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// You could send a notification to admins here
|
||||||
|
$this->notifyAdmins($source, $recentFailures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log successful job fetch
|
||||||
|
*/
|
||||||
|
protected function logSuccessfulFetch(
|
||||||
|
int $sourceId,
|
||||||
|
int $jobsFetched,
|
||||||
|
int $jobsCreated,
|
||||||
|
int $jobsUpdated,
|
||||||
|
int $jobsSkipped,
|
||||||
|
int $durationSeconds,
|
||||||
|
array $metadata = []
|
||||||
|
): void {
|
||||||
|
JobFetchLog::create([
|
||||||
|
'job_source_id' => $sourceId,
|
||||||
|
'success' => true,
|
||||||
|
'jobs_fetched' => $jobsFetched,
|
||||||
|
'jobs_created' => $jobsCreated,
|
||||||
|
'jobs_updated' => $jobsUpdated,
|
||||||
|
'jobs_skipped' => $jobsSkipped,
|
||||||
|
'duration_seconds' => $durationSeconds,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Job fetch completed successfully', [
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'jobs_fetched' => $jobsFetched,
|
||||||
|
'jobs_created' => $jobsCreated,
|
||||||
|
'jobs_updated' => $jobsUpdated,
|
||||||
|
'duration' => $durationSeconds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify admins about source failure (placeholder)
|
||||||
|
*/
|
||||||
|
protected function notifyAdmins($source, int $failureCount): void
|
||||||
|
{
|
||||||
|
// TODO: Implement notification logic
|
||||||
|
// Could use Laravel notifications, email, Slack, etc.
|
||||||
|
Log::critical("ADMIN ALERT: Source {$source->name} disabled after {$failureCount} failures");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('api_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('job_source_id')->nullable()->constrained()->onDelete('cascade');
|
||||||
|
$table->string('endpoint');
|
||||||
|
$table->string('method', 10);
|
||||||
|
$table->integer('status_code')->nullable();
|
||||||
|
$table->integer('response_time_ms')->nullable();
|
||||||
|
$table->boolean('success')->default(false);
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->json('request_params')->nullable();
|
||||||
|
$table->json('response_data')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['job_source_id', 'created_at']);
|
||||||
|
$table->index(['success', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('api_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('job_fetch_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('job_source_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->boolean('success')->default(false);
|
||||||
|
$table->integer('jobs_fetched')->default(0);
|
||||||
|
$table->integer('jobs_created')->default(0);
|
||||||
|
$table->integer('jobs_updated')->default(0);
|
||||||
|
$table->integer('jobs_skipped')->default(0);
|
||||||
|
$table->integer('duration_seconds')->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['job_source_id', 'created_at']);
|
||||||
|
$table->index(['success', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('job_fetch_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('analytics_events', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('event_type', 50); // job_view, job_apply_click, search, etc.
|
||||||
|
$table->foreignId('job_id')->nullable()->constrained()->onDelete('cascade');
|
||||||
|
$table->string('session_id', 100)->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('user_agent')->nullable();
|
||||||
|
$table->string('referer')->nullable();
|
||||||
|
$table->json('event_data')->nullable(); // Additional event-specific data
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
|
||||||
|
$table->index(['event_type', 'created_at']);
|
||||||
|
$table->index(['job_id', 'event_type', 'created_at']);
|
||||||
|
$table->index('session_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('analytics_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Admin Dashboard - JobifyGarden')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{{ route('admin.logs.api') }}" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md">API Logs</a>
|
||||||
|
<a href="{{ route('admin.logs.fetch') }}" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md">Fetch Logs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Period Selector -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="text-sm font-medium text-gray-700 mr-2">Time Period:</label>
|
||||||
|
<select onchange="window.location.href='?days=' + this.value" class="px-3 py-2 border border-gray-300 rounded-md">
|
||||||
|
<option value="1" {{ $days == 1 ? 'selected' : '' }}>Last 24 hours</option>
|
||||||
|
<option value="7" {{ $days == 7 ? 'selected' : '' }}>Last 7 days</option>
|
||||||
|
<option value="30" {{ $days == 30 ? 'selected' : '' }}>Last 30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Health -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">System Health</h2>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="text-4xl mr-4">
|
||||||
|
@if($metrics['health']['status'] === 'healthy')
|
||||||
|
<span class="text-green-500">✓</span>
|
||||||
|
@elseif($metrics['health']['status'] === 'warning')
|
||||||
|
<span class="text-yellow-500">⚠</span>
|
||||||
|
@else
|
||||||
|
<span class="text-red-500">✗</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold capitalize {{ $metrics['health']['status'] === 'healthy' ? 'text-green-600' : ($metrics['health']['status'] === 'warning' ? 'text-yellow-600' : 'text-red-600') }}">
|
||||||
|
{{ $metrics['health']['status'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
API Failures (1h): {{ $metrics['health']['api_failures_last_hour'] }} |
|
||||||
|
Fetch Failures (24h): {{ $metrics['health']['fetch_failures_last_24h'] }}
|
||||||
|
</div>
|
||||||
|
@if($metrics['health']['last_successful_fetch'])
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
Last successful fetch: {{ $metrics['health']['last_successful_fetch']->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Metrics -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">API Performance</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Total Requests</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ number_format($metrics['api']['total_requests']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Success Rate</div>
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ $metrics['api']['success_rate'] }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Failed Requests</div>
|
||||||
|
<div class="text-2xl font-bold text-red-600">{{ number_format($metrics['api']['failed_requests']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Avg Response Time</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ round($metrics['api']['average_response_time']) }}ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Fetch Metrics -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Job Fetching</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Total Fetches</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ number_format($metrics['fetch']['total_fetches']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Jobs Created</div>
|
||||||
|
<div class="text-2xl font-bold text-emerald-600">{{ number_format($metrics['fetch']['total_jobs_created']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Jobs Updated</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ number_format($metrics['fetch']['total_jobs_updated']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Avg Duration</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ round($metrics['fetch']['average_duration']) }}s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(!empty($metrics['fetch']['by_source']))
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-4">By Source</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Source</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Fetches</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Updated</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Avg Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach($metrics['fetch']['by_source'] as $source)
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 text-sm text-gray-900">{{ $source['source'] }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-gray-900">{{ number_format($source['total_fetches']) }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-emerald-600">{{ number_format($source['jobs_created']) }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-blue-600">{{ number_format($source['jobs_updated']) }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-gray-900">{{ $source['avg_duration'] }}s</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics Metrics -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">User Analytics</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Job Views</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ number_format($metrics['analytics']['job_views']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Apply Clicks</div>
|
||||||
|
<div class="text-2xl font-bold text-emerald-600">{{ number_format($metrics['analytics']['job_apply_clicks']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">CTR</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ $metrics['analytics']['click_through_rate'] }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||||
|
<div class="text-sm text-gray-600">Unique Sessions</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ number_format($metrics['analytics']['unique_sessions']) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Popular Jobs -->
|
||||||
|
@if(!empty($metrics['analytics']['popular_jobs']))
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-4">Most Viewed Jobs</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($metrics['analytics']['popular_jobs'] as $job)
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ $job['title'] }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ $job['company'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right ml-4">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ $job['views'] }} views</div>
|
||||||
|
<div class="text-xs text-emerald-600">{{ $job['apply_clicks'] }} applies</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Popular Searches -->
|
||||||
|
@if(!empty($metrics['analytics']['popular_searches']))
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-4">Popular Searches</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($metrics['analytics']['popular_searches'] as $search)
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ $search['term'] }}</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ $search['count'] }} searches</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Status -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Job Sources Status</h2>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
@foreach($metrics['health']['sources_status'] as $source)
|
||||||
|
<div class="border rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h4 class="font-semibold text-gray-900">{{ $source['name'] }}</h4>
|
||||||
|
<span class="px-2 py-1 rounded text-xs {{ $source['is_active'] ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
|
||||||
|
{{ $source['is_active'] ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if($source['last_fetch_at'])
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
Last fetch: {{ \Carbon\Carbon::parse($source['last_fetch_at'])->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs {{ $source['last_fetch_success'] ? 'text-green-600' : 'text-red-600' }}">
|
||||||
|
{{ $source['last_fetch_success'] ? 'Success' : 'Failed' }} | {{ $source['jobs_in_last_fetch'] }} jobs
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-xs text-gray-500">No fetches yet</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Web\HomeController;
|
use App\Http\Controllers\Web\HomeController;
|
||||||
use App\Http\Controllers\Web\JobController;
|
use App\Http\Controllers\Web\JobController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -14,3 +15,10 @@ Route::prefix('jobs')->group(function () {
|
|||||||
Route::get('/category/{slug}', [JobController::class, 'category'])->name('jobs.category');
|
Route::get('/category/{slug}', [JobController::class, 'category'])->name('jobs.category');
|
||||||
Route::get('/{id}', [JobController::class, 'show'])->name('jobs.show');
|
Route::get('/{id}', [JobController::class, 'show'])->name('jobs.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin routes (in production, protect these with authentication middleware)
|
||||||
|
Route::prefix('admin')->name('admin.')->group(function () {
|
||||||
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
Route::get('/logs/api', [DashboardController::class, 'apiLogs'])->name('logs.api');
|
||||||
|
Route::get('/logs/fetch', [DashboardController::class, 'fetchLogs'])->name('logs.fetch');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user