From d2f70900fffa4ad0126adc7fb19952c334e5da99 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 20:14:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(update):=20graceful=20Livewire-less=20/upda?= =?UTF-8?q?te-progress=20page=20=E2=80=94=20no=20more=20502/whitescreen=20?= =?UTF-8?q?on=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Versions/Index.php | 22 +- lang/de/update.php | 21 ++ lang/en/update.php | 21 ++ resources/views/update-progress.blade.php | 262 ++++++++++++++++++++++ routes/web.php | 4 + tests/Feature/UpdateProgressTest.php | 196 ++++++++++++++++ 6 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 lang/de/update.php create mode 100644 lang/en/update.php create mode 100644 resources/views/update-progress.blade.php create mode 100644 tests/Feature/UpdateProgressTest.php diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index e08ca26..4ab2dd1 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -171,7 +171,7 @@ class Index extends Component * runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we * say so and do not await a result. */ - public function requestUpdate(DeploymentService $deployment): void + public function requestUpdate(DeploymentService $deployment): mixed { $channel = $this->channel(); $installed = (string) config('clusev.version'); @@ -184,7 +184,7 @@ class Index extends Component $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]); $this->dispatch('notify', message: __('versions.update_not_available'), level: 'error'); - return; + return null; } $this->updateState = 'update'; $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]); @@ -193,7 +193,7 @@ class Index extends Component if (RateLimiter::tooManyAttempts($key, 3)) { $this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); - return; + return null; } RateLimiter::hit($key, 600); @@ -202,7 +202,7 @@ class Index extends Component if (! $deployment->requestUpdate()) { $this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error'); - return; + return null; } // Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice // survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version @@ -221,6 +221,20 @@ class Index extends Component $this->updateBaseVersion = $installed; // completion = the running version moves past this $this->updatePolls = 0; $this->dispatch('notify', message: __('versions.update_started')); + + // Redirect to the self-contained progress page BEFORE the container goes down. + // navigate:false forces a full browser navigation (the page must be Livewire-independent + // to survive the teardown — see resources/views/update-progress.blade.php). + $prev = url()->previous(); + $parsedPrev = parse_url($prev, PHP_URL_PATH); + $returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//')) + ? $parsedPrev + : route('dashboard', absolute: false); + + return $this->redirect( + route('update.progress', ['return' => $returnPath]), + navigate: false, + ); } /** Resolve the configured channel, clamped to the user-facing set. */ diff --git a/lang/de/update.php b/lang/de/update.php new file mode 100644 index 0000000..aa98bf7 --- /dev/null +++ b/lang/de/update.php @@ -0,0 +1,21 @@ + 'Update läuft — Clusev', + 'heading' => 'Update läuft', + 'subtitle' => 'Der Stack wird neu gebaut. Diese Seite erkennt automatisch, wann er wieder erreichbar ist.', + + // Phase labels (displayed in the checklist) + 'phase_fetch' => 'Holen', + 'phase_build' => 'Bauen', + 'phase_migrate' => 'Migrieren', + 'phase_restart' => 'Neustart', + + // Status messages + 'elapsed' => 'Vergangene Zeit', + 'done_heading' => 'Fertig — wird neu geladen…', + 'timeout_heading' => 'Update dauert ungewöhnlich lange', + 'timeout_hint' => 'Bitte die Seite manuell neu laden oder die Logs prüfen (journalctl -u clusev-update.service).', + 'reload_button' => 'Neu laden', +]; diff --git a/lang/en/update.php b/lang/en/update.php new file mode 100644 index 0000000..ff985cd --- /dev/null +++ b/lang/en/update.php @@ -0,0 +1,21 @@ + 'Update in progress — Clusev', + 'heading' => 'Update in progress', + 'subtitle' => 'The stack is rebuilding. This page will automatically detect when it is back up.', + + // Phase labels (displayed in the checklist) + 'phase_fetch' => 'Fetch', + 'phase_build' => 'Build', + 'phase_migrate' => 'Migrate', + 'phase_restart' => 'Restart', + + // Status messages + 'elapsed' => 'Elapsed time', + 'done_heading' => 'Done — reloading…', + 'timeout_heading' => 'Update is taking unusually long', + 'timeout_hint' => 'Please reload the page manually or check the logs (journalctl -u clusev-update.service).', + 'reload_button' => 'Reload', +]; diff --git a/resources/views/update-progress.blade.php b/resources/views/update-progress.blade.php new file mode 100644 index 0000000..768e6ea --- /dev/null +++ b/resources/views/update-progress.blade.php @@ -0,0 +1,262 @@ + +{{-- + Self-contained update-progress page. NO Livewire, NO Alpine, NO external JS. + Loaded into the browser BEFORE the container teardown triggered by the update + request. While the backend is down the page is already client-side; its inline + JS polling loop tolerates 502s and detects recovery when /up returns 200. + Modelled on errors/layout.blade.php (same token-only CSS, same wordmark). +--}} + + + + + {{ __('update.page_title') }} + @include('partials.head-icons') + @vite(['resources/css/app.css']) + + +
+
+ + {{-- Wordmark --}} +
+ + + + Clusev +
+ + {{-- Main card --}} +
+ + {{-- Spinner + heading --}} +
+ + +
+

{{ __('update.heading') }}

+

{{ __('update.subtitle') }}

+
+ + + + +
+ + {{-- Phase checklist --}} +
+
    + @php + $phases = [ + ['key' => 'fetch', 'label' => __('update.phase_fetch')], + ['key' => 'build', 'label' => __('update.phase_build')], + ['key' => 'migrate', 'label' => __('update.phase_migrate')], + ['key' => 'restart', 'label' => __('update.phase_restart')], + ]; + @endphp + @foreach ($phases as $i => $phase) +
  1. + {{-- Dot --}} + + {{-- Label --}} + {{ $phase['label'] }} + {{-- Check icon (hidden until complete) --}} + +
  2. + @endforeach +
