Files
coolify/resources/views/livewire/analytics.blade.php
T
Andras Bacsai 2fb8c411e9 fix(analytics): keep setup prompt dismissed after refresh
Give the traffic analytics prompt a stable Livewire key and label the server link “Set up on”. Add a browser test for the link and persistent dismissal.
2026-09-24 15:26:08 +02:00

378 lines
23 KiB
PHP

<?php
$tabButtonBase = 'relative inline-flex h-7 items-center justify-center 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 = [
'referer' => 'Referrers',
'browser' => 'Browsers',
'os' => 'Operating systems',
];
$approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-1.5 inline-flex items-center rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium tracking-wide text-amber-700 uppercase dark:bg-amber-500/10 dark:text-amber-400">~ approximate</span>';
$serverListboxOptions = array_merge(
[['value' => '', 'label' => 'All servers']],
collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(),
);
$appListboxOptions = array_merge(
[['value' => '', 'label' => 'All applications']],
$appGroupedOptions,
);
?>
<div class="flex w-full min-w-0 flex-col gap-6">
@if ($scopedServerUuid === null)
<x-slot:title>
Analytics | Coolify
</x-slot>
@endif
{{-- Header --}}
<div class="flex flex-col gap-4">
@if ($scopedServerUuid === null)
<div class="min-w-0">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Analytics</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Request traffic across every application and server, reported by Sentinel.
</p>
</div>
@endif
@if ($servers->isNotEmpty() && $overview)
<div class="flex flex-wrap items-center gap-2">
@if ($scopedServerUuid === null)
<div class="relative w-full transition-opacity sm:w-52"
wire:loading.class="pointer-events-none opacity-60" wire:target="serverUuid">
<x-forms.listbox id="serverUuid" live :options="$serverListboxOptions" placeholder="All servers" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="serverUuid">
<x-loading compact aria-label="Loading analytics" />
</div>
</div>
@endif
{{-- Re-key on the server filter so the application listbox re-initializes with the
newly-scoped options (and reset value) instead of showing stale Alpine state. --}}
<div class="relative w-full transition-opacity sm:w-52" wire:key="app-filter-{{ $serverUuid }}"
wire:loading.class="pointer-events-none opacity-60" wire:target="appUuid">
<x-forms.listbox id="appUuid" live :options="$appListboxOptions" placeholder="All applications" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="appUuid">
<x-loading compact aria-label="Loading analytics" />
</div>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
@include('livewire.traffic._live-toggle')
<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')"
wire:loading.attr="disabled" wire:target="setRange"
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
<span wire:loading.class="invisible" wire:target="setRange('24h')">24 hours</span>
<x-loading compact class="absolute" wire:loading wire:target="setRange('24h')" aria-label="Loading analytics" />
</button>
<button type="button" wire:click="setRange('7d')"
wire:loading.attr="disabled" wire:target="setRange"
@class([$tabButtonBase, $range === '7d' ? $tabButtonActive : $tabButtonInactive])>
<span wire:loading.class="invisible" wire:target="setRange('7d')">7 days</span>
<x-loading compact class="absolute" wire:loading wire:target="setRange('7d')" aria-label="Loading analytics" />
</button>
<button type="button" wire:click="setRange('30d')"
wire:loading.attr="disabled" wire:target="setRange"
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
<span wire:loading.class="invisible" wire:target="setRange('30d')">30 days</span>
<x-loading compact class="absolute" wire:loading wire:target="setRange('30d')" aria-label="Loading analytics" />
</button>
</div>
</div>
</div>
@endif
</div>
{{-- Nudge: enabled-eligible servers that haven't turned traffic analytics on yet. --}}
@if ($scopedServerUuid === null && ! empty($eligibleDisabledServers))
<div wire:key="analytics-traffic-nudge" x-data="{ dismissed: localStorage.getItem('traffic-nudge-{{ $nudgeKey }}') === '1' }" x-show="!dismissed" x-cloak
class="flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="min-w-0 flex-1">
<p class="text-[12px] font-semibold text-black dark:text-fg">
{{ count($eligibleDisabledServers) === 1 ? '1 server can start collecting traffic analytics' : count($eligibleDisabledServers).' servers can start collecting traffic analytics' }}
</p>
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-dim">
Enabling regenerates the proxy config and restarts the proxy + Sentinel (a brief blip).
Works with Traefik &amp; Caddy.
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
@if (count($eligibleDisabledServers) === 1)
<a class="button" href="{{ route('server.analytics', ['server_uuid' => $eligibleDisabledServers[0]['uuid']]) }}" {{ wireNavigate() }}>
Set up on {{ \Illuminate\Support\Str::limit($eligibleDisabledServers[0]['name'], 16) }}
</a>
@else
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
@endif
<button type="button" title="Dismiss"
@click="dismissed = true; localStorage.setItem('traffic-nudge-{{ $nudgeKey }}', '1')"
class="flex h-6 w-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l12 12M6 18L18 6" />
</svg>
</button>
</div>
</div>
@endif
@if ($servers->isEmpty())
@if ($scopedServerUuid === null)
<x-empty size="sm" title="Traffic analytics is not enabled"
description="Enable traffic analytics on a server to see request analytics here."
icon-name="analytics">
<x-slot:contents>
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
</x-slot:contents>
</x-empty>
@endif
@elseif (! $overview)
<x-empty size="sm" title="No analytics data yet"
description="We could not load traffic analytics for the selected filters and range. Try a different range or check back shortly."
icon-name="analytics" />
@else
@if ($this->isLivePollable())
<div wire:poll.60s="loadData" class="hidden"></div>
@endif
{{-- KPIs --}}
<x-application.settings-section id="analytics-overview-section" title="Overview"
helper="Aggregate request volume for the selected filters and range.">
<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 bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ number_format($overview['requests'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-requests',
'initial' => $this->requestsSpark(),
'colorVar' => '--chart-status-3xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'requestsSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
Unique visitors
@if ($uniquesApproximate)
{!! $approxBadge('Summed across servers; visitors seen on multiple servers may be double-counted.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ number_format($overview['uniqueVisitors'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-visitors',
'initial' => $this->uniquesSpark(),
'colorVar' => '--chart-status-2xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'uniquesSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Bandwidth</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatBytes($this->bandwidthBytes()) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-bandwidth',
'initial' => $this->bandwidthSpark(),
'colorVar' => '--chart-spark-bandwidth',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'bandwidthSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ $this->errorRate() }}%</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-errors',
'initial' => $this->errorsSpark(),
'colorVar' => '--chart-status-5xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'errorsSpark',
])
</div>
</div>
<div class="col-span-2 flex flex-col bg-[var(--coollabs-base)] px-4 py-3 sm:col-span-1">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
p95 latency
@if ($latencyApproximate)
{!! $approxBadge('Highest p95 latency across servers; not a true cross-server percentile.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-latency',
'initial' => $this->latencySpark(),
'colorVar' => '--chart-status-4xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'latencySpark',
])
</div>
</div>
</div>
</x-application.settings-section>
{{-- Requests over time (single area series). --}}
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
helper="Total request volume over time for the selected range.">
@include('livewire.traffic._requests-chart')
</x-application.settings-section>
{{-- Status codes: stacked bar of responses by status class. --}}
<x-application.settings-section id="analytics-status-codes-section" title="Status codes"
helper="Share of responses by HTTP status class for the selected range.">
@include('livewire.traffic._status-codes')
</x-application.settings-section>
{{-- Top applications + Top hosts side by side; Top paths spans full width below.
When filtered to one app, only Top paths applies. --}}
<div @class(['grid grid-cols-1 gap-6', 'lg:grid-cols-2' => $appUuid === ''])>
@if ($appUuid === '')
<x-application.settings-section id="analytics-hosts-section" title="Top hosts"
helper="Served hostnames ranked by request volume." flush>
@include('livewire.traffic._hosts-list', ['hosts' => $topHosts])
</x-application.settings-section>
<x-application.settings-section id="analytics-apps-section" title="Top applications"
helper="Applications ranked by request volume. Open one for its analytics." flush>
@if (empty($topApps))
<x-empty size="sm" title="No application data"
description="No per-application requests were recorded for the selected range."
icon-name="unordered-list" />
@else
@php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp
<div x-data="{ page: 0, per: 10, total: {{ count($topApps) }} }">
@foreach ($topApps as $row)
@php
$appHref = $row['link'] ?? null;
$appWidth = min(100, round(($row['requests'] / $maxAppRequests) * 100, 1));
@endphp
<{{ $appHref ? 'a' : 'div' }} wire:key="analytics-app-{{ $row['uuid'] }}"
@if ($appHref) href="{{ $appHref }}" {{ wireNavigate() }} @endif
x-show="{{ $loop->index }} >= page * per && {{ $loop->index }} < (page + 1) * per"
@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]',
'transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]' => $appHref,
])>
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
<span class="truncate text-[12px] text-black dark:text-fg">{{ $row['name'] }}</span>
@if (! empty($row['domain']))
<span class="hidden truncate text-[11px] text-neutral-400 sm:inline dark:text-fg-faint">{{ $row['domain'] }}</span>
@endif
</span>
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $appWidth }}%;"></div>
</div>
<span class="w-12 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
title="{{ number_format($row['requests']) }} requests">{{ compactNumber($row['requests']) }}</span>
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($row['bandwidth']) }}</span>
@if ($appHref)
<svg class="size-3.5 shrink-0 text-neutral-300 dark:text-fg-faint" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
@endif
</{{ $appHref ? 'a' : 'div' }}>
@endforeach
@include('livewire.traffic._pager')
</div>
@endif
</x-application.settings-section>
@endif
</div>
{{-- Top paths (full width). --}}
<x-application.settings-section id="analytics-paths-section" title="Top paths"
helper="Most requested paths for the selected range." flush>
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
</x-application.settings-section>
{{-- Countries --}}
<x-application.settings-section id="analytics-country-section" title="Countries"
helper="Request volume by visitor country for the selected range." flush>
@include('livewire.traffic._geo', [
'countries' => data_get($breakdowns, 'country', []),
'attribution' => $attribution,
])
</x-application.settings-section>
{{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<x-application.settings-section id="analytics-device-section" title="Requests by device type"
helper="Share of requests by client device class for the selected range.">
@php $deviceChart = $this->deviceChartData(); @endphp
@include('livewire.traffic._device-chart', [
'labels' => $deviceChart['labels'],
'series' => $deviceChart['series'],
])
</x-application.settings-section>
@include('livewire.traffic._breakdown-section', [
'dimension' => 'protocol',
'label' => 'Top HTTP versions',
'rows' => data_get($breakdowns, 'protocol', []),
'helper' => 'Request volume by negotiated HTTP protocol version.',
])
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@include('livewire.traffic._breakdown-section', [
'dimension' => 'cache',
'label' => 'Top cache statuses',
'rows' => data_get($breakdowns, 'cache', []),
'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'status',
'label' => 'Top status codes',
'rows' => data_get($breakdowns, 'status', []),
'helper' => 'Most frequent HTTP response status codes for the selected range.',
])
</div>
{{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($dimensionLabels as $dimension => $label)
@include('livewire.traffic._breakdown-section', [
'dimension' => $dimension,
'label' => $label,
'rows' => data_get($breakdowns, $dimension, []),
])
@endforeach
@include('livewire.traffic._breakdown-section', [
'dimension' => 'agent',
'label' => 'AI agents & bots',
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => 'Top IPs',
'rows' => data_get($breakdowns, 'ip', []),
'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
])
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@include('livewire.traffic._breakdown-section', [
'dimension' => 'useragent',
'label' => 'Top user agents',
'rows' => data_get($breakdowns, 'useragent', []),
'helper' => 'Most frequent raw User-Agent strings for the selected range.',
])
@endif
</div>