feat: Add visitor tracking functionality with Filament resource and footer statistics.

This commit is contained in:
Yoga Pangestu 2026-01-06 12:01:50 +07:00
parent a4f85c53c5
commit 9742097d2b
12 changed files with 544 additions and 13 deletions

View File

@ -0,0 +1,13 @@
<?php
namespace App\Filament\Resources\Visitors\Pages;
use App\Filament\Resources\Visitors\VisitorResource;
use Filament\Resources\Pages\ManageRecords;
class ManageVisitors extends ManageRecords
{
protected static ?string $title = 'Pengunjung';
protected static string $resource = VisitorResource::class;
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Filament\Resources\Visitors;
use App\Filament\Resources\Visitors\Pages\ManageVisitors;
use App\Models\Visitor;
use BackedEnum;
use Filament\Forms\Components\DatePicker;
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use UnitEnum;
class VisitorResource extends Resource
{
protected static ?string $model = Visitor::class;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static string|BackedEnum|null $navigationIcon = Heroicon::ChartBar;
protected static ?string $navigationLabel = 'Pengunjung';
protected static ?int $navigationSort = 7;
protected static ?string $slug = 'monitoring/visitors';
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('date')
->label('Tanggal & Waktu')
->date('l, d F Y')
->sortable(),
TextColumn::make('route')
->label('Halaman')
->searchable()
->sortable(),
TextColumn::make('method')
->label('Method')
->badge()
->color(fn (string $state): string => match ($state) {
'GET' => 'success',
'POST' => 'warning',
default => 'gray',
}),
TextColumn::make('status')
->label('Status')
->badge()
->color(fn (int $state): string => $state >= 200 && $state < 300 ? 'success' : 'danger'),
TextColumn::make('ip')
->label('IP Address')
->searchable(),
TextColumn::make('counter')
->label('Hits')
->numeric()
->sortable(),
TextColumn::make('user.name')
->label('User')
->placeholder('Guest'),
])
->defaultSort('date', 'desc')
->filters([
SelectFilter::make('method')
->options([
'GET' => 'GET',
'POST' => 'POST',
]),
Filter::make('date')
->schema([
DatePicker::make('from')->time(),
DatePicker::make('until')->time(),
])
->query(function ($query, array $data) {
return $query
->when($data['from'], fn ($q) => $q->whereDate('date', '>=', $data['from']))
->when($data['until'], fn ($q) => $q->whereDate('date', '<=', $data['until']));
}),
])
->recordActions([])
->toolbarActions([]);
}
public static function getPages(): array
{
return [
'index' => ManageVisitors::route('/'),
];
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use App\Models\Visitor;
use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TrackVisitor
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Only track GET requests that are successful and not Livewire internal requests
if ($request->method() === 'GET' && $response->getStatusCode() === 200 && ! $request->header('X-Livewire')) {
$ip = $request->ip();
$route = $request->route() ? $request->route()->getName() : $request->path();
// Skip if route is not defined or route is Livewire
if (! $route || str_contains($route, 'livewire')) {
return $response;
}
$today = Carbon::today();
// Check if IP has already visited this route today
$alreadyVisited = Visitor::where('ip', $ip)
->where('route', $route)
->whereDate('date', $today)
->exists();
if (! $alreadyVisited) {
Visitor::create([
'user_id' => auth()->id(),
'method' => $request->method(),
'route' => $route,
'status' => $response->getStatusCode(),
'ip' => $ip,
'date' => Carbon::now(),
'counter' => 1, // Set 1 because this is the first unique visit today
]);
}
}
return $response;
}
}

View File

@ -76,4 +76,9 @@ public function verificationReviews(): HasMany
{
return $this->hasMany(VerificationReview::class, 'reviewer_id');
}
public function visitors(): HasMany
{
return $this->hasMany(Visitor::class);
}
}

