Files
coolify/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php
T
Andras Bacsai 575bfaa085 feat(analytics): add configurable client IP storage modes
Allow full, anonymized, or disabled IP recording per server through the UI
and API, and pass the selected mode to Sentinel. Adapt traffic breakdowns
to the effective IP mode, including views that aggregate multiple servers.
2026-10-07 12:35:22 +02:00

241 lines
8.0 KiB
PHP

<?php
namespace App\Livewire\Project\Shared;
use App\Enums\TrafficIpMode;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Models\Service;
use App\Services\SentinelTrafficClient;
use App\Services\TrafficAnalyticsAggregator;
use App\Services\TrafficResource;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
/**
* Traffic analytics tab of one resource (Application or Service). A resource can record
* under several Sentinel keys (compose services, previews). Sentinel's resource scope
* merges them exactly; a Sentinel without resource routes (before 1.0.2) gets every key
* fetched and merged here.
*/
abstract class ResourceTrafficAnalytics extends Component
{
use AuthorizesRequests;
use BuildsTrafficChartPayload;
public string $chartId = 'resource-analytics';
public string $range = '24h';
public bool $enabled = false;
#[Locked]
public ?string $analyticsServerUuid = null;
// Realtime refresh. Off by default (click "Live" to arm it). Only meaningful on the
// 24h range; the 60s cadence matches the SentinelTrafficClient cache TTL and
// Sentinel's per-minute rollups. The control is disabled for 7d/30d.
public bool $live = false;
public ?array $overview = null;
public bool $latencyApproximate = false;
public bool $uniquesApproximate = false;
public array $topPaths = [];
/** @var array<string, array<int, array<string, mixed>>> */
public array $breakdowns = [];
public ?string $attribution = null;
/** TrafficIpMode of the resource's server; decides how the IP breakdown renders. */
#[Locked]
public string $ipMode = 'full';
/**
* Per-bucket status-class time series for the stacked area chart.
*
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
*/
public array $series = [];
/** @var array<int, string> */
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
/**
* Name of the public model property and lazy-load parameter ('application' / 'service').
*/
abstract protected function resourceProperty(): string;
protected function resourceModel(): Application|Service
{
return $this->{$this->resourceProperty()};
}
protected function trafficResource(): TrafficResource
{
return TrafficResource::for($this->resourceModel());
}
public function mount(): void
{
$this->authorize('view', $this->resourceModel());
$server = $this->trafficResource()->server();
$this->analyticsServerUuid = $server?->uuid;
$this->enabled = (bool) $server?->isTrafficAnalyticsEnabled();
if ($this->enabled) {
$this->loadData();
}
}
/**
* Subclasses redeclare $chartId, which drops #[Locked], so the lock is enforced here.
*/
public function updatingChartId(): void
{
throw new CannotUpdateLockedPropertyException('chartId');
}
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;
}
/**
* Realtime polling is only armed when the user has it on and the range is 24h.
*/
public function isLivePollable(): bool
{
return $this->live && $this->range === '24h';
}
public function loadData(): void
{
if (! $this->enabled) {
return;
}
try {
$this->authorize('view', $this->resourceModel());
$resource = $this->trafficResource();
[$from, $to] = SentinelTrafficClient::rangeWindow($this->range);
$server = $resource->server();
$client = app(SentinelTrafficClient::class, ['server' => $server]);
$ipMode = $server ? TrafficIpMode::forServer($server) : TrafficIpMode::Full;
$this->ipMode = $ipMode->value;
// Sentinel's resource scope merges every key exactly in one docker exec. A Sentinel
// before 1.0.2 falls back to all keys of the resource, merged here (approximate).
$aggregator = new TrafficAnalyticsAggregator($ipMode->breakdownDimensions($this->breakdownDimensions));
$aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey));
$result = $aggregator->overview();
$this->overview = $result['overview']->toArray();
$this->latencyApproximate = $result['latencyApproximate'];
$this->uniquesApproximate = $result['uniquesApproximate'];
$this->topPaths = $aggregator->topPaths();
$this->breakdowns = $aggregator->breakdowns();
$this->attribution = $aggregator->attribution();
$this->series = $aggregator->series();
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Payload for the status chart: the stacked-area time series and the KPI sparklines.
*
* @return array<string, mixed>
*/
protected function chartPayload(): array
{
$device = $this->deviceChartData();
return [
'range' => $this->range,
'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'],
];
}
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);
}
public function placeholder(array $params = []): View
{
$model = $params[$this->resourceProperty()] ?? null;
if ($model instanceof Application || $model instanceof Service) {
$this->authorize('view', $model);
$server = TrafficResource::for($model)->server();
if (! $server?->isTrafficAnalyticsEnabled()) {
$this->{$this->resourceProperty()} = $model;
$this->enabled = false;
$this->analyticsServerUuid = $server?->uuid;
return view('livewire.project.application.analytics');
}
}
// Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request.
return view('livewire.project.application.analytics-placeholder');
}
public function render()
{
return view('livewire.project.application.analytics');
}
}