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') }}
+
+
+
+
+
{{ __('update.done_heading') }}
+
+
+
+
+
{{ __('update.timeout_heading') }}
+
{{ __('update.timeout_hint') }}
+
+
+
+
+ {{-- 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)
+ -
+ {{-- Dot --}}
+
+ {{-- Label --}}
+ {{ $phase['label'] }}
+ {{-- Check icon (hidden until complete) --}}
+
+
+ @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);
+ }
+}