mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 09:20:54 -04:00
feat: deprecate new Docker Swarm usage and group referrers
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user