feat: deprecate new Docker Swarm usage and group referrers

This commit is contained in:
Andras Bacsai
2026-09-21 16:45:21 +02:00
parent f45c130c67
commit 20a72f0f80
16 changed files with 213 additions and 16 deletions
+3
View File
@@ -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;
-3
View File
@@ -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;
+3 -5
View File
@@ -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.');
@@ -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;
+2 -2
View File
@@ -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;
+8
View File
@@ -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();
+12
View File
@@ -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);
+25
View File
@@ -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<int, array{value?: string, requests?: int, bytesOut?: int}> $rows
* @return array<int, array{value: string, requests: int, bytesOut: int}>
*/
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.
@@ -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',
@@ -17,6 +17,12 @@
@include('livewire.destination.sidebar', ['destination' => $destination])
<div class="min-w-0">
@if ($destination->getMorphClass() !== 'App\Models\StandaloneDocker')
<x-callout type="warning" title="Docker Swarm support is deprecated" class="mb-6">
{{ config('deprecations.swarm') }}
</x-callout>
@endif
@if (request()->routeIs('destination.danger'))
<div class="application-settings-form">
<x-application.settings-section id="destination-danger-section" title="Danger zone"
@@ -10,6 +10,12 @@
<x-server.sidebar :server="$server" activeMenu="destinations" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if ($server->isSwarm())
<x-callout type="warning" title="Docker Swarm support is deprecated">
{{ config('deprecations.swarm') }}
</x-callout>
@endif
@if ($server->isFunctional())
<x-application.settings-section id="server-destinations-section" title="Destinations"
helper="Docker networks used to isolate and connect resources on this server." flush>
@@ -22,6 +22,12 @@
target="_blank">Read the migration guidance.</a>
</x-callout>
@if (!$canUseSwarm)
<x-callout type="info" title="Unavailable for new teams" class="mt-4">
Docker Swarm cannot be enabled because this team has no existing Swarm resources.
</x-callout>
@endif
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<x-forms.listbox canGate="update" :canResource="$server" id="isSwarmManager" label="Manager role"
helper="Managers control scheduling and cluster state." onChange="instantSave"
@@ -29,14 +35,14 @@
['value' => 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)" />
<x-forms.listbox canGate="update" :canResource="$server" id="isSwarmWorker" label="Worker role"
helper="Workers run tasks assigned by a Swarm manager." onChange="instantSave"
:options="[
['value' => false, 'label' => 'Not a Swarm worker'],
['value' => true, 'label' => 'Swarm worker'],
]"
:disabled="$server->settings->is_swarm_manager || !auth()->user()->can('update', $server)" />
:disabled="!$canUseSwarm || $server->settings->is_swarm_manager || !auth()->user()->can('update', $server)" />
</div>
</x-application.settings-section>
</div>
+108
View File
@@ -0,0 +1,108 @@
<?php
use App\Livewire\Destination\New\Docker;
use App\Livewire\Server\Swarm;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->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();
});
@@ -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');
});
@@ -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 () {
@@ -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');