diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index abf9d88cfd..be5d603eeb 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -438,6 +438,9 @@ class Analytics extends Component foreach ($this->breakdownDimensions as $dimension) { $rows = array_values($breakdownTotals[$dimension]); usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']); + if ($dimension === 'referer') { + $rows = groupRefererBreakdownRows($rows); + } $breakdowns[$dimension] = array_slice($rows, 0, 50); } $this->breakdowns = $breakdowns; diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 9cd1d45932..7e8a68dfca 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -62,8 +62,6 @@ class Index extends Component public ?string $remoteServerUser = 'root'; - public bool $isSwarmManager = false; - public bool $isCloudflareTunnel = false; public ?Server $createdServer = null; @@ -328,7 +326,6 @@ class Index extends Component 'private_key_id' => $this->createdPrivateKey->id, 'team_id' => currentTeam()->id, ]); - $this->createdServer->settings->is_swarm_manager = $this->isSwarmManager; $this->createdServer->settings->is_cloudflare_tunnel = $this->isCloudflareTunnel; $this->createdServer->settings->save(); $this->selectedExistingServer = $this->createdServer->id; diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index a3605f28eb..2569743878 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -29,9 +29,6 @@ class Docker extends Component #[Validate(['required', 'string'])] public string $serverId; - #[Validate(['required', 'boolean'])] - public bool $isSwarm = false; - public function mount(?string $server_id = null): void { $this->network = new_public_id(); @@ -74,9 +71,10 @@ class Docker extends Component public function submit(): mixed { try { - $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class); + $isSwarm = $this->selectedServer->isSwarm(); + $this->authorize('create', $isSwarm ? SwarmDocker::class : StandaloneDocker::class); $this->validate(); - if ($this->isSwarm) { + if ($isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); if ($found) { throw new \Exception('Network already added to this server.'); diff --git a/app/Livewire/Project/Application/Analytics.php b/app/Livewire/Project/Application/Analytics.php index 0c5e30b737..52d15d9e10 100644 --- a/app/Livewire/Project/Application/Analytics.php +++ b/app/Livewire/Project/Application/Analytics.php @@ -106,9 +106,12 @@ class Analytics extends Component $breakdowns = []; foreach ($this->breakdownDimensions as $dimension) { - $breakdowns[$dimension] = $client->breakdown($key, $dimension, $from, $to, 50) + $rows = $client->breakdown($key, $dimension, $from, $to, 50) ->map(fn ($row) => $row->toArray()) ->all(); + $breakdowns[$dimension] = $dimension === 'referer' + ? groupRefererBreakdownRows($rows) + : $rows; } $this->breakdowns = $breakdowns; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index e6e8ec0d69..3dbf7f73bd 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -44,8 +44,10 @@ class Show extends Component public bool $isUsable; + #[Locked] public bool $isSwarmManager; + #[Locked] public bool $isSwarmWorker; public string $serverRole; @@ -249,9 +251,7 @@ class Show extends Component $this->server->save(); $this->server->settings->connection_timeout = $this->connectionTimeout; - $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; - $this->server->settings->is_swarm_worker = $this->isSwarmWorker; $role = ServerRole::from($this->serverRole); $this->server->settings->server_role = $role; $this->server->settings->is_build_server = $role === ServerRole::BUILD; diff --git a/app/Livewire/Server/Swarm.php b/app/Livewire/Server/Swarm.php index af785a8c20..376705dcb7 100644 --- a/app/Livewire/Server/Swarm.php +++ b/app/Livewire/Server/Swarm.php @@ -4,6 +4,7 @@ namespace App\Livewire\Server; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class Swarm extends Component @@ -18,11 +19,15 @@ class Swarm extends Component public bool $isSwarmWorker; + #[Locked] + public bool $canUseSwarm; + public function mount(string $server_uuid) { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->parameters = get_route_parameters(); + $this->canUseSwarm = $this->server->team->usesSwarm(); $this->syncData(); } catch (\Throwable) { return redirect()->route('server.index'); @@ -32,6 +37,9 @@ class Swarm extends Component private function syncData(bool $toModel = false): void { if ($toModel) { + if (! $this->server->team->usesSwarm()) { + throw new \Exception('Docker Swarm is deprecated and cannot be enabled for new teams.'); + } $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; $this->server->settings->save(); diff --git a/app/Models/Team.php b/app/Models/Team.php index 844d6ecb15..b8c22cfb2e 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -311,6 +311,18 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(Server::class); } + public function usesSwarm(): bool + { + return $this->servers() + ->where(function ($query) { + $query->whereHas('settings', function ($settings) { + $settings->where('is_swarm_manager', true) + ->orWhere('is_swarm_worker', true); + })->orWhereHas('swarmDockers'); + }) + ->exists(); + } + public function privateKeys() { return $this->hasMany(PrivateKey::class); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f4a6b60058..dbe8814bd7 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5047,6 +5047,31 @@ function refererHost(?string $referer): ?string return str_starts_with($host, 'www.') ? substr($host, 4) : $host; } +/** + * Group referrer breakdown rows by hostname and sum their metrics. + * + * @param array $rows + * @return array + */ +function groupRefererBreakdownRows(array $rows): array +{ + $grouped = []; + + foreach ($rows as $row) { + $value = (string) ($row['value'] ?? ''); + $host = $value === '__other__' ? $value : (refererHost($value) ?? $value); + + $grouped[$host] ??= ['value' => $host, 'requests' => 0, 'bytesOut' => 0]; + $grouped[$host]['requests'] += (int) ($row['requests'] ?? 0); + $grouped[$host]['bytesOut'] += (int) ($row['bytesOut'] ?? 0); + } + + $rows = array_values($grouped); + usort($rows, fn (array $left, array $right): int => $right['requests'] <=> $left['requests']); + + return $rows; +} + /** * Favicon URL for a host, served by DuckDuckGo's icon proxy. Used to decorate * referrer rows in analytics. diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 5fc0a707fe..d91c852557 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -116,7 +116,7 @@ 'active' => $activeMenu === 'swarm', 'icon' => 'layers', 'group' => 'Networking', - 'visible' => ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel, + 'visible' => $server->team->usesSwarm() && ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel, ], [ 'label' => 'Docker Cleanup', diff --git a/resources/views/livewire/destination/show.blade.php b/resources/views/livewire/destination/show.blade.php index c622438f82..7909fafe27 100644 --- a/resources/views/livewire/destination/show.blade.php +++ b/resources/views/livewire/destination/show.blade.php @@ -17,6 +17,12 @@ @include('livewire.destination.sidebar', ['destination' => $destination])
+ @if ($destination->getMorphClass() !== 'App\Models\StandaloneDocker') + + {{ config('deprecations.swarm') }} + + @endif + @if (request()->routeIs('destination.danger'))
+ @if ($server->isSwarm()) + + {{ config('deprecations.swarm') }} + + @endif + @if ($server->isFunctional()) diff --git a/resources/views/livewire/server/swarm.blade.php b/resources/views/livewire/server/swarm.blade.php index a3e8dfce4a..9ae83e5a3b 100644 --- a/resources/views/livewire/server/swarm.blade.php +++ b/resources/views/livewire/server/swarm.blade.php @@ -22,6 +22,12 @@ target="_blank">Read the migration guidance. + @if (!$canUseSwarm) + + Docker Swarm cannot be enabled because this team has no existing Swarm resources. + + @endif +
false, 'label' => 'Not a Swarm manager'], ['value' => true, 'label' => 'Swarm manager'], ]" - :disabled="$server->settings->is_swarm_worker || !auth()->user()->can('update', $server)" /> + :disabled="!$canUseSwarm || $server->settings->is_swarm_worker || !auth()->user()->can('update', $server)" /> + :disabled="!$canUseSwarm || $server->settings->is_swarm_manager || !auth()->user()->can('update', $server)" />
diff --git a/tests/Feature/SwarmDeprecationTest.php b/tests/Feature/SwarmDeprecationTest.php new file mode 100644 index 0000000000..b30ec2fcbc --- /dev/null +++ b/tests/Feature/SwarmDeprecationTest.php @@ -0,0 +1,108 @@ +forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('a team without swarm cannot enable it on a server', function () { + Livewire::test(Swarm::class, ['server_uuid' => $this->server->uuid]) + ->set('isSwarmManager', true) + ->call('instantSave') + ->assertDispatched('error', 'Docker Swarm is deprecated and cannot be enabled for new teams.'); + + expect($this->server->settings->fresh()->is_swarm_manager)->toBeFalsy(); +}); + +test('a team already using swarm can manage another server role', function () { + $existingSwarmServer = Server::factory()->create(['team_id' => $this->team->id]); + $existingSwarmServer->settings()->update(['is_swarm_manager' => true]); + + Livewire::test(Swarm::class, ['server_uuid' => $this->server->uuid]) + ->set('isSwarmWorker', true) + ->call('instantSave') + ->assertDispatched('success', 'Swarm settings updated.'); + + expect($this->server->settings->fresh()->is_swarm_worker)->toBeTruthy(); +}); + +test('the swarm page shows the deprecation notice for an existing swarm team', function () { + $this->server->settings()->update(['is_swarm_manager' => true]); + + Livewire::test(Swarm::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Docker Swarm support is deprecated') + ->assertSee(config('deprecations.swarm')); +}); + +test('regular teams do not see swarm setup in server navigation', function () { + $swarmUrl = route('server.swarm', ['server_uuid' => $this->server->uuid]); + + $this->get(route('server.show', ['server_uuid' => $this->server->uuid])) + ->assertSuccessful() + ->assertDontSee($swarmUrl, false); + + $this->server->settings()->update(['is_swarm_manager' => true]); + + $this->get(route('server.show', ['server_uuid' => $this->server->uuid])) + ->assertSuccessful() + ->assertSee($swarmUrl, false); +}); + +test('existing swarm destination pages show the deprecation notice', function () { + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'is_swarm_manager' => true, + ]); + $destination = SwarmDocker::query()->create([ + 'name' => 'Legacy Swarm', + 'network' => 'legacy-overlay', + 'server_id' => $this->server->id, + ]); + + $this->get(route('server.destinations', ['server_uuid' => $this->server->uuid])) + ->assertSuccessful() + ->assertSee(config('deprecations.swarm')); + + $this->get(route('destination.show', ['destination_uuid' => $destination->uuid])) + ->assertSuccessful() + ->assertSee(config('deprecations.swarm')); +}); + +test('destination creation derives swarm mode from the existing server', function () { + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'is_swarm_manager' => true, + ]); + + Livewire::test(Docker::class, ['server_id' => (string) $this->server->id]) + ->set('name', 'Existing Swarm Network') + ->set('network', 'existing-overlay') + ->call('submit') + ->assertHasNoErrors(); + + expect(SwarmDocker::query() + ->whereBelongsTo($this->server) + ->where('network', 'existing-overlay') + ->exists())->toBeTrue(); +}); diff --git a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php index 9008b87380..2401622624 100644 --- a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php +++ b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php @@ -21,6 +21,17 @@ it('extracts a bare host from referer URLs and bare hosts, dropping www', functi expect(refererHost(null))->toBeNull(); }); +it('groups referer breakdown rows by normalized host', function () { + expect(groupRefererBreakdownRows([ + ['value' => 'https://www.example.com/first', 'requests' => 7, 'bytesOut' => 700], + ['value' => 'http://example.com/second', 'requests' => 3, 'bytesOut' => 300], + ['value' => 'https://other.example/path', 'requests' => 5, 'bytesOut' => 500], + ]))->toBe([ + ['value' => 'example.com', 'requests' => 10, 'bytesOut' => 1000], + ['value' => 'other.example', 'requests' => 5, 'bytesOut' => 500], + ]); +}); + it('builds a duckduckgo favicon url for a host', function () { expect(refererFaviconUrl('example.com'))->toBe('https://icons.duckduckgo.com/ip3/example.com.ico'); }); diff --git a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php index 1c551c8b21..4033e5e9f3 100644 --- a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php @@ -150,6 +150,10 @@ it('renders KPIs from a mocked traffic client when analytics is enabled', functi $fake = new FakeAnalyticsTrafficClient($application->destination->server); $fake->responses = fakeAnalyticsResponses(); + $fake->responses['/traffic/breakdown/referer'] = json_encode([ + ['value' => 'https://www.google.com/search', 'requests' => 250, 'bytes_out' => 7000], + ['value' => 'http://google.com/news', 'requests' => 50, 'bytes_out' => 1000], + ]); app()->bind(SentinelTrafficClient::class, fn () => $fake); loadLazy(Livewire::test(Analytics::class, ['application' => $application])) @@ -160,7 +164,10 @@ it('renders KPIs from a mocked traffic client when analytics is enabled', functi ->assertSee('Error rate') ->assertSee('/') ->assertSee('United States') - ->assertSee('GeoIP data by MaxMind'); + ->assertSee('GeoIP data by MaxMind') + ->assertSet('breakdowns.referer', [ + ['value' => 'google.com', 'requests' => 300, 'bytesOut' => 8000], + ]); }); it('loads the per-app status time series when Sentinel exposes the series endpoint', function () { diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index c0a13000f0..ff8b4c8e9a 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -128,6 +128,10 @@ it('renders a team-wide analytics summary across enabled servers', function () { $fake = new FakeGlobalAnalyticsTrafficClient($server); $fake->responses = fakeGlobalAnalyticsResponses([$application->uuid]); + $fake->responses['/traffic/breakdown/referer'] = json_encode([ + ['value' => 'https://www.google.com/search', 'requests' => 250, 'bytes_out' => 7000], + ['value' => 'http://google.com/news', 'requests' => 50, 'bytes_out' => 1000], + ]); app()->bind(SentinelTrafficClient::class, fn () => $fake); loadLazy(Livewire::test(Analytics::class)) @@ -253,7 +257,10 @@ it('shows path domains, links top apps to analytics, groups by project, and surf ->assertSee('Top IPs') ->assertSee('203.0.113.7') ->assertSee('Top user agents') - ->assertSee('TestAgent/1.0'); + ->assertSee('TestAgent/1.0') + ->assertSet('breakdowns.referer', [ + ['value' => 'google.com', 'requests' => 300, 'bytesOut' => 8000], + ]); // Path rows carry the resolved domain, top-app rows carry the domain + analytics link. expect($component->instance()->topPaths[0]['domain'])->toBe('shop.example.com');