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:
Andras Bacsai 2025-12-03 10:29:39 +01:00
parent b55aaf34d3
commit e4810a28d2
5 changed files with 376 additions and 8 deletions

View file

@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast
public ?int $teamId = null; 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()) { if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
} }
$this->teamId = $teamId; $this->teamId = $teamId;
$this->activityId = $activityId;
} }
public function broadcastOn(): array public function broadcastOn(): array

View file

@ -4,6 +4,8 @@
use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy; use App\Actions\Proxy\StopProxy;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server; use App\Models\Server;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -12,6 +14,7 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -21,6 +24,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public $timeout = 60; public $timeout = 60;
public ?int $activity_id = null;
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()];
@ -31,14 +36,45 @@ public function __construct(public Server $server) {}
public function handle() public function handle()
{ {
try { try {
$teamId = $this->server->team_id;
// Stop proxy
StopProxy::run($this->server, restarting: true); StopProxy::run($this->server, restarting: true);
// Clear force_stop flag
$this->server->proxy->force_stop = false; $this->server->proxy->force_stop = false;
$this->server->save(); $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) { } 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); return handleError($e);
} }
} }

View file

@ -6,6 +6,8 @@
use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy; use App\Actions\Proxy\StopProxy;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\RestartProxyJob;
use App\Models\Server; use App\Models\Server;
use App\Services\ProxyDashboardCacheService; use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -61,13 +63,11 @@ public function restart()
{ {
try { try {
$this->authorize('manageProxy', $this->server); $this->authorize('manageProxy', $this->server);
StopProxy::run($this->server, restarting: true);
$this->server->proxy->force_stop = false; // Always use background job for all servers
$this->server->save(); 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) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -122,12 +122,17 @@ public function checkProxyStatus()
} }
} }
public function showNotification() public function showNotification($event = null)
{ {
$previousStatus = $this->proxyStatus; $previousStatus = $this->proxyStatus;
$this->server->refresh(); $this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown'; $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) { switch ($this->proxyStatus) {
case 'running': case 'running':
$this->loadProxyConfiguration(); $this->loadProxyConfiguration();
@ -150,6 +155,12 @@ public function showNotification()
case 'starting': case 'starting':
$this->dispatch('info', 'Proxy is starting.'); $this->dispatch('info', 'Proxy is starting.');
break; break;
case 'restarting':
$this->dispatch('info', 'Proxy is restarting.');
break;
case 'error':
$this->dispatch('error', 'Proxy restart failed. Check logs.');
break;
case 'unknown': case 'unknown':
$this->dispatch('info', 'Proxy status is unknown.'); $this->dispatch('info', 'Proxy status is unknown.');
break; break;

View 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;
});
}
}

View 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);
}
}