16
app/Models/Visitor.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Bilfeldt\LaravelRouteStatistics\Models\RouteStatistic as BaseRouteStatistic;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Visitor extends BaseRouteStatistic
{
protected $table = 'visitors';
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -2,9 +2,11 @@
namespace App\Providers;
use App\Models\Visitor;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
use App\Settings\SocialMediaSettings;
use Carbon\Carbon;
use Illuminate\Support\ServiceProvider;
class ViewServiceProvider extends ServiceProvider
@ -25,5 +27,23 @@ public function boot(): void
view()->share('general', app(GeneralSettings::class));
view()->share('social', app(SocialMediaSettings::class));
view()->share('seo', app(SeoSettings::class));
view()->composer('components.partials.footer', function ($view) {
$routeStatisticModel = config('route-statistics.model', Visitor::class);
$today = $routeStatisticModel::whereDate('date', Carbon::today())->sum('counter');
$yesterday = $routeStatisticModel::whereDate('date', Carbon::yesterday())->sum('counter');
$thisMonth = $routeStatisticModel::whereMonth('date', Carbon::now()->month)
->whereYear('date', Carbon::now()->year)
->sum('counter');
$total = $routeStatisticModel::sum('counter');
$view->with('visitorStats', [
'today' => number_format($today),
'yesterday' => number_format($yesterday),
'thisMonth' => number_format($thisMonth),
'total' => number_format($total),
]);
});
}
}

View File

@ -12,6 +12,7 @@
"php": "^8.2",
"asmit/filament-upload": "^1.1",
"bezhansalleh/filament-shield": "^4.0",
"bilfeldt/laravel-route-statistics": "^4.2",
"filament/filament": "^4.0",
"filament/spatie-laravel-media-library-plugin": "^4.0",
"filament/spatie-laravel-settings-plugin": "^4.4",

