feat(analytics): add site analytics with grouped navigation sections

- Add Analytics container: models, tasks, jobs, controllers, routes, services
- Add analytics config with section groups (structure, institute, content, services)
- Add database tables: analytics_sessions, analytics_hits, analytics_daily_stats
- Add frontend: Analytics page, section detail, chart, overview, navigation components
- Add consent banner and JS tracking service (sendBeacon + Inertia listener)
- Add permission sync for view_analytics, view_any_analytic
- Fix trailing slash URL matching in GetNavigationSectionsTask
- Fix event propagation on expand arrows in navigation sections
- Fix RecordVisitJob to not depend on dropped analytics_geo_cache table
- Replace plain text views/visitors with badge-style icons
- Group navigation sections by category with separate blocks
- Add config-driven non-CMS sections (faculties, divisions, DPO, journals)
- Remove Events, TV, Sveden from analytics sections
This commit is contained in:
F4ilji
2026-09-18 22:18:04 +05:00
parent 8af94388ab
commit d6c0f48d48
60 changed files with 2948 additions and 121 deletions
@@ -0,0 +1,48 @@
<?php
namespace App\Containers\Analytics\Commands;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
class AggregateAnalyticsCommand extends \Illuminate\Console\Command
{
protected $signature = 'analytics:aggregate {--days=1 : Days back to aggregate}';
protected $description = 'Aggregate analytics hits into daily stats';
public function handle(): int
{
$days = (int) $this->option('days');
$date = now()->subDays($days)->toDateString();
$stats = AnalyticsHit::selectRaw('
DATE(created_at) as hit_date,
url,
COUNT(*) as views_count,
COUNT(DISTINCT session_id) as unique_visitors
')
->whereDate('created_at', $date)
->groupBy(DB::raw('DATE(created_at)'), 'url')
->get();
$inserted = 0;
foreach ($stats as $row) {
AnalyticsDailyStat::updateOrCreate(
['date' => $row->hit_date, 'url' => $row->url],
[
'views_count' => $row->views_count,
'unique_visitors' => $row->unique_visitors,
]
);
$inserted++;
}
$this->info("Aggregated {$inserted} daily stat rows for {$date}");
return static::SUCCESS;
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Containers\Analytics\Jobs;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\Analytics\Services\DeviceDetectorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecordVisitJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
public function __construct(
public array $data,
) {}
public function handle(
DeviceDetectorService $detector,
): void {
$userAgent = $this->data['user_agent'] ?? '';
if ($detector->isBot($userAgent)) {
return;
}
$device = $detector->parse($userAgent);
$utmParams = array_filter([
'utm_source' => $this->data['utm_source'] ?? null,
'utm_medium' => $this->data['utm_medium'] ?? null,
'utm_campaign' => $this->data['utm_campaign'] ?? null,
], fn($v) => $v !== null);
$session = (new AnalyticsSession())->firstOrCreateSession(
visitorId: $this->data['visitor_id'],
ip: $this->data['ip'] ?? '0.0.0.0',
userId: $this->data['user_id'] ?? null,
entryPage: $this->data['url'],
utmParams: $utmParams,
);
if (empty($session->browser)) {
$session->update([
'browser' => $device['browser'],
'os' => $device['os'],
'device_type' => $device['type'],
]);
}
AnalyticsHit::create([
'session_id' => $session->id,
'user_id' => $this->data['user_id'] ?? null,
'url' => $this->data['url'],
'referrer' => $this->data['referrer'] ?? null,
'title' => $this->data['title'] ?? null,
'created_at' => now(),
]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
class AnalyticsDailyStat extends Model
{
protected $table = 'analytics_daily_stats';
protected $fillable = [
'date',
'url',
'views_count',
'unique_visitors',
];
protected $casts = [
'date' => 'date',
'views_count' => 'integer',
'unique_visitors' => 'integer',
];
}
@@ -0,0 +1,36 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsHit extends Model
{
protected $table = 'analytics_hits';
public $timestamps = false;
protected $fillable = [
'session_id',
'user_id',
'url',
'referrer',
'title',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function session(): BelongsTo
{
return $this->belongsTo(AnalyticsSession::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsSession extends Model
{
protected $table = 'analytics_sessions';
protected $fillable = [
'visitor_id',
'user_id',
'entry_page',
'ip',
'utm_source',
'utm_medium',
'utm_campaign',
'browser',
'os',
'device_type',
'started_at',
'last_activity_at',
];
protected $casts = [
'started_at' => 'datetime',
'last_activity_at' => 'datetime',
];
public function hits(): HasMany
{
return $this->hasMany(AnalyticsHit::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
public function firstOrCreateSession(
string $visitorId,
string $ip,
int $userId = null,
string $entryPage = null,
array $utmParams = [],
): self {
$lastSession = static::where('visitor_id', $visitorId)
->where('last_activity_at', '>', now()->subMinutes(30))
->latest('last_activity_at')
->first();
if ($lastSession) {
$lastSession->update(['last_activity_at' => now()]);
return $lastSession;
}
return static::create([
'visitor_id' => $visitorId,
'user_id' => $userId,
'entry_page' => $entryPage,
'ip' => $ip,
'utm_source' => $utmParams['utm_source'] ?? null,
'utm_medium' => $utmParams['utm_medium'] ?? null,
'utm_campaign' => $utmParams['utm_campaign'] ?? null,
'started_at' => now(),
'last_activity_at' => now(),
]);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Containers\Analytics\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
class AnalyticsServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
\App\Containers\Analytics\Commands\AggregateAnalyticsCommand::class,
]);
$this->app->afterResolving(Schedule::class, function (Schedule $schedule) {
$schedule->command('analytics:aggregate', ['--days' => 1])->hourly();
});
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Containers\Analytics\Services;
use WhichBrowser\Parser;
class DeviceDetectorService
{
public function parse(string $userAgent): array
{
$parser = new Parser($userAgent);
$browser = $parser->browser->name ?? null;
$os = $parser->os->name ?? null;
$deviceType = 'desktop';
if ($parser->isType('tablet')) {
$deviceType = 'tablet';
} elseif ($parser->isType('mobile')) {
$deviceType = 'mobile';
}
return [
'browser' => $browser,
'os' => $os,
'type' => $deviceType,
];
}
public function isBot(string $userAgent): bool
{
$parser = new Parser($userAgent);
return $parser->isType('bot');
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Analytics\Services;
use App\Containers\Analytics\Models\AnalyticsGeoCache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private const CACHE_TTL_DAYS = 7;
public function locate(string $ip): ?array
{
$ipHash = sha1($ip);
$cached = AnalyticsGeoCache::where('ip_hash', $ipHash)
->where('cached_at', '>', now()->subDays(self::CACHE_TTL_DAYS))
->first();
if ($cached) {
return [
'country' => $cached->country,
'city' => $cached->city,
];
}
try {
$response = Http::timeout(3)->get("http://ip-api.com/json/{$ip}", [
'fields' => 'country,city',
]);
if ($response->successful()) {
$data = $response->json();
AnalyticsGeoCache::updateOrCreate(
['ip_hash' => $ipHash],
[
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
'cached_at' => now(),
]
);
return [
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
];
}
} catch (\Throwable $e) {
Log::warning('GeoIP lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]);
}
return null;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetAnalyticsOverviewTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$to = now()->endOfDay();
if ($days === 0) {
$prevFrom = Carbon::yesterday()->startOfDay();
$prevTo = Carbon::yesterday()->endOfDay();
} else {
$prevFrom = Carbon::now()->subDays($days * 2)->startOfDay();
$prevTo = Carbon::now()->subDays($days)->startOfDay();
}
$current = $this->getPeriodStats($from, $to);
$previous = $this->getPeriodStats($prevFrom, $prevTo);
return [
'unique_visitors' => $this->compare($current['visitors'], $previous['visitors']),
'page_views' => $this->compare($current['views'], $previous['views']),
'avg_time' => $this->compare($current['avg_time'], $previous['avg_time'], true),
'bounce_rate' => $this->compare($current['bounce_rate'], $previous['bounce_rate'], true, true),
];
}
private function getPeriodStats(Carbon $from, Carbon $to): array
{
$hits = AnalyticsHit::whereBetween('created_at', [$from, $to])->count();
$sessions = AnalyticsSession::whereBetween('started_at', [$from, $to])
->get(['id', 'started_at', 'last_activity_at']);
$uniqueVisitors = AnalyticsSession::whereBetween('started_at', [$from, $to])
->distinct('visitor_id')
->count('visitor_id');
$totalSessions = $sessions->count();
$avgTime = 0;
if ($totalSessions > 0) {
$totalSeconds = $sessions->sum(function ($s) {
return $s->started_at->diffInSeconds($s->last_activity_at);
});
$avgTime = (int) round($totalSeconds / $totalSessions);
}
$bounceRate = 0;
if ($totalSessions > 0) {
$sessionIds = $sessions->pluck('id');
$singleHitSessions = AnalyticsHit::whereIn('session_id', $sessionIds)
->selectRaw('session_id, COUNT(*) as hits')
->groupBy('session_id')
->havingRaw('COUNT(*) = 1')
->count();
$bounceRate = (int) round(($singleHitSessions / $totalSessions) * 100);
}
return [
'views' => $hits,
'visitors' => $uniqueVisitors,
'avg_time' => $avgTime,
'bounce_rate' => $bounceRate,
];
}
private function compare(int $current, int $previous, bool $isRatio = false, bool $invertDirection = false): array
{
$change = 0;
if ($previous > 0) {
$change = $isRatio
? $current - $previous
: (int) round((($current - $previous) / $previous) * 100);
} elseif ($current > 0) {
$change = $isRatio ? 0 : 100;
}
$direction = $invertDirection
? ($change < 0 ? 'up' : ($change > 0 ? 'down' : 'neutral'))
: ($change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral'));
return [
'value' => $current,
'change' => $change,
'direction' => $direction,
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetDailyStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$stats = AnalyticsDailyStat::where('date', '>=', $from)
->selectRaw('
date,
SUM(views_count) as total_views,
SUM(unique_visitors) as total_unique
')
->groupBy('date')
->orderBy('date')
->get();
if ($stats->isEmpty()) {
return AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('
DATE(created_at) as hit_date,
COUNT(*) as total_views,
COUNT(DISTINCT session_id) as total_unique
')
->groupBy(DB::raw('DATE(created_at)'))
->orderBy('hit_date')
->get()
->map(fn($row) => [
'date' => (string) $row->hit_date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])
->toArray();
}
return $stats->map(fn($row) => [
'date' => $row->date instanceof Carbon ? $row->date->format('Y-m-d') : (string) $row->date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])->toArray();
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
class GetDevicesTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->selectRaw('
device_type,
COUNT(*) as count
')
->groupBy('device_type')
->get()
->pluck('count', 'device_type')
->toArray();
$browsers = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('browser')
->selectRaw('
browser,
COUNT(*) as count
')
->groupBy('browser')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'browser')
->toArray();
$oses = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('os')
->selectRaw('
os,
COUNT(*) as count
')
->groupBy('os')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'os')
->toArray();
return [
'devices' => $devices,
'browsers' => $browsers,
'oses' => $oses,
];
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
class GetNavigationSectionsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$mainSections = MainSection::with('subSections.pages')
->orderBy('sort')
->get();
$sections = [];
$cmsSlugs = [];
// 1. CMS sections (MainSection → SubSection → Page)
foreach ($mainSections as $ms) {
$msPrefix = '/' . $ms->slug;
$cmsSlugs[] = $msPrefix;
$msHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $msPrefix));
$subSections = [];
foreach ($ms->subSections as $ss) {
$ssPrefix = $msPrefix . '/' . $ss->slug;
$ssHits = $msHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $ssPrefix));
$pages = [];
foreach ($ss->pages as $page) {
$pagePath = '/' . $page->path;
$pHits = $ssHits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pagePath);
$pages[] = [
'id' => $page->id,
'title' => $page->title ?: $page->slug,
'path' => $page->path,
'url' => $pagePath,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
$subSections[] = [
'id' => $ss->id,
'title' => $ss->title,
'slug' => $ss->slug,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages' => $pages,
];
}
$sections[] = [
'id' => $ms->id,
'title' => $ms->title,
'slug' => $ms->slug,
'group' => 'structure',
'views' => $msHits->count(),
'visitors' => $msHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// 2. Config-defined sections (non-CMS)
$configSections = config('analytics.sections', []);
foreach ($configSections as $prefix => $config) {
if ($prefix === '/') continue;
if (in_array($prefix, $cmsSlugs)) continue;
$sectionHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $prefix));
$subSections = [];
if (isset($config['children'])) {
$model = $config['children']['model'];
$nameKey = $config['children']['name_key'];
$slugKey = $config['children']['slug_key'];
$children = $model::all();
foreach ($children as $child) {
$childSlug = $child->{$slugKey};
$childPrefix = $prefix . '/' . $childSlug;
$childHits = $sectionHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $childPrefix));
$subSections[] = [
'id' => $child->id,
'title' => $child->{$nameKey},
'slug' => $childSlug,
'views' => $childHits->count(),
'visitors' => $childHits->pluck('session_id')->unique()->count(),
'pages' => [],
];
}
}
$sections[] = [
'id' => 'config:' . $prefix,
'title' => $config['label'],
'slug' => ltrim($prefix, '/'),
'group' => $config['group'] ?? 'other',
'views' => $sectionHits->count(),
'visitors' => $sectionHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// Homepage — as first item in structure group
$homeHits = $hits->filter(fn($h) => (rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === '/'));
array_unshift($sections, [
'id' => 'home',
'title' => 'Главная страница',
'slug' => '',
'group' => 'structure',
'type' => 'home',
'views' => $homeHits->count(),
'visitors' => $homeHits->pluck('session_id')->unique()->count(),
'sub_sections' => [],
]);
return [
'sections' => $sections,
'groupLabels' => config('analytics.group_labels', []),
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetReferrersTask
{
public function run(int $days = 30, int $limit = 10): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$referrers = AnalyticsHit::where('created_at', '>=', $from)
->whereNotNull('referrer')
->where('referrer', '!=', '')
->selectRaw('
CASE
WHEN referrer LIKE "%google.%" THEN "Google"
WHEN referrer LIKE "%yandex.%" THEN "Yandex"
WHEN referrer LIKE "%vk.com%" THEN "VK"
WHEN referrer LIKE "%t.me%" THEN "Telegram"
WHEN referrer LIKE "%ok.ru%" THEN "Одноклассники"
WHEN referrer LIKE "%dzen.ru%" THEN "Дзен"
WHEN referrer LIKE "%facebook.%" THEN "Facebook"
WHEN referrer LIKE "%instagram.%" THEN "Instagram"
WHEN referrer LIKE "%twitter.%" THEN "Twitter"
ELSE SUBSTRING_INDEX(SUBSTRING_INDEX(referrer, "/", 3), "/", -1)
END as source,
COUNT(*) as hits,
COUNT(DISTINCT session_id) as visitors
')
->groupBy('source')
->orderByDesc('hits')
->limit($limit)
->get()
->toArray();
$directCount = AnalyticsHit::where('created_at', '>=', $from)
->where(function ($q) {
$q->whereNull('referrer')->orWhere('referrer', '');
})
->count();
$results = array_map(fn($r) => [
'source' => $r['source'],
'hits' => (int) $r['hits'],
'visitors' => (int) $r['visitors'],
], $referrers);
if ($directCount > 0) {
array_unshift($results, [
'source' => 'Прямой заход',
'hits' => $directCount,
'visitors' => $directCount,
]);
}
return $results;
}
}
@@ -0,0 +1,216 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\AppStructure\Models\MainSection;
use App\Containers\AppStructure\Models\Page;
use App\Containers\AppStructure\Models\SubSection;
use Illuminate\Support\Carbon;
class GetSectionDetailTask
{
public function run(string $prefix, int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->where('url', 'like', $prefix . '%')
->selectRaw('url, session_id, created_at, referrer')
->get();
$totalViews = $hits->count();
$uniqueVisitors = $hits->pluck('session_id')->unique()->count();
$mainSection = null;
$subSection = null;
$page = null;
$children = [];
$breadcrumbs = [];
$type = 'unknown';
$exitPages = [];
$cleanPrefix = ltrim($prefix, '/');
$parts = array_filter(explode('/', $cleanPrefix));
// 1. Check if MainSection
$mainSection = MainSection::where('slug', $cleanPrefix)->first();
if ($mainSection) {
$type = 'main_section';
$breadcrumbs = [['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')]];
$subSections = SubSection::where('main_section_id', $mainSection->id)
->with('pages')
->orderBy('sort')
->get();
foreach ($subSections as $ss) {
$ssPrefix = '/' . $mainSection->slug . '/' . $ss->slug;
$ssHits = $hits->filter(fn($h) => str_starts_with(parse_url($h->url, PHP_URL_PATH) ?: '', $ssPrefix));
$children[] = [
'id' => $ss->id,
'title' => $ss->title,
'prefix' => $ssPrefix,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages_count' => $ss->pages->count(),
];
}
}
// 2. Check if SubSection (2 parts)
elseif (count($parts) === 2) {
$mainSection = MainSection::where('slug', $parts[0])->first();
$subSection = SubSection::where('slug', $parts[1])
->where('main_section_id', $mainSection?->id)
->first();
if ($subSection && $mainSection) {
$type = 'sub_section';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
];
$pages = Page::where('sub_section_id', $subSection->id)
->orderBy('sort')
->get();
foreach ($pages as $p) {
$pUrl = '/' . $p->path;
$pHits = $hits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pUrl);
$children[] = [
'id' => $p->id,
'title' => $p->title ?: $p->slug,
'url' => $pUrl,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
}
}
// 3. Check if Page (by path or 3+ parts)
if ($type === 'unknown') {
$page = Page::where('path', $cleanPrefix)->first();
if ($page && $page->section) {
$subSection = $page->section;
$mainSection = $subSection->mainSection;
}
if ($mainSection && $subSection) {
$type = 'page';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
['title' => $subSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug . '/' . $subSection->slug])],
];
// Exit pages: URLs visited after this page in the same session
$exitPages = $this->getExitPages($hits, $prefix, $days);
}
}
$label = $cleanPrefix;
if ($mainSection) $label = $mainSection->title;
if ($subSection) $label = $subSection->title;
if ($page) $label = $page->title ?: $page->slug;
$topPages = $hits->groupBy('url')
->map(fn($group) => [
'url' => $group->first()->url,
'views' => $group->count(),
'unique_visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('views')
->values()
->take(15)
->toArray();
$dailyStats = $hits->groupBy(fn($h) => $h->created_at->format('Y-m-d'))
->map(fn($group, $date) => [
'date' => $date,
'views' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->values()
->sortBy('date')
->toArray();
$referrers = $hits->filter(fn($h) => !empty($h->referrer))
->groupBy(function ($h) {
$url = $h->referrer;
if (str_contains($url, 'google.')) return 'Google';
if (str_contains($url, 'yandex.')) return 'Yandex';
if (str_contains($url, 'vk.com')) return 'VK';
if (str_contains($url, 't.me')) return 'Telegram';
if (str_contains($url, 'ok.ru')) return 'Одноклассники';
if (str_contains($url, 'dzen.ru')) return 'Дзен';
return parse_url($url, PHP_URL_HOST) ?? 'Другое';
})
->map(fn($group) => [
'source' => $group->first()->referrer,
'hits' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('hits')
->values()
->take(10)
->toArray();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->whereIn('id', $hits->pluck('session_id')->unique())
->selectRaw('device_type, COUNT(*) as count')
->groupBy('device_type')
->pluck('count', 'device_type')
->toArray();
return [
'prefix' => $prefix,
'type' => $type,
'label' => $label,
'breadcrumbs' => $breadcrumbs,
'children' => $children,
'exit_pages' => $exitPages,
'total_views' => $totalViews,
'unique_visitors' => $uniqueVisitors,
'top_pages' => $topPages,
'daily_stats' => $dailyStats,
'referrers' => $referrers,
'devices' => $devices,
];
}
private function getExitPages($hits, string $prefix, int $days): array
{
$sessionIds = $hits->pluck('session_id')->unique();
$allHits = AnalyticsHit::where('created_at', '>=', now()->subDays($days)->startOfDay())
->whereIn('session_id', $sessionIds)
->orderBy('session_id')
->orderBy('created_at')
->selectRaw('session_id, url, created_at')
->get()
->groupBy('session_id');
$exitCounts = [];
foreach ($allHits as $sessionId => $sessionHits) {
$sorted = $sessionHits->values();
for ($i = 0; $i < $sorted->count() - 1; $i++) {
$currentUrl = parse_url($sorted[$i]->url, PHP_URL_PATH) ?: '/';
if (str_starts_with($currentUrl, $prefix)) {
$nextUrl = parse_url($sorted[$i + 1]->url, PHP_URL_PATH) ?: '/';
if (!isset($exitCounts[$nextUrl])) {
$exitCounts[$nextUrl] = 0;
}
$exitCounts[$nextUrl]++;
}
}
}
arsort($exitCounts);
return array_slice(array_map(fn($url, $count) => [
'url' => $url,
'count' => $count,
], array_keys($exitCounts), $exitCounts), 0, 10);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetSectionsStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$sections = config('analytics.sections', []);
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$grouped = [];
foreach ($hits as $hit) {
$prefix = $this->resolvePrefix($hit->url, $sections);
if (!isset($grouped[$prefix])) {
$grouped[$prefix] = ['views' => 0, 'visitors' => []];
}
$grouped[$prefix]['views']++;
$grouped[$prefix]['visitors'][$hit->session_id] = true;
}
$result = [];
foreach ($grouped as $prefix => $data) {
$config = $sections[$prefix] ?? null;
$result[] = [
'prefix' => $prefix,
'label' => $config['label'] ?? $prefix,
'icon' => $config['icon'] ?? 'folder',
'views' => $data['views'],
'visitors' => count($data['visitors']),
'order' => $config['order'] ?? 99,
];
}
usort($result, fn($a, $b) => $a['order'] <=> $b['order']);
return $result;
}
private function resolvePrefix(string $url, array $sections): string
{
$path = parse_url($url, PHP_URL_PATH) ?: '/';
$segments = explode('/', trim($path, '/'));
$firstSegment = '/' . ($segments[0] ?? '');
if (isset($sections[$firstSegment])) {
return $firstSegment;
}
if ($firstSegment === '/' && $path === '/') {
return '/';
}
// Check main sections for CMS pages
$mainSection = MainSection::where('slug', $segments[0] ?? '')->first();
if ($mainSection) {
return '/page';
}
return '/other';
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Facades\DB;
class GetTopPagesTask
{
public function run(int $days = 7, int $limit = 10): array
{
return AnalyticsHit::select('url')
->selectRaw('COUNT(*) as views')
->selectRaw('COUNT(DISTINCT session_id) as unique_visitors')
->where('created_at', '>=', now()->subDays($days))
->groupBy('url')
->orderByDesc('views')
->limit($limit)
->get()
->toArray();
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Containers\Analytics\UI\WEB\Controllers;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\Analytics\Tasks\GetAnalyticsOverviewTask;
use App\Containers\Analytics\Tasks\GetDailyStatsTask;
use App\Containers\Analytics\Tasks\GetDevicesTask;
use App\Containers\Analytics\Tasks\GetNavigationSectionsTask;
use App\Containers\Analytics\Tasks\GetReferrersTask;
use App\Containers\Analytics\Tasks\GetSectionDetailTask;
use App\Containers\Analytics\Tasks\GetTopPagesTask;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Response;
class AnalyticsController extends Controller
{
public function __construct(
private readonly GetAnalyticsOverviewTask $overviewTask,
private readonly GetTopPagesTask $topPagesTask,
private readonly GetDailyStatsTask $dailyStatsTask,
private readonly GetReferrersTask $referrersTask,
private readonly GetDevicesTask $devicesTask,
private readonly GetNavigationSectionsTask $navigationSectionsTask,
private readonly GetSectionDetailTask $sectionDetailTask,
) {}
public function __invoke(Request $request): Response
{
$days = (int) $request->query('days', 30);
return inertia()->render('Dashboard/Analytics', [
'overview' => $this->overviewTask->run(days: $days),
'topPages' => $this->topPagesTask->run(days: $days),
'dailyStats' => $this->dailyStatsTask->run(days: $days),
'referrers' => $this->referrersTask->run(days: $days),
'devices' => $this->devicesTask->run(days: $days),
'navigation' => $this->navigationSectionsTask->run(days: $days),
'period' => $days,
]);
}
public function section(Request $request): Response
{
$prefix = $request->query('prefix', '/');
$days = (int) $request->query('days', 30);
$section = $this->sectionDetailTask->run(prefix: $prefix, days: $days);
return inertia()->render('Dashboard/AnalyticsSectionDetail', [
'section' => $section,
'period' => $days,
]);
}
public function clear(): RedirectResponse
{
DB::statement('SET FOREIGN_KEY_CHECKS=0');
AnalyticsHit::truncate();
AnalyticsSession::truncate();
AnalyticsDailyStat::truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
return redirect()->route('dashboard.analytics.index')
->with('success', 'Данные аналитики очищены');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Containers\Analytics\UI\WEB\Controllers;
use App\Containers\Analytics\Jobs\RecordVisitJob;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TrackController extends Controller
{
public function store(Request $request): Response
{
$payload = [
'visitor_id' => $request->input('visitor_id'),
'url' => $request->input('url'),
'referrer' => $request->input('referrer'),
'title' => $request->input('title'),
'screen_resolution' => $request->input('screen_resolution'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'user_id' => auth('web')->id(),
];
$url = parse_url($request->input('url', ''), PHP_URL_QUERY);
if ($url) {
parse_str($url, $queryParams);
$payload['utm_source'] = $queryParams['utm_source'] ?? null;
$payload['utm_medium'] = $queryParams['utm_medium'] ?? null;
$payload['utm_campaign'] = $queryParams['utm_campaign'] ?? null;
}
RecordVisitJob::dispatch($payload)->onQueue('analytics');
return response()->noContent();
}
}
@@ -0,0 +1,8 @@
<?php
use App\Containers\Analytics\UI\WEB\Controllers\TrackController;
use Illuminate\Support\Facades\Route;
Route::post('/track/hit', [TrackController::class, 'store'])
->middleware(['throttle:60,1'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
@@ -0,0 +1,3 @@
<?php
// Route is registered in Dashboard/UI/WEB/Routes/web.php
@@ -431,6 +431,11 @@ Route::middleware(['access-check', 'dashboard.auth', 'dashboard.permission'])->g
Route::put('/{credential}', [IntegrationCredentialsController::class, 'update'])->name('update');
Route::delete('/{credential}', [IntegrationCredentialsController::class, 'destroy'])->name('destroy');
});
// Аналитика
Route::get('/dashboard/analytics', \App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class)->name('dashboard.analytics.index');
Route::get('/dashboard/analytics/section', [\App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class, 'section'])->name('dashboard.analytics.section');
Route::post('/dashboard/analytics/clear', [\App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class, 'clear'])->name('dashboard.analytics.clear');
});
@@ -21,7 +21,6 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
// Отключаем SSR для Dashboard роутов
if (str_starts_with($request->path(), 'dashboard')) {
config(['inertia.ssr.enabled' => false]);
}
@@ -49,7 +48,6 @@ class HandleInertiaRequests extends Middleware
}
return 'empty';
},
'yandex_metrika_id' => config('services.yandex_metrika.id'),
'flash' => function () use ($request) {
return [
'success' => $request->session()->get('success'),
+2 -8
View File
@@ -87,15 +87,9 @@ class AppServiceProvider extends ServiceProvider
private static function configureFactoryResolution(): void
{
Factory::guessFactoryNamesUsing(function (string $modelName) {
$appNamespace = 'App\\';
$className = class_basename($modelName);
if (str_starts_with($modelName, $appNamespace.'Models\\')) {
$modelName = substr($modelName, strlen($appNamespace.'Models\\'));
} else {
$modelName = substr($modelName, strlen($appNamespace));
}
return 'Database\\Factories\\'.$modelName.'Factory';
return 'Database\\Factories\\'.$className.'Factory';
});
}
@@ -21,6 +21,11 @@ class SyncDashboardPermissions extends AbstractConsoleCommand
'educational_group', 'division',
];
private const EXTRA_PERMISSIONS = [
'view_analytics',
'view_any_analytic',
];
private const PREFIXES = [
'view_any', 'create', 'update', 'delete', 'restore', 'force_delete',
];
@@ -46,6 +51,13 @@ class SyncDashboardPermissions extends AbstractConsoleCommand
['name' => 'view_any_contact_widget', 'guard_name' => 'web'],
);
foreach (self::EXTRA_PERMISSIONS as $name) {
Permission::firstOrCreate(
['name' => $name, 'guard_name' => 'web'],
);
$created++;
}
$roleNames = explode(',', $this->option('role'));
$allPerms = Permission::where('guard_name', 'web')->get();
@@ -49,7 +49,6 @@ class HandleInertiaRequests extends Middleware
}
return 'empty';
},
'yandex_metrika_id' => config('services.yandex_metrika.id'),
];
}
}