+
+ + {{-- Elapsed time --}} +

+ {{ __('update.elapsed') }}: 0s +

+ +
{{-- /card --}} +
+
+ + {{-- Inline CSS for the spinner animation (no Alpine/external deps) --}} + + + {{-- Inline JS — no external dependencies. Reads ?return= for the redirect target. + Polls /up every 2500 ms; requires 2 consecutive 200s before redirecting (flap guard). + Times out after 10 minutes and shows a manual-reload prompt. --}} + + + diff --git a/routes/web.php b/routes/web.php index 5f15e58..090fe7f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -107,5 +107,9 @@ Route::middleware('auth')->group(function () { Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); Route::get('/help', Help\Index::class)->name('help'); + + // Plain Blade view — deliberately NOT a Livewire component so it survives the + // container teardown that follows an update request (R1/R2 exception, by design). + Route::get('/update-progress', fn () => view('update-progress'))->name('update.progress'); }); }); diff --git a/tests/Feature/UpdateProgressTest.php b/tests/Feature/UpdateProgressTest.php new file mode 100644 index 0000000..e99a50a --- /dev/null +++ b/tests/Feature/UpdateProgressTest.php @@ -0,0 +1,196 @@ +clearUpdateRequest(); + $this->actingAs(User::factory()->create(['must_change_password' => false])); + } + + protected function tearDown(): void + { + app(DeploymentService::class)->clearUpdateRequest(); + parent::tearDown(); + } + + // ── (a) requestUpdate() behaviour ───────────────────────────────────────── + + public function test_request_update_redirects_to_progress_page(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + // The redirect includes a ?return= query param; assertRedirectContains checks the path. + $response = Livewire::test(Index::class)->call('requestUpdate'); + $redirectUrl = $response->effects['redirect'] ?? ''; + $this->assertStringContainsString( + route('update.progress', absolute: false), + $redirectUrl, + 'requestUpdate must redirect to the update-progress page', + ); + } + + public function test_request_update_still_writes_sentinel_and_sets_redis_flag(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + Livewire::test(Index::class)->call('requestUpdate'); + + $this->assertTrue( + app(DeploymentService::class)->updateRequested(), + 'the update sentinel a host watcher reacts to must still be written', + ); + $marker = Cache::get('clusev:update-in-progress'); + $this->assertIsArray($marker, 'the in-progress Redis flag must be set'); + $this->assertSame('0.9.4', $marker['base'], 'the base version must be captured in the flag'); + } + + public function test_request_update_still_audits_the_action(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + Livewire::test(Index::class)->call('requestUpdate'); + + $this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists()); + } + + public function test_request_update_still_respects_throttle(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + $key = 'update-request:'.auth()->id(); + for ($i = 0; $i < 3; $i++) { + RateLimiter::hit($key, 600); + } + + // Throttled — must NOT redirect, must NOT write sentinel. + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', false); + + $this->assertFalse( + app(DeploymentService::class)->updateRequested(), + 'throttle must block the sentinel write', + ); + $this->assertNull(Cache::get('clusev:update-in-progress'), 'no Redis flag when throttled'); + } + + public function test_request_update_does_not_redirect_when_nothing_to_update(): void + { + config()->set('clusev.version', '0.9.9'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', false) + ->assertNoRedirect(); + } + + public function test_request_update_does_not_redirect_when_sentinel_not_writable(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + $mock = \Mockery::mock(DeploymentService::class)->makePartial(); + $mock->shouldReceive('requestUpdate')->andReturn(false); + $this->app->instance(DeploymentService::class, $mock); + + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', false) + ->assertNoRedirect(); + } + + // ── (b) /update-progress page ───────────────────────────────────────────── + + public function test_update_progress_page_returns_200_for_authenticated_user(): void + { + $this->get(route('update.progress'))->assertOk(); + } + + public function test_update_progress_page_redirects_guests_to_login(): void + { + auth()->logout(); + $this->get(route('update.progress'))->assertRedirect(route('login')); + } + + public function test_update_progress_page_contains_heading(): void + { + $this->get(route('update.progress')) + ->assertOk() + ->assertSee('Update'); + } + + public function test_update_progress_page_has_no_livewire_scripts(): void + { + $body = $this->get(route('update.progress'))->content(); + + $this->assertStringNotContainsStringIgnoringCase( + 'livewire', + $body, + 'the update-progress page must contain no Livewire script tags — it must survive the backend going down', + ); + } + + public function test_update_progress_page_has_no_wire_attributes(): void + { + $body = $this->get(route('update.progress'))->content(); + + $this->assertStringNotContainsString( + 'wire:', + $body, + 'the update-progress page must contain no wire: attributes', + ); + } + + public function test_update_progress_page_polls_up_endpoint_in_its_js(): void + { + $body = $this->get(route('update.progress'))->content(); + + $this->assertStringContainsString( + "fetch('/up'", + $body, + 'the inline JS must poll /up to detect when the app comes back', + ); + } + + public function test_update_progress_page_contains_phase_labels(): void + { + $body = $this->get(route('update.progress'))->content(); + + // German labels (default locale in tests) + $this->assertStringContainsString('Holen', $body); + $this->assertStringContainsString('Bauen', $body); + $this->assertStringContainsString('Migrieren', $body); + $this->assertStringContainsString('Neustart', $body); + } +}