210
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "37c52f60c416d783d4922313800eb7b5",
"content-hash": "a8225cac91593cfe6465c7be71fbbdc6",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -292,6 +292,214 @@
],
"time": "2025-12-26T09:31:27+00:00"
},
{
"name": "bilfeldt/laravel-correlation-id",
"version": "v1.5.0",
"source": {
"type": "git",
"url": "https://github.com/bilfeldt/laravel-correlation-id.git",
"reference": "0a12cddc777d84529b798f5c1105bbd227c7e2e8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bilfeldt/laravel-correlation-id/zipball/0a12cddc777d84529b798f5c1105bbd227c7e2e8",
"reference": "0a12cddc777d84529b798f5c1105bbd227c7e2e8",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0 || ^11.0 || ^12.0",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"nunomaduro/collision": "^7.8 || ^8.0",
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
"phpunit/phpunit": "^10.0 || ^11.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Bilfeldt\\CorrelationId\\CorrelationIdServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Bilfeldt\\CorrelationId\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Anders Bilfeldt",
"email": "abilfeldt@gmail.com",
"role": "Developer"
}
],
"description": "Deal with Request-ID and Correlation-ID in Laravel applications",
"homepage": "https://github.com/bilfeldt/laravel-correlation-id",
"keywords": [
"bilfeldt",
"correlation id",
"request id"
],
"support": {
"issues": "https://github.com/bilfeldt/laravel-correlation-id/issues",
"source": "https://github.com/bilfeldt/laravel-correlation-id"
},
"funding": [
{
"url": "https://github.com/bilfeldt",
"type": "github"
}
],
"time": "2025-11-28T18:40:34+00:00"
},
{
"name": "bilfeldt/laravel-request-logger",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/bilfeldt/laravel-request-logger.git",
"reference": "40c22bbddc469a38dee1a4574bcd809ada352813"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bilfeldt/laravel-request-logger/zipball/40c22bbddc469a38dee1a4574bcd809ada352813",
"reference": "40c22bbddc469a38dee1a4574bcd809ada352813",
"shasum": ""
},
"require": {
"bilfeldt/laravel-correlation-id": "^1.0",
"ext-json": "*",
"illuminate/contracts": "^10.0 || ^11.0 || ^12.0",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"nunomaduro/collision": "^7.2 || ^8.0",
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
"phpunit/phpunit": "^10.0 || ^11.5.3",
"spatie/laravel-ray": "^1.32"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"RequestLogger": "Bilfeldt\\RequestLogger\\RequestLoggerFacade"
},
"providers": [
"Bilfeldt\\RequestLogger\\RequestLoggerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Bilfeldt\\RequestLogger\\": "src",
"Bilfeldt\\RequestLogger\\Database\\Factories\\": "database/factories"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Anders Bilfeldt",
"email": "abilfeldt@gmail.com",
"role": "Developer"
}
],
"description": "Log Laravel application request and responses for debugging or statistics",
"homepage": "https://github.com/bilfeldt/laravel-request-logger",
"keywords": [
"bilfeldt",
"laravel",
"laravel-request-logger"
],
"support": {
"issues": "https://github.com/bilfeldt/laravel-route-statistics/issues",
"source": "https://github.com/bilfeldt/laravel-route-statistics"
},
"funding": [
{
"url": "https://github.com/bilfeldt",
"type": "github"
}
],
"time": "2025-02-17T09:51:14+00:00"
},
{
"name": "bilfeldt/laravel-route-statistics",
"version": "v4.2.1",
"source": {
"type": "git",
"url": "https://github.com/bilfeldt/laravel-route-statistics.git",
"reference": "9b314244248f43615049f102ba563ed5b7858db0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bilfeldt/laravel-route-statistics/zipball/9b314244248f43615049f102ba563ed5b7858db0",
"reference": "9b314244248f43615049f102ba563ed5b7858db0",
"shasum": ""
},
"require": {
"bilfeldt/laravel-request-logger": "^3.0",
"illuminate/contracts": "^10.0 || ^11.0 || ^12.0",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"nunomaduro/collision": "^7.2 || ^8.0",
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
"phpunit/phpunit": "^10.0 || ^11.5.3"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Bilfeldt\\LaravelRouteStatistics\\LaravelRouteStatisticsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Bilfeldt\\LaravelRouteStatistics\\": "src",
"Bilfeldt\\LaravelRouteStatistics\\Database\\Factories\\": "database/factories"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Anders Bilfeldt",
"email": "abilfeldt@gmail.com",
"role": "Developer"
}
],
"description": "Log statistics about route usage per user/team",
"homepage": "https://github.com/bilfeldt/laravel-route-statistics",
"keywords": [
"bilfeldt",
"laravel",
"route",
"statistics"
],
"support": {
"issues": "https://github.com/bilfeldt/laravel-route-statistics/issues",
"source": "https://github.com/bilfeldt/laravel-route-statistics"
},
"funding": [
{
"url": "https://github.com/bilfeldt",
"type": "github"
}
],
"time": "2025-04-12T20:18:06+00:00"
},
{
"name": "blade-ui-kit/blade-heroicons",
"version": "2.6.0",

View File

@ -0,0 +1,74 @@
<?php
use App\Models\Visitor;
return [
/*
|--------------------------------------------------------------------------
| Enabled
|--------------------------------------------------------------------------
|
| This is a 'master' switch to enable/disable logging. Setting this to
| false will disable all logging.
|
*/
'enabled' => env('ROUTE_STATISTICS_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Store parameters
|--------------------------------------------------------------------------
|
| If this setting is set to true the route parameters will also be logged.
|
*/
'store_route_parameters' => env('ROUTE_STATISTICS_STORE_ROUTE_PARAMETERS', false),
/*
|--------------------------------------------------------------------------
| Aggregation
|--------------------------------------------------------------------------
|
| This setting controls how we should aggregate requests.
| Possible values are: SECOND, MINUTE, HOUR, DAY, MONTH, YEAR
|
*/
'aggregate' => env('ROUTE_STATISTICS_AGGREGATE', 'DAY'),
/*
|--------------------------------------------------------------------------
| Model
|--------------------------------------------------------------------------
|
| This is the model used to store request statistics.
| It is possible to implement a custom model which extends the default model
| or alternatively implement a completely new model which implements
| Bilfeldt\RequestLogger\Contracts\RequestLoggerInterface
|
*/
'model' => env('ROUTE_STATISTICS_MODEL', Visitor::class),
/*
|--------------------------------------------------------------------------
| Store Strategy
|--------------------------------------------------------------------------
|
| If you set this to true, the Logs stored in the database using queues
| It is good when you have large database
|
*/
'queued' => env('ROUTE_STATISTICS_QUEUED', false),
/*
|--------------------------------------------------------------------------
| User Model
|--------------------------------------------------------------------------
|
| This is the model used for user relationships.
| You can set a custom user model for relationships.
|
| Leaving this empty will use the model from the 'users' auth provider.
|
*/
'user_model' => env('ROUTE_STATISTICS_USER_MODEL'),
];

View File

@ -0,0 +1,35 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('visitors', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->nullable()->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('team_id')->nullable(); // Can be changed to the following if your application uses teams: $table->foreignId('team_id')->nullable()->constrained();
$table->string('method')->nullable();
$table->string('route')->nullable();
$table->json('parameters')->nullable();
$table->integer('status')->nullable();
$table->ipAddress('ip')->nullable();
$table->timestamp('date');
$table->unsignedInteger('counter');
$table->index('date');
$table->index(['user_id', 'date', 'route', 'method']);
$table->index(['team_id', 'date', 'route', 'method']);
$table->index(['route', 'method', 'date']);
});
}
public function down()
{
Schema::dropIfExists('visitors');
}
};

