diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php index bd99a0f3c..3994dc0f8 100644 --- a/app/Events/ProxyStatusChangedUI.php +++ b/app/Events/ProxyStatusChangedUI.php @@ -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 diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index e3e809c8d..5b3c33dba 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -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); } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d104bce54..73ac165d3 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -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; diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php new file mode 100644 index 000000000..5771a58f7 --- /dev/null +++ b/tests/Feature/Proxy/RestartProxyTest.php @@ -0,0 +1,139 @@ +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; + }); + } +} diff --git a/tests/Unit/Jobs/RestartProxyJobTest.php b/tests/Unit/Jobs/RestartProxyJobTest.php new file mode 100644 index 000000000..4da28a4df --- /dev/null +++ b/tests/Unit/Jobs/RestartProxyJobTest.php @@ -0,0 +1,179 @@ +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); + } +}