This commit is contained in:
Doar Mihai
2026-04-03 20:41:35 +03:00
parent 19d5e23dfd
commit a8a48f3f6f
13 changed files with 1185 additions and 0 deletions
@@ -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',
+70
View File
@@ -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,
]);
}
}
+56
View File
@@ -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));
}
}
+63
View File
@@ -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;
}
}
+171
View File
@@ -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(),
];
}
}
+274
View File
@@ -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();
}
}
+114
View File
@@ -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');
}
};
+224
View File
@@ -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
+8
View File
@@ -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');
});