uuid => name, for the server filter */ public array $serverOptions = []; /** @var array uuid => name, for the application filter (scoped to the selected server) */ public array $appOptions = []; /** * Listbox options for the application filter, grouped under project headers so * it's clear which application belongs to which project. * * @var array */ public array $appGroupedOptions = []; #[Url(as: 'range')] public string $range = '24h'; #[Url(as: 'server')] public string $serverUuid = ''; #[Url(as: 'app')] public string $appUuid = ''; // Realtime refresh; off by default (click "Live" to arm it). Only meaningful on the // 24h range, which matches the 60s Sentinel cache TTL. public bool $live = false; public ?array $overview = null; public bool $latencyApproximate = false; public bool $uniquesApproximate = false; /** @var array> */ public array $topApps = []; /** @var array> */ public array $topHosts = []; /** @var array> */ public array $topPaths = []; /** @var array>> */ public array $breakdowns = []; public ?string $attribution = null; /** * Per-bucket status-class time series for the stacked area chart, summed across * target servers and sorted by bucket. Empty when no target Sentinel exposes the * series endpoint (older builds), which flips the chart back to the status donut. * * @var array */ public array $series = []; public bool $hasSeries = false; /** * Servers that could run traffic analytics but have it off — drives the nudge banner. * * @var array */ public array $eligibleDisabledServers = []; public string $nudgeKey = ''; /** * Upper bound on per-app overviews fetched for the leaderboard, so a server with a huge * number of recorded apps can't reintroduce a per-app round-trip storm. Truncation is * logged (see loadData) rather than silently swallowed. */ private const MAX_LEADERBOARD_APPS = 200; /** @var array */ protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; /** * Per-request cache of app uuid => display metadata, so resolving a name/domain/link * for the leaderboard and path domains hits the DB at most once per app. * * @var array */ protected array $appMetaCache = []; public function mount(?string $scopedServerUuid = null): void { $allServers = Server::ownedByCurrentTeamCached(); $this->scopedServerUuid = $scopedServerUuid; if ($this->scopedServerUuid !== null) { $server = $allServers->firstWhere('uuid', $this->scopedServerUuid); abort_if($server === null, 404); $this->serverUuid = $server->uuid; $this->chartId = 'server-analytics-'.$server->uuid; $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect(); $this->serverOptions = [$server->uuid => $server->name]; $this->eligibleDisabledServers = []; $this->nudgeKey = ''; } else { $this->servers = $allServers ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled()) ->values(); $this->serverOptions = $this->servers ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) ->all(); $eligibleDisabled = $allServers ->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled() && ! $server->isSwarm() && ! $server->isBuildServer()) ->values(); $this->eligibleDisabledServers = $eligibleDisabled ->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name]) ->all(); $this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12); // A bookmarked ?server= may point at a server that is no longer enabled. if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { $this->serverUuid = ''; } } $this->refreshAppOptions(); if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) { $this->appUuid = ''; } if ($this->servers->isNotEmpty()) { $this->loadData(); } } public function setRange(string $range): void { $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; $this->loadData(); } public function toggleLive(): void { if ($this->range !== '24h') { return; } $this->live = ! $this->live; } #[On('trafficAnalyticsStateChanged')] public function refreshTrafficAnalyticsState(): void { if ($this->scopedServerUuid === null) { return; } $server = Server::ownedByCurrentTeam()->whereUuid($this->scopedServerUuid)->firstOrFail(); $this->overview = null; $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect(); if ($this->servers->isNotEmpty()) { $this->refreshAppOptions(); $this->loadData(); } } public function isLivePollable(): bool { return $this->live && $this->range === '24h'; } public function updatedServerUuid(): void { // Scope the app options to the newly selected server and drop an app filter // that no longer belongs to it. $this->refreshAppOptions(); if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) { $this->appUuid = ''; } $this->loadData(); } public function updatedAppUuid(): void { $this->loadData(); } protected function refreshAppOptions(): void { $enabledUuids = $this->servers->pluck('uuid'); $apps = Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get() ->filter(function (Application $app) use ($enabledUuids): bool { $serverUuid = $app->destination?->server?->uuid; if (! $serverUuid || ! $enabledUuids->contains($serverUuid)) { return false; } return $this->serverUuid === '' || $serverUuid === $this->serverUuid; }); // Flat uuid => name map, used to validate a bookmarked ?app= filter. $options = $apps->mapWithKeys(fn (Application $app) => [$app->uuid => $app->name])->all(); asort($options); $this->appOptions = $options; // Grouped listbox options: a header row per project, then its apps (both alpha-sorted). $grouped = []; $byProject = $apps ->groupBy(fn (Application $app) => (string) (data_get($app, 'environment.project.name') ?: 'Ungrouped')) ->sortKeys(); foreach ($byProject as $projectName => $projectApps) { $grouped[] = ['value' => '__group_'.md5($projectName), 'label' => $projectName, 'header' => true]; foreach ($projectApps->sortBy('name') as $app) { $grouped[] = ['value' => $app->uuid, 'label' => $app->name]; } } $this->appGroupedOptions = $grouped; } /** * Servers this view should query, honoring the active server/app filters. */ protected function targetServers(): Collection { if ($this->scopedServerUuid !== null) { return $this->servers; } if ($this->appUuid !== '') { $server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first() ?->destination?->server; return $server && $this->servers->contains(fn (Server $s) => $s->uuid === $server->uuid) ? collect([$server]) : collect(); } if ($this->serverUuid !== '') { return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values(); } return $this->servers; } public function loadData(): void { if ($this->servers->isEmpty()) { return; } [$from, $to] = $this->window(); $appKey = $this->appUuid !== '' ? $this->appUuid : null; $servers = $this->targetServers(); $overviews = []; $appRows = []; $pathTotals = []; $breakdownTotals = array_fill_keys($this->breakdownDimensions, []); $seriesByBucket = []; $attribution = null; foreach ($servers as $server) { try { $client = $this->trafficClient($server); // Warm every server-wide endpoint in one docker exec instead of ~15 serial // SSH round-trips; the per-call methods below then read from cache. $leaderboardUuids = $client->prefetchServerWide($appKey, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); // Per-application leaderboard only makes sense when not already filtered to one app. if ($appKey === null && $leaderboardUuids !== []) { if (count($leaderboardUuids) > self::MAX_LEADERBOARD_APPS) { Log::warning('Traffic analytics leaderboard truncated', [ 'server' => $server->uuid, 'total' => count($leaderboardUuids), 'shown' => self::MAX_LEADERBOARD_APPS, ]); $leaderboardUuids = array_slice($leaderboardUuids, 0, self::MAX_LEADERBOARD_APPS); } // Warm the leaderboard's per-app overviews in a second batched exec. $client->prefetchAppOverviews($leaderboardUuids, $from, $to); } $overviews[] = $client->overview($appKey, $from, $to); if ($appKey === null) { foreach ($leaderboardUuids as $uuid) { $appOverview = $client->overview($uuid, $from, $to)->toArray(); $meta = $this->appMeta($uuid); $appRows[] = [ 'uuid' => $uuid, 'name' => $meta['name'], 'domain' => $meta['domain'], 'link' => $meta['link'], 'requests' => (int) ($appOverview['requests'] ?? 0), 'bandwidth' => (int) ($appOverview['bytesIn'] ?? 0) + (int) ($appOverview['bytesOut'] ?? 0), ]; } } foreach ($client->paths($appKey, $from, $to, 50) as $path) { $data = $path->toArray(); $pathStr = (string) ($data['path'] ?? ''); // Prefer the per-path app from Sentinel; fall back to the active app filter // (older Sentinel omits `app`, but a filtered view still knows the app). $appId = (string) ($data['app'] ?? ''); $resolveId = $appId !== '' ? $appId : ($appKey ?? ''); // Key by (app, path) so the same path under two apps stays two rows, each // carrying its own domain. $key = $resolveId."\n".$pathStr; $domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null; $pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0]; $pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0); $pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); $pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0); $pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0); $pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0)); } foreach ($this->breakdownDimensions as $dimension) { foreach ($client->breakdown($appKey, $dimension, $from, $to, 50) as $row) { $data = $row->toArray(); $value = (string) ($data['value'] ?? ''); $breakdownTotals[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0]; $breakdownTotals[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0); $breakdownTotals[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); } } $attribution ??= $client->attribution(); // Per-bucket status series; summed by bucket across servers. Isolated so a // series hiccup (or an older Sentinel lacking the endpoint) never discards a // server's other data — an empty result simply flips the chart to the donut. try { foreach ($client->series($appKey, $this->range) as $bucket) { $data = $bucket->toArray(); $ts = (int) ($data['bucket'] ?? 0); $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0]; $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0); $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0); $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0); $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0); $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0); $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0); $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); // Uniques summed across servers (approximate); p95 takes the worst bucket. $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0); $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0)); } } catch (\Throwable $e) { // Leave this server out of the series; donut fallback covers it. } } catch (\Throwable $e) { // Skip unreachable/failed servers so one bad server doesn't break the whole view. continue; } } if (empty($overviews)) { $this->resetData(); // The chart lives under wire:ignore, so it only updates via this event — dispatch // even when cleared so a previously-populated chart flips to its no-data state // instead of keeping stale data. $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); return; } $result = TrafficAnalyticsAggregator::sumOverviews($overviews); $this->overview = $result['overview']->toArray(); $this->latencyApproximate = $result['latencyApproximate']; $this->uniquesApproximate = $result['uniquesApproximate']; usort($appRows, fn ($a, $b) => $b['requests'] <=> $a['requests']); $this->topApps = array_slice($appRows, 0, 50); // Top hosts: fold per-app volume up to the served hostname (an app's primary // domain). Apps without a configured FQDN collapse into one "Unknown host" row. $hostTotals = []; foreach ($appRows as $row) { $host = $row['domain'] ?? ''; $hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0]; $hostTotals[$host]['requests'] += (int) $row['requests']; $hostTotals[$host]['bandwidth'] += (int) $row['bandwidth']; } $hosts = array_values($hostTotals); usort($hosts, fn ($a, $b) => $b['requests'] <=> $a['requests']); $this->topHosts = array_slice($hosts, 0, 50); $paths = array_values($pathTotals); usort($paths, fn ($a, $b) => $b['requests'] <=> $a['requests']); $this->topPaths = array_slice($paths, 0, 50); $breakdowns = []; foreach ($this->breakdownDimensions as $dimension) { $rows = array_values($breakdownTotals[$dimension]); usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']); if ($dimension === 'referer') { $rows = groupRefererBreakdownRows($rows); } $breakdowns[$dimension] = array_slice($rows, 0, 50); } $this->breakdowns = $breakdowns; $this->attribution = $attribution; ksort($seriesByBucket); $this->series = array_values($seriesByBucket); $this->hasSeries = $this->series !== []; $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); } /** * Payload for the status chart: the stacked-area time series when available, * plus the donut totals as a fallback for older Sentinel builds. * * @return array */ protected function chartPayload(): array { $device = $this->deviceChartData(); $overview = $this->overview ?? []; return [ 'hasSeries' => $this->hasSeries, 'range' => $this->range, 'seriesData' => [ $overview['s2xx'] ?? 0, $overview['s3xx'] ?? 0, $overview['s4xx'] ?? 0, $overview['s5xx'] ?? 0, ], 'timeSeries' => [ 'categories' => array_column($this->series, 'bucket'), 'requests' => $this->requestsSpark(), 's2xx' => array_column($this->series, 's2xx'), 's3xx' => array_column($this->series, 's3xx'), 's4xx' => array_column($this->series, 's4xx'), 's5xx' => array_column($this->series, 's5xx'), ], 'requestsSpark' => $this->requestsSpark(), 'sparkCategories' => array_column($this->series, 'bucket'), 'errorsSpark' => $this->errorsSpark(), 'bandwidthSpark' => $this->bandwidthSpark(), 'uniquesSpark' => $this->uniquesSpark(), 'latencySpark' => $this->latencySpark(), 'geo' => $this->geoMarkers(), 'deviceLabels' => $device['labels'], 'deviceSeries' => $device['series'], ]; } protected function resetData(): void { $this->overview = null; $this->latencyApproximate = false; $this->uniquesApproximate = false; $this->topApps = []; $this->topHosts = []; $this->topPaths = []; $this->breakdowns = []; $this->attribution = null; $this->series = []; $this->hasSeries = false; } public function errorRate(): float { if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { return 0.0; } $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); return round(($errors / $this->overview['requests']) * 100, 2); } public function bandwidthBytes(): int { if (! $this->overview) { return 0; } return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0); } protected function trafficClient(Server $server): SentinelTrafficClient { return app(SentinelTrafficClient::class, ['server' => $server]); } /** * Resolve an app uuid to its display name, primary domain, and analytics-page link, * memoized per request. Returns the uuid as the name for apps not owned by the team * so a Sentinel-reported uuid never discloses another team's application name. * * @return array{name: string, domain: ?string, link: ?string} */ protected function appMeta(string $uuid): array { if (isset($this->appMetaCache[$uuid])) { return $this->appMetaCache[$uuid]; } $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($uuid)->first(); $domain = null; if ($app) { $first = collect($app->fqdns)->first(); $domain = $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null; } $link = null; if ($app && data_get($app, 'environment.project.uuid')) { $link = route('project.application.analytics', [ 'project_uuid' => $app->environment->project->uuid, 'environment_uuid' => $app->environment->uuid, 'application_uuid' => $app->uuid, ]); } return $this->appMetaCache[$uuid] = [ 'name' => $app?->name ?? $uuid, 'domain' => $domain, 'link' => $link, ]; } /** * @return array{0: string, 1: string} */ private function window(): array { $to = now(); $from = match ($this->range) { '7d' => now()->subDays(7), '30d' => now()->subDays(30), default => now()->subDay(), }; return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; } public function placeholder(array $params = []): View { $scopedServerUuid = $params['scopedServerUuid'] ?? null; $hideSkeleton = false; if (is_string($scopedServerUuid)) { $server = Server::ownedByCurrentTeamCached()->firstWhere('uuid', $scopedServerUuid); $hideSkeleton = $server !== null && ! $server->isTrafficAnalyticsEnabled(); } // Rendered instantly; the Sentinel round-trips run in the deferred lazy-load request. return view('livewire.analytics-placeholder', compact('hideSkeleton')); } public function render() { return view('livewire.analytics'); } }