View File

@ -82,16 +82,16 @@ class="col-12 col-xl-4 col-lg-4 col-sm-6 last-paragraph-no-margin text-xl-start
<!-- Samping Kanan (Paling Kanan): Statistik Pengunjung -->
<div class="col-6 col-xl-3 col-lg-2 col-sm-4 xs-mb-30px">
<span class="fw-600 d-block text-dark-gray mb-10px">Statistik Pengunjung</span>
<span class="fw-600 d-block text-dark-gray mb-10px">Statistik Visitor</span>
<ul class="list-style-01">
<li class="border-color-extra-medium-gray pt-5px pb-5px">Hari ini: <span
class="fw-600 text-dark-gray float-end">123</span></li>
class="fw-600 text-dark-gray float-end">{{ $visitorStats['today'] }}</span></li>
<li class="border-color-extra-medium-gray pt-5px pb-5px">Kemarin: <span
class="fw-600 text-dark-gray float-end">456</span></li>
class="fw-600 text-dark-gray float-end">{{ $visitorStats['yesterday'] }}</span></li>
<li class="border-color-extra-medium-gray pt-5px pb-5px">Bulan ini: <span
class="fw-600 text-dark-gray float-end">7,890</span></li>
class="fw-600 text-dark-gray float-end">{{ $visitorStats['thisMonth'] }}</span></li>
<li class="border-color-extra-medium-gray pt-5px pb-5px">Total: <span
class="fw-600 text-dark-gray float-end">12,345</span></li>
class="fw-600 text-dark-gray float-end">{{ $visitorStats['total'] }}</span></li>
</ul>
</div>
</div>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\TrackVisitor;
use App\Livewire\Home\AboutUs;
use App\Livewire\Home\Announcement;
use App\Livewire\Home\Index;
@ -7,12 +8,14 @@
use App\Livewire\Home\News\Show as NewsShow;
use Illuminate\Support\Facades\Route;
Route::get('/', Index::class)->name('home');
Route::get('/about-us', AboutUs::class)->name('about-us');
Route::middleware([TrackVisitor::class])->group(function () {
Route::get('/', Index::class)->name('home');
Route::get('/about-us', AboutUs::class)->name('about-us');
Route::prefix('news')->group(function () {
Route::get('/', NewsIndex::class)->name('news.index');
Route::get('/{news:slug}', NewsShow::class)->name('news.show');
Route::prefix('news')->group(function () {
Route::get('/', NewsIndex::class)->name('news.index');
Route::get('/{news:slug}', NewsShow::class)->name('news.show');
});
Route::get('/announcements', Announcement::class)->name('announcement');
});
Route::get('/announcements', Announcement::class)->name('announcement');