feat(traffic): add server analytics view

This commit is contained in:
Aditya Tripathi
2026-08-10 20:32:31 +00:00
parent 3439f0fe63
commit 3e7972349d
5 changed files with 530 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Livewire\Server;
use App\Models\Application;
use App\Models\Server;
use App\Services\SentinelTrafficClient;
use Livewire\Component;
class Analytics extends Component
{
public Server $server;
public string $chartId = 'server-analytics';
public string $range = '24h';
public bool $enabled = false;
public ?array $overview = null;
public array $topPaths = [];
/** @var array<string, array<int, array<string, mixed>>> */
public array $breakdowns = [];
/** @var array<int, array<string, mixed>> */
public array $leaderboard = [];
public ?string $attribution = null;
/** @var array<int, string> */
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device'];
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->enabled = (bool) $this->server->isTrafficAnalyticsEnabled();
if ($this->enabled) {
$this->loadData();
}
}
public function setRange(string $range): void
{
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
$this->loadData();
}
public function loadData(): void
{
if (! $this->enabled) {
return;
}
try {
[$from, $to] = $this->window();
$client = $this->trafficClient();
$this->overview = $client->overview(null, $from, $to)->toArray();
$this->topPaths = $client->paths(null, $from, $to, 20)
->map(fn ($path) => $path->toArray())
->all();
$breakdowns = [];
foreach ($this->breakdownDimensions as $dimension) {
$breakdowns[$dimension] = $client->breakdown(null, $dimension, $from, $to, 10)
->map(fn ($row) => $row->toArray())
->all();
}
$this->breakdowns = $breakdowns;
$this->attribution = $client->attribution();
$this->leaderboard = $this->loadLeaderboard($client, $from, $to);
$this->dispatch("refreshChartData-{$this->chartId}-status", [
'seriesData' => [
$this->overview['s2xx'] ?? 0,
$this->overview['s3xx'] ?? 0,
$this->overview['s4xx'] ?? 0,
$this->overview['s5xx'] ?? 0,
],
]);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
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);
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadLeaderboard(SentinelTrafficClient $client, string $from, string $to): array
{
$rows = [];
foreach ($client->apps() as $uuid) {
if (! is_string($uuid) || $uuid === '') {
continue;
}
$overview = $client->overview($uuid, $from, $to)->toArray();
$rows[] = [
'uuid' => $uuid,
'name' => Application::whereUuid($uuid)->first()?->name ?? $uuid,
'requests' => (int) ($overview['requests'] ?? 0),
'bandwidth' => (int) ($overview['bytesIn'] ?? 0) + (int) ($overview['bytesOut'] ?? 0),
];
}
usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
return array_slice($rows, 0, 10);
}
protected function trafficClient(): SentinelTrafficClient
{
return app(SentinelTrafficClient::class, ['server' => $this->server]);
}
/**
* @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 render()
{
return view('livewire.server.analytics');
}
}
@@ -129,6 +129,14 @@
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Analytics',
'route' => 'server.analytics',
'active' => $activeMenu === 'analytics',
'icon' => 'graph',
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Security',
'route' => 'server.security.patches',
@@ -0,0 +1,217 @@
<?php
$tabButtonBase = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
$tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]';
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
$dimensionLabels = [
'country' => 'Countries',
'referer' => 'Referrers',
'browser' => 'Browsers',
'os' => 'Operating systems',
'device' => 'Devices',
];
?>
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Analytics | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar :server="$server" activeMenu="analytics" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if (! $enabled)
<x-application.settings-section id="analytics-section" title="Analytics"
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel across all applications on this server.">
<x-slot:actions>
<a class="button" href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }}>
Server settings
<x-external-link />
</a>
</x-slot:actions>
<x-empty size="sm" title="Traffic analytics is not enabled"
description="Enable Sentinel traffic analytics for this server to start collecting request analytics."
icon-name="network" />
</x-application.settings-section>
@elseif (! $overview)
<x-application.settings-section id="analytics-section" title="Analytics"
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel across all applications on this server.">
<x-empty size="sm" title="No analytics data yet"
description="We could not load traffic analytics for the selected range. Try a different range or check back shortly."
icon-name="network" />
</x-application.settings-section>
@else
<x-application.settings-section id="analytics-range-section" title="Analytics"
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel across all applications on this server.">
<x-slot:actions>
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
<button type="button" wire:click="setRange('24h')"
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
24 hours
</button>
<button type="button" wire:click="setRange('7d')"
@class([$tabButtonBase, $range === '7d' ? $tabButtonActive : $tabButtonInactive])>
7 days
</button>
<button type="button" wire:click="setRange('30d')"
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
30 days
</button>
</div>
</x-slot:actions>
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-neutral-200 sm:grid-cols-3 lg:grid-cols-5 dark:bg-white/[0.07]">
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
<span class="text-xl font-semibold text-black dark:text-fg">{{ number_format($overview['requests'] ?? 0) }}</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Unique visitors</span>
<span class="text-xl font-semibold text-black dark:text-fg">{{ number_format($overview['uniqueVisitors'] ?? 0) }}</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Bandwidth</span>
<span class="text-xl font-semibold text-black dark:text-fg">{{ formatBytes($this->bandwidthBytes()) }}</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
<span class="text-xl font-semibold text-black dark:text-fg">{{ $this->errorRate() }}%</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">p95 latency</span>
<span class="text-xl font-semibold text-black dark:text-fg">{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms</span>
</div>
</div>
</x-application.settings-section>
<x-application.settings-section id="analytics-status-section" title="Status codes"
helper="Distribution of response status codes for the selected range.">
<div wire:ignore id="{!! $chartId !!}-status" class="min-h-[220px] w-full"></div>
@script
<script>
(() => {
checkTheme();
const statusColorsLight = ['#0ca30c', '#2a78d6', '#fab219', '#d03b3b'];
const statusColorsDark = ['#0ca30c', '#3987e5', '#fab219', '#d03b3b'];
const statusColors = () => theme === 'light' ? statusColorsLight : statusColorsDark;
const statusChart = new ApexCharts(document.getElementById('{!! $chartId !!}-status'), {
chart: {
height: 220,
type: 'donut',
toolbar: {
show: false
},
background: 'transparent',
},
series: [0, 0, 0, 0],
labels: ['2xx', '3xx', '4xx', '5xx'],
colors: statusColors(),
stroke: {
width: 2,
},
dataLabels: {
enabled: false,
},
legend: {
position: 'bottom',
labels: {
colors: textColor,
},
},
noData: {
text: 'Loading status codes…',
style: {
color: textColor,
},
},
tooltip: {
y: {
formatter: value => `${value.toLocaleString()} requests`,
},
},
});
statusChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-status', chartData => {
checkTheme();
statusChart.updateOptions({
colors: statusColors(),
series: chartData[0].seriesData,
legend: {
position: 'bottom',
labels: {
colors: textColor,
},
},
});
});
})();
</script>
@endscript
</x-application.settings-section>
<x-application.settings-section id="analytics-leaderboard-section" title="Top applications"
helper="Applications on this server ranked by request volume for the selected range." flush>
@forelse ($leaderboard as $row)
<div wire:key="analytics-leaderboard-{{ $row['uuid'] }}"
class="flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]">
<span class="min-w-0 flex-1 truncate text-[12px] text-black dark:text-fg">{{ $row['name'] }}</span>
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ number_format($row['requests']) }} req</span>
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ formatBytes($row['bandwidth']) }}</span>
</div>
@empty
<x-empty size="sm" title="No application data" description="No per-application requests were recorded for the selected range."
icon-name="unordered-list" />
@endforelse
</x-application.settings-section>
<x-application.settings-section id="analytics-paths-section" title="Top paths"
helper="Most requested paths for the selected range." flush>
@forelse ($topPaths as $path)
<div wire:key="analytics-path-{{ $loop->index }}"
class="flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]">
<span class="min-w-0 flex-1 truncate font-mono text-[12px] text-black dark:text-fg">{{ $path['path'] }}</span>
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ number_format($path['requests']) }} req</span>
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ formatBytes($path['bytesOut']) }}</span>
<span class="hidden shrink-0 text-[12px] text-neutral-500 sm:inline dark:text-fg-dim">p95 {{ number_format($path['p95'], 1) }} ms</span>
</div>
@empty
<x-empty size="sm" title="No path data" description="No requests were recorded for the selected range."
icon-name="unordered-list" />
@endforelse
</x-application.settings-section>
@foreach ($dimensionLabels as $dimension => $label)
<x-application.settings-section id="analytics-{{ $dimension }}-section" title="{{ $label }}"
helper="Top {{ strtolower($label) }} by request count for the selected range." flush>
@forelse (data_get($breakdowns, $dimension, []) as $row)
<div wire:key="analytics-{{ $dimension }}-{{ $loop->index }}"
class="flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]">
<span class="min-w-0 flex-1 truncate text-[12px] text-black dark:text-fg">{{ $row['value'] ?: 'Unknown' }}</span>
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ number_format($row['requests']) }} req</span>
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ formatBytes($row['bytesOut']) }}</span>
</div>
@empty
<x-empty size="sm" title="No data" description="No {{ strtolower($label) }} data for the selected range."
icon-name="network" />
@endforelse
@if ($dimension === 'country' && $attribution)
<p class="border-t border-neutral-200 px-4 py-2 text-[11px] text-neutral-400 dark:border-white/[0.07] dark:text-fg-faint">
{{ $attribution }}
</p>
@endif
</x-application.settings-section>
@endforeach
@endif
</div>
</div>
</div>
+2
View File
@@ -49,6 +49,7 @@ use App\Livewire\Security\CloudTokens;
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
use App\Livewire\Server\Advanced as ServerAdvanced;
use App\Livewire\Server\Analytics as ServerAnalytics;
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
use App\Livewire\Server\Charts as ServerCharts;
use App\Livewire\Server\CloudflareTunnel;
@@ -358,6 +359,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/destinations', ServerDestinations::class)->name('server.destinations');
Route::get('/log-drains', LogDrains::class)->name('server.log-drains');
Route::get('/metrics', ServerCharts::class)->name('server.metrics');
Route::get('/analytics', ServerAnalytics::class)->name('server.analytics');
Route::get('/danger', DeleteServer::class)->name('server.delete');
Route::get('/transfer', ServerTransfer::class)->name('server.transfer');
Route::get('/proxy', ProxyShow::class)->name('server.proxy');
@@ -0,0 +1,136 @@
<?php
use App\Livewire\Server\Analytics;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\SentinelTrafficClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
class FakeServerAnalyticsTrafficClient extends SentinelTrafficClient
{
public array $responses = [];
protected function raw(string $url): string
{
foreach ($this->responses as $needle => $response) {
if (str_contains($url, $needle)) {
return $response;
}
}
return '{}';
}
}
function fakeServerAnalyticsResponses(array $appUuids = []): array
{
return [
'/traffic/apps' => json_encode($appUuids),
'/traffic/overview' => json_encode([
'requests' => 1000,
'bytes_in' => 5000,
'bytes_out' => 25000,
'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10],
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
'unique_visitors' => 320,
]),
'/traffic/paths' => json_encode([
['path' => '/', 'requests' => 500, 'bytes_out' => 12000, 'p50' => 10.0, 'p95' => 30.0],
]),
'/traffic/breakdown/country' => json_encode([
['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
]),
'/traffic/breakdown/referer' => json_encode([
['value' => 'google.com', 'requests' => 300, 'bytes_out' => 8000],
]),
'/traffic/breakdown/browser' => json_encode([
['value' => 'Chrome', 'requests' => 700, 'bytes_out' => 18000],
]),
'/traffic/breakdown/os' => json_encode([
['value' => 'macOS', 'requests' => 400, 'bytes_out' => 10000],
]),
'/traffic/breakdown/device' => json_encode([
['value' => 'Desktop', 'requests' => 800, 'bytes_out' => 20000],
]),
'/traffic/attribution' => json_encode(['attribution' => 'GeoIP data by MaxMind']),
];
}
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
});
it('renders server-wide analytics with a per-app leaderboard when enabled', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
$application = Application::factory()->create([
'name' => 'Leaderboard App',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
]);
$fake = new FakeServerAnalyticsTrafficClient($server);
$fake->responses = fakeServerAnalyticsResponses([$application->uuid]);
app()->bind(SentinelTrafficClient::class, fn () => $fake);
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
->assertOk()
->assertSee('Requests')
->assertSee('1,000')
->assertSee('Unique visitors')
->assertSee('Error rate')
->assertSee('/')
->assertSee('US')
->assertSee('GeoIP data by MaxMind')
->assertSee('Leaderboard App');
});
it('shows an empty state when traffic analytics is disabled for the server', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
->assertOk()
->assertSee('Analytics')
->assertDontSee('Unique visitors');
});