From dd422e6608c2f8fc9711172b976dc8b9a8b8457e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Sat, 22 Aug 2026 11:42:41 +0000 Subject: [PATCH] fix(traffic-analytics): clear stale chart and cache dashboard 404s - Dispatch the chart refresh event even when loadData() finds no overviews, so a wire:ignore'd chart flips to its no-data state instead of keeping stale data. - Cache dashboard-route absence for 60s in SentinelTrafficClient so older Sentinel installs that 404 the dashboard endpoint aren't re-probed over SSH on every refresh within the same window. - Make Pest's loadLazy() throw when no __lazyLoad trigger is found instead of silently testing the un-mounted placeholder. --- app/Livewire/Analytics.php | 13 ++++-- app/Services/SentinelTrafficClient.php | 13 ++++++ .../TrafficAnalytics/GlobalAnalyticsTest.php | 26 +++++++++++ .../SentinelTrafficClientTest.php | 43 +++++++++++++++++++ tests/Pest.php | 5 ++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 4764acfc41..f569a4ef71 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -361,6 +361,10 @@ class Analytics extends Component if (empty($overviews)) { $this->resetData(); + // The chart lives under wire:ignore, so it only updates via this event — dispatch + // even when cleared so a previously-populated chart flips to its no-data state + // instead of keeping stale data. + $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); return; } @@ -416,15 +420,16 @@ class Analytics extends Component protected function chartPayload(): array { $device = $this->deviceChartData(); + $overview = $this->overview ?? []; return [ 'hasSeries' => $this->hasSeries, 'range' => $this->range, 'seriesData' => [ - $this->overview['s2xx'] ?? 0, - $this->overview['s3xx'] ?? 0, - $this->overview['s4xx'] ?? 0, - $this->overview['s5xx'] ?? 0, + $overview['s2xx'] ?? 0, + $overview['s3xx'] ?? 0, + $overview['s4xx'] ?? 0, + $overview['s5xx'] ?? 0, ], 'timeSeries' => [ 'categories' => array_column($this->series, 'bucket'), diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index aacc4b4b4b..3cd7eabe6c 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -174,13 +174,26 @@ class SentinelTrafficClient */ private function fetchDashboard(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): ?array { + // Older Sentinel 404s this route. raw() throws on that (and doesn't cache the failure), + // so without a marker every refresh would re-probe over SSH before falling back to the + // batch. Remember the absence for the same 60s window as the data cache: at most one + // wasted probe per minute, and a Sentinel upgrade is picked up on the next window. + $absenceKey = 'traffic:dashboard-absent:'.$this->server->uuid; + if (Cache::get($absenceKey) === true) { + return null; + } + try { $decoded = json_decode($this->raw($this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit)), true); } catch (\Throwable) { + Cache::put($absenceKey, true, 60); + return null; } if (! is_array($decoded) || ! array_key_exists('overview', $decoded)) { + Cache::put($absenceKey, true, 60); + return null; } diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index 6780f7aebb..dce4ebe9cf 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -375,6 +375,32 @@ it('scopes the view to a single application and hides the leaderboard when filte ->assertDontSee('Top applications'); }); +it('still dispatches the chart refresh when every server fails so a stale chart clears', function () { + $server = bootEnabledGlobalServer(); + + // Server unreachable: the overview fetch throws, so no overviews are collected and + // loadData short-circuits via resetData(). The chart lives under wire:ignore, so it + // must still receive a refresh event to flip to its no-data state instead of keeping + // whatever it last plotted. + $fake = new class($server) extends SentinelTrafficClient + { + protected function raw(string $url): string + { + if (str_contains($url, '/traffic/overview')) { + throw new RuntimeException('server unreachable'); + } + + return '{}'; + } + }; + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSet('overview', null) + ->assertDispatched('refreshChartData-global-analytics-status'); +}); + it('shows the not-enabled empty state when no server has traffic analytics on', function () { $server = Server::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php index 4eb3dbdc60..05bfbcddf2 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php @@ -241,6 +241,49 @@ it('warms every server-wide endpoint in a single batched exec and per-call metho $client->attribution(); }); +it('probes the absent dashboard route only once per cache window, then reuses the batch fallback', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + // Older Sentinel: the dashboard route 404s (unparseable body), so raw() throws and the + // client falls back to the batch. The absence must be remembered so a second prefetch in + // the same window doesn't re-probe the dashboard over SSH. + $client = new class($server) extends SentinelTrafficClient + { + public int $dashboardProbes = 0; + + public int $batchCalls = 0; + + protected function remoteFetch(string $url): string + { + if (str_contains($url, '/traffic/dashboard')) { + $this->dashboardProbes++; + + return 'Not Found'; + } + throw new RuntimeException("unexpected individual fetch: {$url}"); + } + + protected function batchRemoteFetch(array $urls): string + { + $this->batchCalls++; + $bodies = array_map(fn ($url) => match (true) { + str_contains($url, '/traffic/apps') => json_encode(['app-a']), + str_contains($url, '/attribution') => '{"attribution":"demo"}', + str_contains($url, '/overview') => '{"requests":1}', + default => '[]', + }, $urls); + + return implode("\x1e", $bodies)."\x1e"; + } + }; + + $client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'); + $client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'); + + expect($client->dashboardProbes)->toBe(1) + ->and($client->batchCalls)->toBe(1); +}); + it('double-quotes the url in the remote curl command so & is not a shell background operator', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $client = new class($server) extends SentinelTrafficClient diff --git a/tests/Pest.php b/tests/Pest.php index ac0b667c19..d143e9c125 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -55,7 +55,10 @@ function loadLazy(Testable $component): Testable preg_match('/__lazyLoad\('([^&]+)'\)/', $component->html(), $matches); if (empty($matches)) { - return $component; + // No trigger means the component isn't lazy (or the placeholder markup changed). + // Fail loudly rather than silently asserting against the un-mounted placeholder, + // which would turn a lazy-load regression into a false-positive pass. + throw new RuntimeException('loadLazy: no __lazyLoad trigger found — component is not #[Lazy] or its placeholder markup changed.'); } return $component->call('__lazyLoad', $matches[1]);