Make proxy restart run as background job to prevent localhost lockout
When restarting the proxy on localhost (where Coolify is running), the UI becomes inaccessible because the connection is lost. This change makes all proxy restarts run as background jobs with WebSocket notifications, allowing the operation to complete even after connection loss. Changes: - Enhanced ProxyStatusChangedUI event to carry activityId for log monitoring - Updated RestartProxyJob to dispatch status events and track activity - Simplified Navbar restart() to always dispatch job for all servers - Enhanced showNotification() to handle activity monitoring and new statuses - Added comprehensive unit and feature tests Benefits: - Prevents localhost lockout during proxy restarts - Consistent behavior across all server types - Non-blocking UI with real-time progress updates - Automatic activity log monitoring - Proper error handling and recovery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b55aaf34d3
commit
e4810a28d2
5 changed files with 376 additions and 8 deletions
|
|
@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast
|
|||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public function __construct(?int $teamId = null)
|
||||
public ?int $activityId = null;
|
||||
|
||||
public function __construct(?int $teamId = null, ?int $activityId = null)
|
||||
{
|
||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
$this->activityId = $activityId;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Proxy\StopProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -12,6 +14,7 @@
|
|||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
|
|
@ -21,6 +24,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public $timeout = 60;
|
||||
|
||||
public ?int $activity_id = null;
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()];
|
||||
|
|
@ -31,14 +36,45 @@ public function __construct(public Server $server) {}
|
|||
public function handle()
|
||||
{
|
||||
try {
|
||||
$teamId = $this->server->team_id;
|
||||
|
||||
// Stop proxy
|
||||
StopProxy::run($this->server, restarting: true);
|
||||
|
||||
// Clear force_stop flag
|
||||
$this->server->proxy->force_stop = false;
|
||||
$this->server->save();
|
||||
|
||||
StartProxy::run($this->server, force: true, restarting: true);
|
||||
// Start proxy asynchronously to get activity
|
||||
$activity = StartProxy::run($this->server, force: true, restarting: true);
|
||||
|
||||
// Store activity ID and dispatch event with it
|
||||
if ($activity && is_object($activity)) {
|
||||
$this->activity_id = $activity->id;
|
||||
ProxyStatusChangedUI::dispatch($teamId, $this->activity_id);
|
||||
}
|
||||
|
||||
// Check Traefik version after restart (same as original behavior)
|
||||
if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
||||
$traefikVersions = get_traefik_versions();
|
||||
if ($traefikVersions !== null) {
|
||||
CheckTraefikVersionForServerJob::dispatch($this->server, $traefikVersions);
|
||||
} else {
|
||||
Log::warning('Traefik version check skipped: versions.json data unavailable', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Set error status
|
||||
$this->server->proxy->status = 'error';
|
||||
$this->server->save();
|
||||
|
||||
// Notify UI of error
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Proxy\StopProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -61,13 +63,11 @@ public function restart()
|
|||
{
|
||||
try {
|
||||
$this->authorize('manageProxy', $this->server);
|
||||
StopProxy::run($this->server, restarting: true);
|
||||
|
||||
$this->server->proxy->force_stop = false;
|
||||
$this->server->save();
|
||||
// Always use background job for all servers
|
||||
RestartProxyJob::dispatch($this->server);
|
||||
$this->dispatch('info', 'Proxy restart initiated. Monitor progress in activity logs.');
|
||||
|
||||
$activity = StartProxy::run($this->server, force: true, restarting: true);
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -122,12 +122,17 @@ public function checkProxyStatus()
|
|||
}
|
||||
}
|
||||
|
||||
public function showNotification()
|
||||
public function showNotification($event = null)
|
||||
{
|
||||
$previousStatus = $this->proxyStatus;
|
||||
$this->server->refresh();
|
||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||
|
||||
// If event contains activityId, open activity monitor
|
||||
if ($event && isset($event['activityId'])) {
|
||||
$this->dispatch('activityMonitor', $event['activityId']);
|
||||
}
|
||||
|
||||
switch ($this->proxyStatus) {
|
||||
case 'running':
|
||||
$this->loadProxyConfiguration();
|
||||
|
|
@ -150,6 +155,12 @@ public function showNotification()
|
|||
case 'starting':
|
||||
$this->dispatch('info', 'Proxy is starting.');
|
||||
break;
|
||||
case 'restarting':
|
||||
$this->dispatch('info', 'Proxy is restarting.');
|
||||
break;
|
||||
case 'error':
|
||||
$this->dispatch('error', 'Proxy restart failed. Check logs.');
|
||||
break;
|
||||
case 'unknown':
|
||||
$this->dispatch('info', 'Proxy status is unknown.');
|
||||
break;
|
||||
|
|
|
|||
139
tests/Feature/Proxy/RestartProxyTest.php
Normal file
139
tests/Feature/Proxy/RestartProxyTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Proxy;
|
||||
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RestartProxyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected Team $team;
|
||||
|
||||
protected Server $server;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test user and team
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create(['name' => 'Test Team']);
|
||||
$this->user->teams()->attach($this->team);
|
||||
|
||||
// Create test server
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Server',
|
||||
'ip' => '192.168.1.100',
|
||||
]);
|
||||
|
||||
// Authenticate user
|
||||
$this->actingAs($this->user);
|
||||
}
|
||||
|
||||
public function test_restart_dispatches_job_for_all_servers()
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $this->server])
|
||||
->call('restart');
|
||||
|
||||
// Assert job was dispatched
|
||||
Queue::assertPushed(RestartProxyJob::class, function ($job) {
|
||||
return $job->server->id === $this->server->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_restart_dispatches_job_for_localhost_server()
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
// Create localhost server (id = 0)
|
||||
$localhostServer = Server::factory()->create([
|
||||
'id' => 0,
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Localhost',
|
||||
'ip' => 'host.docker.internal',
|
||||
]);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $localhostServer])
|
||||
->call('restart');
|
||||
|
||||
// Assert job was dispatched
|
||||
Queue::assertPushed(RestartProxyJob::class, function ($job) use ($localhostServer) {
|
||||
return $job->server->id === $localhostServer->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_restart_shows_info_message()
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $this->server])
|
||||
->call('restart')
|
||||
->assertDispatched('info', 'Proxy restart initiated. Monitor progress in activity logs.');
|
||||
}
|
||||
|
||||
public function test_unauthorized_user_cannot_restart_proxy()
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
// Create another user without access
|
||||
$unauthorizedUser = User::factory()->create();
|
||||
$this->actingAs($unauthorizedUser);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $this->server])
|
||||
->call('restart')
|
||||
->assertForbidden();
|
||||
|
||||
// Assert job was NOT dispatched
|
||||
Queue::assertNotPushed(RestartProxyJob::class);
|
||||
}
|
||||
|
||||
public function test_restart_prevents_concurrent_jobs_via_without_overlapping()
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
// Dispatch job twice
|
||||
Livewire::test('server.navbar', ['server' => $this->server])
|
||||
->call('restart');
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $this->server])
|
||||
->call('restart');
|
||||
|
||||
// Assert job was pushed twice (WithoutOverlapping middleware will handle deduplication)
|
||||
Queue::assertPushed(RestartProxyJob::class, 2);
|
||||
|
||||
// Get the jobs
|
||||
$jobs = Queue::pushed(RestartProxyJob::class);
|
||||
|
||||
// Verify both jobs have WithoutOverlapping middleware
|
||||
foreach ($jobs as $job) {
|
||||
$middleware = $job['job']->middleware();
|
||||
$this->assertCount(1, $middleware);
|
||||
$this->assertInstanceOf(\Illuminate\Queue\Middleware\WithoutOverlapping::class, $middleware[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_restart_uses_server_team_id()
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $this->server])
|
||||
->call('restart');
|
||||
|
||||
Queue::assertPushed(RestartProxyJob::class, function ($job) {
|
||||
return $job->server->team_id === $this->team->id;
|
||||
});
|
||||
}
|
||||
}
|
||||
179
tests/Unit/Jobs/RestartProxyJobTest.php
Normal file
179
tests/Unit/Jobs/RestartProxyJobTest.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Proxy\StopProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RestartProxyJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_job_has_without_overlapping_middleware()
|
||||
{
|
||||
$server = Mockery::mock(Server::class);
|
||||
$server->uuid = 'test-uuid';
|
||||
|
||||
$job = new RestartProxyJob($server);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
$this->assertCount(1, $middleware);
|
||||
$this->assertInstanceOf(WithoutOverlapping::class, $middleware[0]);
|
||||
}
|
||||
|
||||
public function test_job_stops_and_starts_proxy()
|
||||
{
|
||||
// Mock Server
|
||||
$server = Mockery::mock(Server::class);
|
||||
$server->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn((object) ['force_stop' => true]);
|
||||
$server->shouldReceive('save')->once();
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
|
||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('name')->andReturn('test-server');
|
||||
|
||||
// Mock Activity
|
||||
$activity = Mockery::mock(Activity::class);
|
||||
$activity->id = 123;
|
||||
|
||||
// Mock Actions
|
||||
$stopProxyMock = Mockery::mock('alias:'.StopProxy::class);
|
||||
$stopProxyMock->shouldReceive('run')
|
||||
->once()
|
||||
->with($server, restarting: true);
|
||||
|
||||
$startProxyMock = Mockery::mock('alias:'.StartProxy::class);
|
||||
$startProxyMock->shouldReceive('run')
|
||||
->once()
|
||||
->with($server, force: true, restarting: true)
|
||||
->andReturn($activity);
|
||||
|
||||
// Mock Events
|
||||
Event::fake();
|
||||
Queue::fake();
|
||||
|
||||
// Mock get_traefik_versions helper
|
||||
$this->app->instance('traefik_versions', ['latest' => '2.10']);
|
||||
|
||||
// Execute job
|
||||
$job = new RestartProxyJob($server);
|
||||
$job->handle();
|
||||
|
||||
// Assert activity ID was set
|
||||
$this->assertEquals(123, $job->activity_id);
|
||||
|
||||
// Assert event was dispatched
|
||||
Event::assertDispatched(ProxyStatusChangedUI::class, function ($event) {
|
||||
return $event->teamId === 1 && $event->activityId === 123;
|
||||
});
|
||||
|
||||
// Assert Traefik version check was dispatched
|
||||
Queue::assertPushed(CheckTraefikVersionForServerJob::class);
|
||||
}
|
||||
|
||||
public function test_job_handles_errors_gracefully()
|
||||
{
|
||||
// Mock Server
|
||||
$server = Mockery::mock(Server::class);
|
||||
$server->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn((object) ['status' => 'running']);
|
||||
$server->shouldReceive('save')->once();
|
||||
|
||||
// Mock StopProxy to throw exception
|
||||
$stopProxyMock = Mockery::mock('alias:'.StopProxy::class);
|
||||
$stopProxyMock->shouldReceive('run')
|
||||
->once()
|
||||
->andThrow(new \Exception('Test error'));
|
||||
|
||||
Event::fake();
|
||||
|
||||
// Execute job
|
||||
$job = new RestartProxyJob($server);
|
||||
$job->handle();
|
||||
|
||||
// Assert error event was dispatched
|
||||
Event::assertDispatched(ProxyStatusChangedUI::class, function ($event) {
|
||||
return $event->teamId === 1 && $event->activityId === null;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_job_skips_traefik_version_check_for_non_traefik_proxies()
|
||||
{
|
||||
// Mock Server with non-Traefik proxy
|
||||
$server = Mockery::mock(Server::class);
|
||||
$server->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn((object) ['force_stop' => true]);
|
||||
$server->shouldReceive('save')->once();
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::CADDY->value);
|
||||
|
||||
// Mock Activity
|
||||
$activity = Mockery::mock(Activity::class);
|
||||
$activity->id = 123;
|
||||
|
||||
// Mock Actions
|
||||
$stopProxyMock = Mockery::mock('alias:'.StopProxy::class);
|
||||
$stopProxyMock->shouldReceive('run')->once();
|
||||
|
||||
$startProxyMock = Mockery::mock('alias:'.StartProxy::class);
|
||||
$startProxyMock->shouldReceive('run')->once()->andReturn($activity);
|
||||
|
||||
Event::fake();
|
||||
Queue::fake();
|
||||
|
||||
// Execute job
|
||||
$job = new RestartProxyJob($server);
|
||||
$job->handle();
|
||||
|
||||
// Assert Traefik version check was NOT dispatched
|
||||
Queue::assertNotPushed(CheckTraefikVersionForServerJob::class);
|
||||
}
|
||||
|
||||
public function test_job_clears_force_stop_flag()
|
||||
{
|
||||
// Mock Server
|
||||
$proxy = (object) ['force_stop' => true];
|
||||
$server = Mockery::mock(Server::class);
|
||||
$server->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxy);
|
||||
$server->shouldReceive('save')->once();
|
||||
$server->shouldReceive('proxyType')->andReturn('NONE');
|
||||
|
||||
// Mock Activity
|
||||
$activity = Mockery::mock(Activity::class);
|
||||
$activity->id = 123;
|
||||
|
||||
// Mock Actions
|
||||
Mockery::mock('alias:'.StopProxy::class)
|
||||
->shouldReceive('run')->once();
|
||||
|
||||
Mockery::mock('alias:'.StartProxy::class)
|
||||
->shouldReceive('run')->once()->andReturn($activity);
|
||||
|
||||
Event::fake();
|
||||
|
||||
// Execute job
|
||||
$job = new RestartProxyJob($server);
|
||||
$job->handle();
|
||||
|
||||
// Assert force_stop was set to false
|
||||
$this->assertFalse($proxy->force_stop);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue