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.
This commit is contained in:
Aditya Tripathi
2026-08-22 11:42:41 +00:00
parent 19752da6af
commit dd422e6608
5 changed files with 95 additions and 5 deletions
+9 -4
View File
@@ -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'),
+13
View File
@@ -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;
}
@@ -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,
@@ -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
+4 -1
View File
@@ -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]);