fix(analytics): start Locations card on resource analytics tabs

Key the loaded view so the lazy-load morph replaces the placeholder
skeleton instead of patching it in place. Alpine did not start the
Locations toggle, the globe, and the table pagers on reused nodes.

Closes #12370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-29 11:28:04 +01:00
co-authored by Claude Opus 5.5
parent 623d367a2a
commit 3048e9948c
2 changed files with 203 additions and 174 deletions
@@ -39,192 +39,197 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
icon-name="network" />
</x-application.settings-section>
@else
@if ($live)
<div wire:poll.60s="loadData" class="hidden"></div>
@endif
{{-- Keyed so the lazy-load morph replaces the placeholder skeleton, which has the same
sections, instead of patching it in place: Alpine does not start the x-data
components (Locations toggle and globe, table pagers) on reused skeleton nodes. --}}
<div wire:key="analytics-loaded" class="flex flex-col gap-6">
@if ($live)
<div wire:poll.60s="loadData" class="hidden"></div>
@endif
<x-application.settings-section id="analytics-range-section" title="Analytics"
helper="Inspect traffic statistics reported by Sentinel.">
<x-slot:actions>
<div class="flex items-center gap-2">
<div class="relative w-full transition-opacity sm:w-44"
wire:loading.class="pointer-events-none opacity-60" wire:target="setRange">
<x-forms.listbox id="range" onChange="setRange" :onChangeArgs="[]" :options="$rangeListboxOptions" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="setRange">
<x-loading compact aria-label="Loading analytics" />
<x-application.settings-section id="analytics-range-section" title="Analytics"
helper="Inspect traffic statistics reported by Sentinel.">
<x-slot:actions>
<div class="flex items-center gap-2">
<div class="relative w-full transition-opacity sm:w-44"
wire:loading.class="pointer-events-none opacity-60" wire:target="setRange">
<x-forms.listbox id="range" onChange="setRange" :onChangeArgs="[]" :options="$rangeListboxOptions" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="setRange">
<x-loading compact aria-label="Loading analytics" />
</div>
</div>
<x-live-toggle :active="$live" storageKey="traffic-live" title="Turn live refresh on or off (updates every 60 seconds)" />
</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 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" title="{{ number_format($overview['requests'] ?? 0) }} requests">{{ compactNumber($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>
<x-live-toggle :active="$live" storageKey="traffic-live" title="Turn live refresh on or off (updates every 60 seconds)" />
</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 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" title="{{ number_format($overview['requests'] ?? 0) }} requests">{{ compactNumber($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 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 services; visitors of more than one service may be double-counted.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['uniqueVisitors'] ?? 0) }} unique visitors">{{ compactNumber($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 services; not a true merged percentile.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatLatency($overview['latencyP95'] ?? 0) }}</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>
<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 services; visitors of more than one service may be double-counted.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['uniqueVisitors'] ?? 0) }} unique visitors">{{ compactNumber($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 services; not a true merged percentile.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatLatency($overview['latencyP95'] ?? 0) }}</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>
<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>
<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>
<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>
@include('livewire.traffic._geo', [
'countries' => data_get($breakdowns, 'country', []),
'cities' => trafficCityRows(data_get($breakdowns, 'city', [])),
'attribution' => $attribution,
])
{{-- 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>
<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>
<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.',
<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>
<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>
@include('livewire.traffic._geo', [
'countries' => data_get($breakdowns, 'country', []),
'cities' => trafficCityRows(data_get($breakdowns, 'city', [])),
'attribution' => $attribution,
])
@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>
{{-- 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>
{{-- 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, []),
'dimension' => 'protocol',
'label' => 'Top HTTP versions',
'rows' => data_get($breakdowns, 'protocol', []),
'helper' => 'Request volume by negotiated HTTP protocol version.',
])
@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.',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
</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.',
])
<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.',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</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.',
])
</div>
@endif
</div>
+24
View File
@@ -17,6 +17,9 @@ class FakeBrowserCityTrafficClient extends SentinelTrafficClient
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
'unique_visitors' => 320,
],
'/traffic/paths' => [
['path' => '/', 'requests' => 900, 'bytes_out' => 20000],
],
'/traffic/breakdown/country' => [
['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000],
['value' => 'FR', 'requests' => 400, 'bytes_out' => 10000],
@@ -65,3 +68,24 @@ it('switches the Locations card between countries and cities and remembers the s
->assertNoJavaScriptErrors()
->screenshot(filename: 'analytics-locations-cities');
});
// The lazy placeholder of this tab has the same sections as the loaded view. The Top paths
// row makes the loaded view start Alpine components, which a morph of the skeleton broke.
it('switches the Locations card between countries and cities on the application analytics tab', function () {
$application = createBrowserApplication($this->stack, ['uuid' => 'app-geo-cities', 'name' => 'Geo App']);
loginAndSkipBoarding();
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $application);
$page = visit("{$url}/analytics");
$page->assertSee('Locations')
->assertSee('Germany')
->assertDontSee('BE, Germany')
->click('button:visible:has-text("Cities")')
->assertSee('BE, Germany')
->assertSee('IDF, France')
->assertScript("Boolean(document.getElementById('application-analytics-globe')._trafficGlobe)")
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-analytics-locations-cities');
});