mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
feat(traffic): add server analytics view
This commit is contained in:
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user