mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
refactor(traffic): remove fallbacks for unreleased Sentinel builds
Every released Sentinel with traffic analytics (1.0.0 and later) has the dashboard and series routes and the requests, bytes_in and app fields, so the fallbacks for builds without them are not needed. - Keep "no data" handling for Sentinel without traffic routes (0.0.x) - Keep the resource-scope fallback, which Sentinel 1.0.1 needs
This commit is contained in:
@@ -14,8 +14,7 @@ class TrafficPathData extends Data
|
||||
public int $s5xx,
|
||||
public float $p50,
|
||||
public float $p95,
|
||||
// Owning app key (Sentinel returns this per path row so the UI can show the
|
||||
// domain). Empty on older Sentinel builds that predate the field.
|
||||
// Owning app key, so the UI can show the domain of each path row.
|
||||
public string $app = '',
|
||||
) {}
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ class TrafficSeriesBucketData extends Data
|
||||
public int $s3xx,
|
||||
public int $s4xx,
|
||||
public int $s5xx,
|
||||
// Per-bucket aggregates (added to Sentinel's series alongside the status classes)
|
||||
// so every KPI card can draw a real sparkline. Older Sentinel omits these; they
|
||||
// default to 0 and requests falls back to the status-class sum.
|
||||
// Per-bucket aggregates, so every KPI card can draw a real sparkline.
|
||||
public int $requests = 0,
|
||||
public int $bytesIn = 0,
|
||||
public int $bytesOut = 0,
|
||||
@@ -25,18 +23,13 @@ class TrafficSeriesBucketData extends Data
|
||||
|
||||
public static function fromSentinel(array $row): self
|
||||
{
|
||||
$s2xx = (int) data_get($row, 's2xx', 0);
|
||||
$s3xx = (int) data_get($row, 's3xx', 0);
|
||||
$s4xx = (int) data_get($row, 's4xx', 0);
|
||||
$s5xx = (int) data_get($row, 's5xx', 0);
|
||||
|
||||
return new self(
|
||||
bucket: (int) data_get($row, 'bucket', 0),
|
||||
s2xx: $s2xx,
|
||||
s3xx: $s3xx,
|
||||
s4xx: $s4xx,
|
||||
s5xx: $s5xx,
|
||||
requests: (int) data_get($row, 'requests', $s2xx + $s3xx + $s4xx + $s5xx),
|
||||
s2xx: (int) data_get($row, 's2xx', 0),
|
||||
s3xx: (int) data_get($row, 's3xx', 0),
|
||||
s4xx: (int) data_get($row, 's4xx', 0),
|
||||
s5xx: (int) data_get($row, 's5xx', 0),
|
||||
requests: (int) data_get($row, 'requests', 0),
|
||||
bytesIn: (int) data_get($row, 'bytes_in', 0),
|
||||
bytesOut: (int) data_get($row, 'bytes_out', 0),
|
||||
uniqueVisitors: (int) data_get($row, 'unique_visitors', 0),
|
||||
|
||||
@@ -58,7 +58,8 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Older versions left occupying executions of a deleted server active, which blocked deleting the App.
|
||||
* Removes executions that still occupy a runner of a deleted server (for example a server deleted
|
||||
* without model events), because they block deleting the GitHub App.
|
||||
*/
|
||||
private function removeRunnersOfDeletedServers(): void
|
||||
{
|
||||
|
||||
+14
-31
@@ -81,15 +81,12 @@ class Analytics extends Component
|
||||
|
||||
/**
|
||||
* Per-bucket status-class time series for the stacked area chart, summed across
|
||||
* target servers and sorted by bucket. Empty when no target Sentinel exposes the
|
||||
* series endpoint (older builds), which flips the chart back to the status donut.
|
||||
* target servers and sorted by bucket.
|
||||
*
|
||||
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
|
||||
*/
|
||||
public array $series = [];
|
||||
|
||||
public bool $hasSeries = false;
|
||||
|
||||
/**
|
||||
* Servers that could run traffic analytics but have it off — drives the nudge banner.
|
||||
*
|
||||
@@ -341,7 +338,7 @@ class Analytics extends Component
|
||||
|
||||
[$from, $to] = $this->window();
|
||||
// A selected application or service is queried in Sentinel's resource scope, or under
|
||||
// all of its keys on an older Sentinel.
|
||||
// all of its keys on a Sentinel without resource routes (before 1.0.2).
|
||||
$resource = $this->selectedResource();
|
||||
$servers = $this->targetServers();
|
||||
$domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain'];
|
||||
@@ -356,27 +353,24 @@ class Analytics extends Component
|
||||
|
||||
if ($resource !== null) {
|
||||
// One server in Sentinel's resource scope is exact; a merge across servers
|
||||
// or the per-key fallback of an older Sentinel stays approximate.
|
||||
// or the per-key fallback (Sentinel before 1.0.2) stays approximate.
|
||||
$aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, $domainForKey);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Warm every server-wide endpoint in one docker exec instead of ~15 serial
|
||||
// SSH round-trips; the per-call methods below then read from cache.
|
||||
// Warm every server-wide endpoint, including the leaderboard's per-key
|
||||
// overviews, in one dashboard fetch; the per-call methods below then read
|
||||
// from cache.
|
||||
$leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
|
||||
|
||||
if ($leaderboardKeys !== []) {
|
||||
if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) {
|
||||
Log::warning('Traffic analytics leaderboard truncated', [
|
||||
'server' => $server->uuid,
|
||||
'total' => count($leaderboardKeys),
|
||||
'shown' => self::MAX_LEADERBOARD_APPS,
|
||||
]);
|
||||
$leaderboardKeys = array_slice($leaderboardKeys, 0, self::MAX_LEADERBOARD_APPS);
|
||||
}
|
||||
// Warm the leaderboard's per-key overviews in a second batched exec.
|
||||
$client->prefetchAppOverviews($leaderboardKeys, $from, $to);
|
||||
if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) {
|
||||
Log::warning('Traffic analytics leaderboard truncated', [
|
||||
'server' => $server->uuid,
|
||||
'total' => count($leaderboardKeys),
|
||||
'shown' => self::MAX_LEADERBOARD_APPS,
|
||||
]);
|
||||
$leaderboardKeys = array_slice($leaderboardKeys, 0, self::MAX_LEADERBOARD_APPS);
|
||||
}
|
||||
|
||||
$aggregator->collect($client, null, $from, $to, $this->range, $domainForKey);
|
||||
@@ -435,31 +429,21 @@ class Analytics extends Component
|
||||
$this->breakdowns = $aggregator->breakdowns();
|
||||
$this->attribution = $aggregator->attribution();
|
||||
$this->series = $aggregator->series();
|
||||
$this->hasSeries = $this->series !== [];
|
||||
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the status chart: the stacked-area time series when available,
|
||||
* plus the donut totals as a fallback for older Sentinel builds.
|
||||
* Payload for the status chart: the stacked-area time series and the KPI sparklines.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function chartPayload(): array
|
||||
{
|
||||
$device = $this->deviceChartData();
|
||||
$overview = $this->overview ?? [];
|
||||
|
||||
return [
|
||||
'hasSeries' => $this->hasSeries,
|
||||
'range' => $this->range,
|
||||
'seriesData' => [
|
||||
$overview['s2xx'] ?? 0,
|
||||
$overview['s3xx'] ?? 0,
|
||||
$overview['s4xx'] ?? 0,
|
||||
$overview['s5xx'] ?? 0,
|
||||
],
|
||||
'timeSeries' => [
|
||||
'categories' => array_column($this->series, 'bucket'),
|
||||
'requests' => $this->requestsSpark(),
|
||||
@@ -491,7 +475,6 @@ class Analytics extends Component
|
||||
$this->breakdowns = [];
|
||||
$this->attribution = null;
|
||||
$this->series = [];
|
||||
$this->hasSeries = false;
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
|
||||
@@ -11,24 +11,19 @@ namespace App\Livewire\Concerns;
|
||||
trait BuildsTrafficChartPayload
|
||||
{
|
||||
/**
|
||||
* Per-bucket total requests, for the Requests spark. Derived from the status-class
|
||||
* counts (every request carries a status class), so it always matches real traffic
|
||||
* regardless of whether Sentinel populates the explicit per-bucket `requests` field.
|
||||
* Per-bucket total requests, for the Requests spark.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function requestsSpark(): array
|
||||
{
|
||||
return array_map(
|
||||
fn ($b) => (int) ($b['s2xx'] ?? 0) + (int) ($b['s3xx'] ?? 0) + (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
|
||||
$this->series,
|
||||
);
|
||||
return array_map(fn ($b) => (int) ($b['requests'] ?? 0), $this->series);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is plottable request-over-time data for the Requests chart. False when
|
||||
* Sentinel returned no series buckets (older builds) or every bucket is empty (no traffic
|
||||
* in the range), so the views can render a no-data state instead of a blank chart.
|
||||
* there are no series buckets (no data) or every bucket is empty (no traffic in the
|
||||
* range), so the views can render a no-data state instead of a blank chart.
|
||||
*/
|
||||
public function hasRequestSeries(): bool
|
||||
{
|
||||
@@ -49,8 +44,7 @@ trait BuildsTrafficChartPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. Empty for
|
||||
* older Sentinel builds that don't emit per-bucket byte counts.
|
||||
* Per-bucket bandwidth (bytes in + out), for the Bandwidth spark.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
|
||||
@@ -74,16 +74,12 @@ class TrafficAnalytics extends Component
|
||||
try {
|
||||
$client = $this->trafficClient($server);
|
||||
|
||||
$aggregator->addOverview($client->overview(null, $from, $to));
|
||||
|
||||
$overview = $client->overview(null, $from, $to);
|
||||
// Per-bucket status series, summed across servers, for the sparklines.
|
||||
// Isolated so a series hiccup (older Sentinel) never drops a server's overview.
|
||||
try {
|
||||
$aggregator->addSeries($client->series(null, $this->range));
|
||||
} catch (\Throwable $e) {
|
||||
// Leave this server out of the sparkline series.
|
||||
\Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
|
||||
}
|
||||
$series = $client->series(null, $this->range);
|
||||
|
||||
$aggregator->addOverview($overview);
|
||||
$aggregator->addSeries($series);
|
||||
} catch (\Throwable $e) {
|
||||
// Skip unreachable/failed servers so one bad server doesn't break the whole summary.
|
||||
\Log::debug('Traffic overview fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
|
||||
|
||||
@@ -17,7 +17,8 @@ use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyExceptio
|
||||
/**
|
||||
* Traffic analytics tab of one resource (Application or Service). A resource can record
|
||||
* under several Sentinel keys (compose services, previews). Sentinel's resource scope
|
||||
* merges them exactly; an older Sentinel gets every key fetched and merged here.
|
||||
* merges them exactly; a Sentinel without resource routes (before 1.0.2) gets every key
|
||||
* fetched and merged here.
|
||||
*/
|
||||
abstract class ResourceTrafficAnalytics extends Component
|
||||
{
|
||||
@@ -52,15 +53,12 @@ abstract class ResourceTrafficAnalytics extends Component
|
||||
public ?string $attribution = null;
|
||||
|
||||
/**
|
||||
* Per-bucket status-class time series for the stacked area chart. Empty when this
|
||||
* resource's Sentinel lacks the series endpoint, which flips the chart to the donut.
|
||||
* Per-bucket status-class time series for the stacked area chart.
|
||||
*
|
||||
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
|
||||
*/
|
||||
public array $series = [];
|
||||
|
||||
public bool $hasSeries = false;
|
||||
|
||||
/** @var array<int, string> */
|
||||
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
|
||||
|
||||
@@ -135,8 +133,8 @@ abstract class ResourceTrafficAnalytics extends Component
|
||||
[$from, $to] = SentinelTrafficClient::rangeWindow($this->range);
|
||||
$client = app(SentinelTrafficClient::class, ['server' => $resource->server()]);
|
||||
|
||||
// Sentinel's resource scope merges every key exactly in one docker exec. An older
|
||||
// Sentinel falls back to all keys of the resource, merged here (approximate).
|
||||
// Sentinel's resource scope merges every key exactly in one docker exec. A Sentinel
|
||||
// before 1.0.2 falls back to all keys of the resource, merged here (approximate).
|
||||
$aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions);
|
||||
$aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey));
|
||||
|
||||
@@ -148,7 +146,6 @@ abstract class ResourceTrafficAnalytics extends Component
|
||||
$this->breakdowns = $aggregator->breakdowns();
|
||||
$this->attribution = $aggregator->attribution();
|
||||
$this->series = $aggregator->series();
|
||||
$this->hasSeries = $this->series !== [];
|
||||
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
|
||||
} catch (\Throwable $e) {
|
||||
@@ -157,8 +154,7 @@ abstract class ResourceTrafficAnalytics extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the status chart: the stacked-area time series when available,
|
||||
* plus the donut totals as a fallback for older Sentinel builds.
|
||||
* Payload for the status chart: the stacked-area time series and the KPI sparklines.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -167,14 +163,7 @@ abstract class ResourceTrafficAnalytics extends Component
|
||||
$device = $this->deviceChartData();
|
||||
|
||||
return [
|
||||
'hasSeries' => $this->hasSeries,
|
||||
'range' => $this->range,
|
||||
'seriesData' => [
|
||||
$this->overview['s2xx'] ?? 0,
|
||||
$this->overview['s3xx'] ?? 0,
|
||||
$this->overview['s4xx'] ?? 0,
|
||||
$this->overview['s5xx'] ?? 0,
|
||||
],
|
||||
'timeSeries' => [
|
||||
'categories' => array_column($this->series, 'bucket'),
|
||||
'requests' => $this->requestsSpark(),
|
||||
|
||||
@@ -22,7 +22,7 @@ class SentinelTrafficClient
|
||||
|
||||
/**
|
||||
* Seconds to remember that this server's Sentinel has no `/api/resource/...` routes. Longer
|
||||
* than the data cache: every probe on an older Sentinel costs one or two wasted execs.
|
||||
* than the data cache: every probe on a Sentinel before 1.0.2 costs one wasted exec.
|
||||
*/
|
||||
public const RESOURCE_SCOPE_ABSENCE_TTL = 300;
|
||||
|
||||
@@ -117,10 +117,7 @@ class SentinelTrafficClient
|
||||
* Per-bucket status-class time series for the stacked-area chart.
|
||||
*
|
||||
* The series endpoints take a single `range` knob (24h/7d/30d) rather than
|
||||
* from/to, and always return a fixed-length, zero-filled array when present.
|
||||
* An older Sentinel without the route answers 404 (empty/non-array body);
|
||||
* we return an empty collection in that case so callers can gracefully fall
|
||||
* back to the donut instead of surfacing an error.
|
||||
* from/to, and always return a fixed-length, zero-filled array.
|
||||
*
|
||||
* @return Collection<int, TrafficSeriesBucketData>
|
||||
*/
|
||||
@@ -168,9 +165,8 @@ class SentinelTrafficClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm every resource endpoint in one exec with the resource dashboard bundle. When the
|
||||
* bundle is unavailable, batch the individual resource endpoints instead. Returns false
|
||||
* (and remembers it) when Sentinel has no resource routes; the caller then uses the
|
||||
* Warm every resource endpoint in one exec with the resource dashboard bundle. Returns
|
||||
* false (and remembers it) when Sentinel has no resource routes; the caller then uses the
|
||||
* per-key path with prefetchResource().
|
||||
*
|
||||
* @param array<int, string> $dimensions
|
||||
@@ -183,27 +179,15 @@ class SentinelTrafficClient
|
||||
}
|
||||
|
||||
$bundle = $this->fetchBundle($this->dashboardUrl($resourceUuid, $from, $to, $range, $pathLimit, $breakdownLimit, 0, self::SCOPE_RESOURCE));
|
||||
if ($bundle !== null) {
|
||||
$this->seedFromDashboard($resourceUuid, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle, self::SCOPE_RESOURCE);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$overviewUrl = $this->overviewUrl($resourceUuid, $from, $to, self::SCOPE_RESOURCE);
|
||||
$this->warm([
|
||||
...$this->endpointUrls($resourceUuid, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit, self::SCOPE_RESOURCE),
|
||||
$this->attributionUrl(),
|
||||
]);
|
||||
|
||||
// After a real batch, an uncached overview means the route is absent: no second probe.
|
||||
$supported = (! $this->usesBatchableTransport() || Cache::has($this->cacheKey($overviewUrl)))
|
||||
&& $this->genuineOverview($overviewUrl) !== null;
|
||||
|
||||
if (! $supported) {
|
||||
if ($bundle === null) {
|
||||
$this->markResourceScopeAbsent();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $supported;
|
||||
$this->seedFromDashboard($resourceUuid, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle, self::SCOPE_RESOURCE);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function apps(): array
|
||||
@@ -294,8 +278,7 @@ class SentinelTrafficClient
|
||||
|
||||
/**
|
||||
* Warm every endpoint for several app keys. One key uses prefetchServerWide(); several
|
||||
* keys batch their dashboard bundles (or, on an older Sentinel, their individual
|
||||
* endpoints) into one exec instead of one SSH round-trip per key.
|
||||
* keys batch their dashboard bundles into one exec instead of one SSH round-trip per key.
|
||||
*
|
||||
* @param array<int, string> $appKeys
|
||||
* @param array<int, string> $dimensions
|
||||
@@ -315,31 +298,27 @@ class SentinelTrafficClient
|
||||
return;
|
||||
}
|
||||
|
||||
if (Cache::get($this->dashboardAbsenceKey()) !== true) {
|
||||
$dashboardUrls = array_map(
|
||||
fn (string $appKey) => $this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, 0),
|
||||
$appKeys
|
||||
);
|
||||
$this->warm($dashboardUrls);
|
||||
if (Cache::get($this->dashboardAbsenceKey()) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Cache::has($this->cacheKey($dashboardUrls[0]))) {
|
||||
// Each call now reads its bundle from cache and seeds the per-endpoint cache.
|
||||
foreach ($appKeys as $appKey) {
|
||||
$this->prefetchServerWide($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit);
|
||||
}
|
||||
$dashboardUrls = array_map(
|
||||
fn (string $appKey) => $this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, 0),
|
||||
$appKeys
|
||||
);
|
||||
$this->warm($dashboardUrls);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Older Sentinel without the dashboard route: remember it like fetchDashboard() does.
|
||||
if (! Cache::has($this->cacheKey($dashboardUrls[0]))) {
|
||||
// No traffic routes (Sentinel 0.0.x) or unreachable: remember it like fetchDashboard() does.
|
||||
Cache::put($this->dashboardAbsenceKey(), true, 60);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$urls = [$this->attributionUrl()];
|
||||
// Each call now reads its bundle from cache and seeds the per-endpoint cache.
|
||||
foreach ($appKeys as $appKey) {
|
||||
array_push($urls, ...$this->endpointUrls($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit));
|
||||
$this->prefetchServerWide($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit);
|
||||
}
|
||||
$this->warm($urls);
|
||||
}
|
||||
|
||||
public function attribution(): ?string
|
||||
@@ -350,13 +329,11 @@ class SentinelTrafficClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the 60s response cache for every endpoint the dashboard reads, in as few SSH
|
||||
* round-trips as possible. Prefers Sentinel's aggregate `/traffic/dashboard` (one call
|
||||
* that returns every shape, including the per-app leaderboard), and falls back to a
|
||||
* single batched `docker exec` over the individual endpoints when that route is absent
|
||||
* (older Sentinel). Best-effort: any failure leaves the per-call methods to fetch
|
||||
* individually. Returns the recorded app uuids so the caller can warm the per-app
|
||||
* overviews when the fallback path is taken.
|
||||
* Warm the 60s response cache for every endpoint the dashboard reads with one call to
|
||||
* Sentinel's aggregate `/traffic/dashboard`, which returns every shape including the
|
||||
* per-app leaderboard. Best-effort: any failure leaves the per-call methods to fetch
|
||||
* individually. Returns the leaderboard's app uuids for the server-wide view (an empty
|
||||
* list for one key, or when the bundle is unavailable).
|
||||
*
|
||||
* @param array<int, string> $dimensions
|
||||
* @return array<int, string>
|
||||
@@ -364,33 +341,17 @@ class SentinelTrafficClient
|
||||
public function prefetchServerWide(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50, int $appsLimit = 200): array
|
||||
{
|
||||
$bundle = $this->fetchDashboard($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit);
|
||||
if ($bundle !== null) {
|
||||
$this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle);
|
||||
|
||||
if ($appKey !== null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->safeReportedKeys(array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? []));
|
||||
if ($bundle === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fallback for older Sentinel without /traffic/dashboard: batch the individual endpoints.
|
||||
$urls = [
|
||||
...$this->endpointUrls($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit),
|
||||
$this->attributionUrl(),
|
||||
];
|
||||
// The per-application leaderboard only exists on the unfiltered view.
|
||||
if ($appKey === null) {
|
||||
$urls[] = $this->appsUrl();
|
||||
}
|
||||
|
||||
$this->warm($urls);
|
||||
$this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle);
|
||||
|
||||
if ($appKey !== null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->safeReportedKeys($this->apps());
|
||||
return $this->safeReportedKeys(array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,8 +377,8 @@ class SentinelTrafficClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (older
|
||||
* Sentinel 404s) or the response isn't a real bundle. The bundle always carries an
|
||||
* Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (Sentinel
|
||||
* 0.0.x 404s), the server is unreachable, or the response isn't a real bundle. The bundle always carries an
|
||||
* `overview` member — even for an empty range — so its presence distinguishes a genuine
|
||||
* response from a stub/`{}`.
|
||||
*
|
||||
@@ -425,10 +386,10 @@ 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.
|
||||
// Sentinel 0.0.x 404s this route. raw() throws on that (and doesn't cache the failure),
|
||||
// so without a marker every refresh would re-probe over SSH. 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 = $this->dashboardAbsenceKey();
|
||||
if (Cache::get($absenceKey) === true) {
|
||||
return null;
|
||||
@@ -511,11 +472,7 @@ class SentinelTrafficClient
|
||||
*/
|
||||
private function fetchSeries(string $url): Collection
|
||||
{
|
||||
$rows = json_decode($this->raw($url), true);
|
||||
|
||||
if (! is_array($rows) || $rows === []) {
|
||||
return collect();
|
||||
}
|
||||
$rows = json_decode($this->raw($url), true) ?? [];
|
||||
|
||||
return collect($rows)->map(fn ($r) => TrafficSeriesBucketData::fromSentinel($r));
|
||||
}
|
||||
@@ -550,44 +507,11 @@ class SentinelTrafficClient
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the per-app overview cache for the leaderboard in one batched exec.
|
||||
*
|
||||
* @param array<int, string> $appKeys
|
||||
*/
|
||||
public function prefetchAppOverviews(array $appKeys, string $from, string $to): void
|
||||
{
|
||||
$urls = array_map(fn ($appKey) => $this->overviewUrl($appKey, $from, $to), $appKeys);
|
||||
|
||||
$this->warm($urls);
|
||||
}
|
||||
|
||||
private function dashboardAbsenceKey(): string
|
||||
{
|
||||
return 'traffic:dashboard-absent:'.$this->server->uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* The individual endpoint urls (overview, paths, series, breakdowns) for one key or the
|
||||
* whole server, used by the batched fallback on Sentinel builds without the dashboard.
|
||||
*
|
||||
* @param array<int, string> $dimensions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function endpointUrls(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit, int $breakdownLimit, string $scope = self::SCOPE_APP): array
|
||||
{
|
||||
$urls = [
|
||||
$this->overviewUrl($appKey, $from, $to, $scope),
|
||||
$this->pathsUrl($appKey, $from, $to, $pathLimit, $scope),
|
||||
$this->seriesUrl($appKey, $range, $scope),
|
||||
];
|
||||
foreach ($dimensions as $dimension) {
|
||||
$urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit, $scope);
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
private function overviewUrl(?string $appKey, string $from, string $to, string $scope = self::SCOPE_APP): string
|
||||
{
|
||||
$path = $this->appScopedPath($appKey, 'overview', $scope);
|
||||
|
||||
@@ -68,8 +68,6 @@ class TrafficAnalyticsAggregator
|
||||
|
||||
/**
|
||||
* Fetch every shape for one app key (or the whole server when null) and merge it in.
|
||||
* The series fetch is isolated: a failure (or an older Sentinel without the endpoint)
|
||||
* never discards the other data; an empty series flips the chart to the donut.
|
||||
*
|
||||
* @param callable(string): ?string $domainForKey resolves a path row's app key to its domain
|
||||
*/
|
||||
@@ -104,7 +102,7 @@ class TrafficAnalyticsAggregator
|
||||
private function collectScope(SentinelTrafficClient $client, ?string $key, bool $resourceScope, string $from, string $to, string $range, callable $domainForKey, int $limit): void
|
||||
{
|
||||
$this->addOverview($resourceScope ? $client->resourceOverview($key, $from, $to) : $client->overview($key, $from, $to));
|
||||
$this->addPaths($resourceScope ? $client->resourcePaths($key, $from, $to, $limit) : $client->paths($key, $from, $to, $limit), $key, $domainForKey);
|
||||
$this->addPaths($resourceScope ? $client->resourcePaths($key, $from, $to, $limit) : $client->paths($key, $from, $to, $limit), $domainForKey);
|
||||
|
||||
foreach ($this->dimensions as $dimension) {
|
||||
$this->addBreakdown($dimension, $resourceScope
|
||||
@@ -114,11 +112,7 @@ class TrafficAnalyticsAggregator
|
||||
|
||||
$this->attribution ??= $client->attribution();
|
||||
|
||||
try {
|
||||
$this->addSeries($resourceScope ? $client->resourceSeries($key, $range) : $client->series($key, $range));
|
||||
} catch (\Throwable) {
|
||||
// Leave this source out of the series; the donut fallback covers it.
|
||||
}
|
||||
$this->addSeries($resourceScope ? $client->resourceSeries($key, $range) : $client->series($key, $range));
|
||||
}
|
||||
|
||||
public function addOverview(TrafficOverviewData $overview): void
|
||||
@@ -128,24 +122,22 @@ class TrafficAnalyticsAggregator
|
||||
|
||||
/**
|
||||
* Merge path rows keyed by (app, path), so the same path under two apps stays two
|
||||
* rows, each with its own domain. Sentinel's per-row `app` wins; older Sentinel omits
|
||||
* it, so a key-scoped fetch falls back to the key it queried.
|
||||
* rows, each with the domain of the row's Sentinel `app` key.
|
||||
*
|
||||
* @param iterable<int, mixed> $paths
|
||||
* @param callable(string): ?string $domainForKey
|
||||
*/
|
||||
public function addPaths(iterable $paths, ?string $fallbackAppKey, callable $domainForKey): void
|
||||
public function addPaths(iterable $paths, callable $domainForKey): void
|
||||
{
|
||||
foreach ($paths as $path) {
|
||||
$data = $path->toArray();
|
||||
$pathStr = (string) ($data['path'] ?? '');
|
||||
$appId = (string) ($data['app'] ?? '');
|
||||
$resolveId = $appId !== '' ? $appId : ($fallbackAppKey ?? '');
|
||||
$key = $resolveId."\n".$pathStr;
|
||||
$key = $appId."\n".$pathStr;
|
||||
|
||||
$this->paths[$key] ??= [
|
||||
'path' => $pathStr,
|
||||
'domain' => $resolveId !== '' ? $domainForKey($resolveId) : null,
|
||||
'domain' => $appId !== '' ? $domainForKey($appId) : null,
|
||||
'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0,
|
||||
];
|
||||
$this->paths[$key]['requests'] += (int) ($data['requests'] ?? 0);
|
||||
|
||||
@@ -172,7 +172,6 @@ it('loads the per-app status time series when Sentinel exposes the series endpoi
|
||||
|
||||
loadLazy(Livewire::test(Analytics::class, ['application' => $application]))
|
||||
->assertOk()
|
||||
->assertSet('hasSeries', true)
|
||||
->assertSet('series', [
|
||||
['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytesIn' => 1000, 'bytesOut' => 5000, 'uniqueVisitors' => 12, 'p95' => 30.0],
|
||||
['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytesIn' => 1500, 'bytesOut' => 8000, 'uniqueVisitors' => 20, 'p95' => 45.0],
|
||||
@@ -201,22 +200,6 @@ it('decorates per-app paths with the app domain and surfaces AI agents', functio
|
||||
expect($component->instance()->topPaths[0]['domain'])->toBe('api.example.com');
|
||||
});
|
||||
|
||||
it('falls back to the donut for the per-app chart when the series endpoint is absent', function () {
|
||||
$application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
|
||||
|
||||
$responses = fakeAnalyticsResponses();
|
||||
unset($responses['/traffic/series']);
|
||||
|
||||
$fake = new FakeAnalyticsTrafficClient($application->destination->server);
|
||||
$fake->responses = $responses;
|
||||
app()->bind(SentinelTrafficClient::class, fn () => $fake);
|
||||
|
||||
loadLazy(Livewire::test(Analytics::class, ['application' => $application]))
|
||||
->assertOk()
|
||||
->assertSet('hasSeries', false)
|
||||
->assertSet('series', []);
|
||||
});
|
||||
|
||||
it('shows an empty state when traffic analytics is disabled for the server', function () {
|
||||
$application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false);
|
||||
|
||||
|
||||
@@ -36,7 +36,12 @@ class FakeGlobalAnalyticsTrafficClient extends SentinelTrafficClient
|
||||
function fakeGlobalAnalyticsResponses(array $appUuids = []): array
|
||||
{
|
||||
return [
|
||||
'/traffic/apps' => json_encode($appUuids),
|
||||
// The leaderboard keys come from the dashboard bundle; the fake's raw() then serves
|
||||
// every per-call fetch from the entries below.
|
||||
'/traffic/dashboard' => json_encode([
|
||||
'overview' => [],
|
||||
'apps' => array_map(fn (string $uuid) => ['uuid' => $uuid, 'overview' => []], $appUuids),
|
||||
]),
|
||||
'/traffic/overview' => json_encode([
|
||||
'requests' => 1000,
|
||||
'bytes_in' => 5000,
|
||||
@@ -271,7 +276,6 @@ it('builds a stacked status time series when Sentinel exposes the series endpoin
|
||||
|
||||
loadLazy(Livewire::test(Analytics::class))
|
||||
->assertOk()
|
||||
->assertSet('hasSeries', true)
|
||||
->assertSet('series', [
|
||||
['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytesIn' => 1000, 'bytesOut' => 5000, 'uniqueVisitors' => 12, 'p95' => 30.0],
|
||||
['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytesIn' => 1500, 'bytesOut' => 8000, 'uniqueVisitors' => 20, 'p95' => 45.0],
|
||||
@@ -317,23 +321,6 @@ it('derives KPI sparklines, device-donut data, and top hosts for the chart paylo
|
||||
expect($instance->topHosts[0]['requests'])->toBe(1000);
|
||||
});
|
||||
|
||||
it('falls back to the donut when Sentinel lacks the series endpoint', function () {
|
||||
$server = bootEnabledGlobalServer();
|
||||
|
||||
// Same responses minus the series entry — an older Sentinel returns 404 (empty body).
|
||||
$responses = fakeGlobalAnalyticsResponses();
|
||||
unset($responses['/traffic/series']);
|
||||
|
||||
$fake = new FakeGlobalAnalyticsTrafficClient($server);
|
||||
$fake->responses = $responses;
|
||||
app()->bind(SentinelTrafficClient::class, fn () => $fake);
|
||||
|
||||
loadLazy(Livewire::test(Analytics::class))
|
||||
->assertOk()
|
||||
->assertSet('hasSeries', false)
|
||||
->assertSet('series', []);
|
||||
});
|
||||
|
||||
it('does not disclose another team application name for a sentinel-reported uuid', function () {
|
||||
$server = bootEnabledGlobalServer();
|
||||
|
||||
|
||||
@@ -231,66 +231,23 @@ it('serves every resource endpoint from one resource dashboard exec', function (
|
||||
->and($client->fetched)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('falls back to one batch of individual resource endpoints when the resource dashboard is absent', function () {
|
||||
$client = new class($this->server) extends SentinelTrafficClient
|
||||
{
|
||||
public int $batchCalls = 0;
|
||||
|
||||
protected function remoteFetch(string $url): string
|
||||
{
|
||||
if (str_contains($url, '/traffic/dashboard')) {
|
||||
return '';
|
||||
}
|
||||
throw new RuntimeException("unexpected fetch: {$url}");
|
||||
}
|
||||
|
||||
protected function batchRemoteFetch(array $urls): string
|
||||
{
|
||||
$this->batchCalls++;
|
||||
|
||||
return implode("\x1e", array_map(fn ($url) => match (true) {
|
||||
str_contains($url, '/resource/res/traffic/overview') => json_encode(resourceScopeOverview(3, 2)),
|
||||
str_contains($url, '/attribution') => '{"attribution":"demo"}',
|
||||
str_contains($url, '/resource/res/traffic/') => '[]',
|
||||
default => throw new RuntimeException("unexpected batch url: {$url}"),
|
||||
}, $urls))."\x1e";
|
||||
}
|
||||
};
|
||||
|
||||
expect($client->prefetchResourceScope('res', 'F', 'T', ['country'], '24h'))->toBeTrue()
|
||||
->and($client->batchCalls)->toBe(1)
|
||||
->and($client->resourceOverview('res', 'F', 'T')->requests)->toBe(3)
|
||||
->and($client->attribution())->toBe('demo');
|
||||
$client->resourceBreakdown('res', 'country', 'F', 'T');
|
||||
});
|
||||
|
||||
it('remembers absent resource routes and does not probe them again within the ttl', function () {
|
||||
$client = new class($this->server) extends SentinelTrafficClient
|
||||
{
|
||||
public int $remoteFetches = 0;
|
||||
|
||||
public int $batchCalls = 0;
|
||||
|
||||
protected function remoteFetch(string $url): string
|
||||
{
|
||||
$this->remoteFetches++;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function batchRemoteFetch(array $urls): string
|
||||
{
|
||||
$this->batchCalls++;
|
||||
|
||||
return implode("\x1e", array_fill(0, count($urls), ''))."\x1e";
|
||||
}
|
||||
};
|
||||
|
||||
expect($client->prefetchResourceScope('res', 'F', 'T', ['country'], '24h'))->toBeFalse()
|
||||
->and($client->prefetchResourceScope('res', 'F', 'T', ['country'], '24h'))->toBeFalse()
|
||||
->and($client->tryResourceOverview('res', 'F', 'T'))->toBeNull()
|
||||
->and($client->remoteFetches)->toBe(1)
|
||||
->and($client->batchCalls)->toBe(1)
|
||||
->and($client->supportsResourceScope())->toBeFalse();
|
||||
|
||||
$this->travel(SentinelTrafficClient::RESOURCE_SCOPE_ABSENCE_TTL + 1)->seconds();
|
||||
@@ -394,7 +351,10 @@ it('uses the resource scope when the global analytics filter selects a resource'
|
||||
it('keeps the unfiltered leaderboard on per-key rows without resource queries', function () {
|
||||
bindResourceScopeFake([
|
||||
'/resource/' => new RuntimeException('the leaderboard must not query the resource scope'),
|
||||
'/traffic/apps' => json_encode([$this->apiKey, $this->webKey]),
|
||||
'/traffic/dashboard' => json_encode([
|
||||
'overview' => [],
|
||||
'apps' => [['uuid' => $this->apiKey, 'overview' => []], ['uuid' => $this->webKey, 'overview' => []]],
|
||||
]),
|
||||
"/app/{$this->apiKey}/traffic/overview" => json_encode(resourceScopeOverview(100, 10)),
|
||||
"/app/{$this->webKey}/traffic/overview" => json_encode(resourceScopeOverview(50, 8)),
|
||||
'/traffic/overview' => json_encode(resourceScopeOverview(150, 15)),
|
||||
|
||||
@@ -61,6 +61,19 @@ function resourceKeysOverview(int $requests, int $uniques = 10, float $p95 = 20.
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server-wide dashboard bundle; the global leaderboard reads its keys from `apps`.
|
||||
*
|
||||
* @param array<int, string> $keys
|
||||
*/
|
||||
function resourceKeysDashboard(array $keys): string
|
||||
{
|
||||
return json_encode([
|
||||
'overview' => [],
|
||||
'apps' => array_map(fn (string $key) => ['uuid' => $key, 'overview' => []], $keys),
|
||||
]);
|
||||
}
|
||||
|
||||
function bindResourceKeysFake(array $responses): void
|
||||
{
|
||||
app()->bind(SentinelTrafficClient::class, function ($app, $params) use ($responses) {
|
||||
@@ -187,39 +200,6 @@ it('warms the key list and every key bundle in two batched execs', function () {
|
||||
$client->breakdown('res-web', 'country', 'F', 'T');
|
||||
});
|
||||
|
||||
it('batches the individual endpoints of every key when Sentinel lacks the dashboard route', function () {
|
||||
$client = new class($this->server) extends SentinelTrafficClient
|
||||
{
|
||||
public int $batchCalls = 0;
|
||||
|
||||
protected function batchRemoteFetch(array $urls): string
|
||||
{
|
||||
$this->batchCalls++;
|
||||
|
||||
return implode("\x1e", array_map(fn ($url) => match (true) {
|
||||
str_contains($url, '/traffic/apps') => json_encode(['res-api', 'res-web']),
|
||||
str_contains($url, '/traffic/dashboard') => 'Not Found',
|
||||
str_contains($url, '/attribution') => '{"attribution":"demo"}',
|
||||
str_contains($url, '/overview') => '{"requests":3}',
|
||||
default => '[]',
|
||||
}, $urls))."\x1e";
|
||||
}
|
||||
|
||||
protected function remoteFetch(string $url): string
|
||||
{
|
||||
throw new RuntimeException("individual fetch should not run for: {$url}");
|
||||
}
|
||||
};
|
||||
|
||||
$keys = $client->prefetchResource('res', 'F', 'T', ['country'], '24h');
|
||||
|
||||
expect($keys)->toBe(['res-api', 'res-web'])
|
||||
->and($client->batchCalls)->toBe(3)
|
||||
->and($client->overview('res-api', 'F', 'T')->requests)->toBe(3)
|
||||
->and($client->overview('res-web', 'F', 'T')->requests)->toBe(3)
|
||||
->and($client->attribution())->toBe('demo');
|
||||
});
|
||||
|
||||
it('merges overviews, paths, breakdowns, and series of several keys', function () {
|
||||
$aggregator = new TrafficAnalyticsAggregator(['country']);
|
||||
|
||||
@@ -231,9 +211,8 @@ it('merges overviews, paths, breakdowns, and series of several keys', function (
|
||||
$aggregator->addPaths(collect([
|
||||
TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-api', 'requests' => 10, 'p95' => 5]),
|
||||
TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-api', 'requests' => 5, 'p95' => 9]),
|
||||
]), 'k-api', $domainForKey);
|
||||
// Older Sentinel omits `app`: the queried key is the fallback.
|
||||
$aggregator->addPaths(collect([TrafficPathData::fromSentinel(['path' => '/', 'requests' => 30])]), 'k-web', $domainForKey);
|
||||
]), $domainForKey);
|
||||
$aggregator->addPaths(collect([TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-web', 'requests' => 30])]), $domainForKey);
|
||||
|
||||
$aggregator->addBreakdown('country', collect([TrafficBreakdownData::fromSentinel(['value' => 'US', 'requests' => 4, 'bytes_out' => 1])]));
|
||||
$aggregator->addBreakdown('country', collect([TrafficBreakdownData::fromSentinel(['value' => 'US', 'requests' => 6, 'bytes_out' => 2])]));
|
||||
@@ -307,7 +286,7 @@ it('groups compose keys of one application into one leaderboard row', function (
|
||||
$webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web');
|
||||
|
||||
bindResourceKeysFake([
|
||||
'/traffic/apps' => json_encode([$apiKey, $webKey, 'orphan-key']),
|
||||
'/traffic/dashboard' => resourceKeysDashboard([$apiKey, $webKey, 'orphan-key']),
|
||||
"/app/{$apiKey}/traffic/overview" => resourceKeysOverview(100),
|
||||
"/app/{$webKey}/traffic/overview" => resourceKeysOverview(50),
|
||||
'/app/orphan-key/traffic/overview' => resourceKeysOverview(5),
|
||||
@@ -345,7 +324,7 @@ it('lists services in the filter, links service rows to service analytics, and q
|
||||
$serviceKey = $service->uuid.'-'.traefikSafeServiceNameSegment('web');
|
||||
|
||||
bindResourceKeysFake([
|
||||
'/traffic/apps' => json_encode([$serviceKey]),
|
||||
'/traffic/dashboard' => resourceKeysDashboard([$serviceKey]),
|
||||
"/app/{$serviceKey}/traffic/overview" => resourceKeysOverview(70),
|
||||
'/traffic/overview' => resourceKeysOverview(70),
|
||||
]);
|
||||
@@ -385,7 +364,7 @@ it('never names or links a key owned by another team resource', function () {
|
||||
$otherKey = $otherService->uuid.'-'.traefikSafeServiceNameSegment('web');
|
||||
|
||||
bindResourceKeysFake([
|
||||
'/traffic/apps' => json_encode([$otherKey]),
|
||||
'/traffic/dashboard' => resourceKeysDashboard([$otherKey]),
|
||||
'/traffic/overview' => resourceKeysOverview(10),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Analytics;
|
||||
use App\Livewire\Dashboard\TrafficAnalytics;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
@@ -43,7 +44,7 @@ it('does not run a remote command for a server that is not reachable', function
|
||||
[$from, $to] = SentinelTrafficClient::rangeWindow('24h');
|
||||
|
||||
expect(fn () => $client->overview(null, $from, $to))->toThrow(RuntimeException::class);
|
||||
$client->prefetchAppOverviews(['app'], $from, $to);
|
||||
expect($client->prefetchServerWide(null, $from, $to, [], '24h'))->toBe([]);
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
@@ -92,3 +93,28 @@ it('fetches a healthy server with a bounded ssh and curl timeout', function () {
|
||||
Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30
|
||||
&& str_contains($process->command, '--max-time'));
|
||||
});
|
||||
|
||||
it('shows no data without an error when Sentinel has no traffic routes (0.0.x answers 404)', function () {
|
||||
markTrafficServerReachable($this->server);
|
||||
// Gin's 404 body for every unknown route, including /api/traffic/dashboard.
|
||||
Process::fake(fn () => Process::result(output: '404 page not found'));
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
loadLazy(Livewire::test(Analytics::class))
|
||||
->assertOk()
|
||||
->assertSet('overview', null)
|
||||
->assertSet('topApps', [])
|
||||
->assertSet('series', [])
|
||||
->assertNotDispatched('error');
|
||||
|
||||
loadLazy(Livewire::test(TrafficAnalytics::class))
|
||||
->assertOk()
|
||||
->assertSet('overview', null)
|
||||
->assertSet('series', [])
|
||||
->assertNotDispatched('error');
|
||||
|
||||
Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, '/api/traffic/dashboard'));
|
||||
});
|
||||
|
||||
@@ -131,17 +131,6 @@ it('builds the per-app series url and defaults an unknown range to 24h', functio
|
||||
->toContain('range=24h');
|
||||
});
|
||||
|
||||
it('returns an empty series when the endpoint is absent (older Sentinel 404)', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$client = new FakeTrafficClient($server);
|
||||
|
||||
// Empty body / unparseable / empty array all mean "no series" → donut fallback.
|
||||
foreach (['', 'Not Found', '{}', '[]'] as $body) {
|
||||
$client->response = $body;
|
||||
expect($client->series(null, '24h'))->toBeEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a malicious app key for the series endpoint', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$client = new FakeTrafficClient($server);
|
||||
@@ -200,54 +189,12 @@ it('serves every endpoint from one aggregate dashboard fetch when Sentinel expos
|
||||
$client->breakdown(null, 'country', 'F', 'T');
|
||||
});
|
||||
|
||||
it('warms every server-wide endpoint in a single batched exec and per-call methods hit cache', function () {
|
||||
it('probes the absent dashboard route only once per cache window and returns no leaderboard', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
// Batches responses; individual remoteFetch() must never run once the batch has warmed
|
||||
// the cache, proving the round-trips collapsed into one exec.
|
||||
$client = new class($server) extends SentinelTrafficClient
|
||||
{
|
||||
public array $batchedCalls = [];
|
||||
|
||||
protected function batchRemoteFetch(array $urls): string
|
||||
{
|
||||
$this->batchedCalls[] = $urls;
|
||||
|
||||
// One framed body per url, matched by endpoint so ordering stays irrelevant.
|
||||
$bodies = array_map(fn ($url) => match (true) {
|
||||
str_contains($url, '/traffic/apps') => json_encode(['app-a', 'app-b']),
|
||||
str_contains($url, '/attribution') => '{"attribution":"demo"}',
|
||||
str_contains($url, '/overview') => '{"requests":1}',
|
||||
default => '[]', // paths, series, breakdowns
|
||||
}, $urls);
|
||||
|
||||
return implode("\x1e", $bodies)."\x1e";
|
||||
}
|
||||
|
||||
protected function remoteFetch(string $url): string
|
||||
{
|
||||
throw new RuntimeException("individual fetch should not run for: {$url}");
|
||||
}
|
||||
};
|
||||
|
||||
$apps = $client->prefetchServerWide(null, 'F', 'T', ['country', 'browser'], '24h');
|
||||
|
||||
expect($client->batchedCalls)->toHaveCount(1)
|
||||
->and($apps)->toBe(['app-a', 'app-b']);
|
||||
|
||||
// These now read from the warmed cache; remoteFetch() would throw if they didn't.
|
||||
expect($client->overview(null, 'F', 'T')->requests)->toBe(1);
|
||||
$client->breakdown(null, 'country', 'F', 'T');
|
||||
$client->series(null, '24h');
|
||||
$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.
|
||||
// Sentinel 0.0.x has no traffic routes: Gin answers 404 with a plain-text body, so raw()
|
||||
// throws. The absence must be remembered so a second prefetch in the same window doesn't
|
||||
// re-probe the dashboard over SSH, and nothing falls back to individual endpoints.
|
||||
$client = new class($server) extends SentinelTrafficClient
|
||||
{
|
||||
public int $dashboardProbes = 0;
|
||||
@@ -259,7 +206,7 @@ it('probes the absent dashboard route only once per cache window, then reuses th
|
||||
if (str_contains($url, '/traffic/dashboard')) {
|
||||
$this->dashboardProbes++;
|
||||
|
||||
return 'Not Found';
|
||||
return '404 page not found';
|
||||
}
|
||||
throw new RuntimeException("unexpected individual fetch: {$url}");
|
||||
}
|
||||
@@ -267,22 +214,15 @@ it('probes the absent dashboard route only once per cache window, then reuses th
|
||||
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";
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$client->prefetchServerWide(null, 'F', 'T', ['country'], '24h');
|
||||
$client->prefetchServerWide(null, 'F', 'T', ['country'], '24h');
|
||||
|
||||
expect($client->dashboardProbes)->toBe(1)
|
||||
->and($client->batchCalls)->toBe(1);
|
||||
expect($client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'))->toBe([])
|
||||
->and($client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'))->toBe([])
|
||||
->and($client->dashboardProbes)->toBe(1)
|
||||
->and($client->batchCalls)->toBe(0);
|
||||
});
|
||||
|
||||
it('double-quotes the url in the remote curl command so & is not a shell background operator', function () {
|
||||
@@ -342,14 +282,3 @@ it('skips an unsafe app key in the dashboard bundle and keeps the other apps', f
|
||||
->and($client->overview('app-b', 'F', 'T')->requests)->toBe(1);
|
||||
Log::shouldHaveReceived('warning')->withArgs(fn ($message) => str_contains($message, 'unsafe'))->once();
|
||||
});
|
||||
|
||||
it('skips an unsafe app key reported by an older Sentinel without the dashboard route', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
Log::spy();
|
||||
|
||||
$client = new FakeTrafficClient($server);
|
||||
$client->response = json_encode(['app-a', 'bad"key', 'app-b']);
|
||||
|
||||
expect($client->prefetchServerWide(null, 'F', 'T', [], '24h'))->toBe(['app-a', 'app-b']);
|
||||
Log::shouldHaveReceived('warning')->withArgs(fn ($message) => str_contains($message, 'unsafe'))->once();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user