fix(update): graceful Livewire-less /update-progress page — no more 502/whitescreen on update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-20 20:14:11 +02:00
parent ffc17966ef
commit d2f70900ff
6 changed files with 522 additions and 4 deletions

View File

@ -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. */

21
lang/de/update.php Normal file
View File

@ -0,0 +1,21 @@
<?php
// Update-progress page strings (R16). Used by resources/views/update-progress.blade.php.
return [
'page_title' => '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',
];

21
lang/en/update.php Normal file
View File

@ -0,0 +1,21 @@
<?php
// Update-progress page strings (R16). Used by resources/views/update-progress.blade.php.
return [
'page_title' => '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',
];

View File

@ -0,0 +1,262 @@
<!DOCTYPE html>
{{--
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).
--}}
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('update.page_title') }}</title>
@include('partials.head-icons')
@vite(['resources/css/app.css'])
</head>
<body class="min-h-screen bg-void font-sans text-ink antialiased">
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12 sm:px-6">
<div class="w-full max-w-lg">
{{-- Wordmark --}}
<div class="mb-10 flex items-center justify-center gap-2.5">
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</span>
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
</div>
{{-- Main card --}}
<div class="rounded-xl border border-line bg-surface p-6 shadow-panel sm:p-8">
{{-- Spinner + heading --}}
<div class="flex flex-col items-center gap-5 text-center">
<div id="js-spinner" class="relative h-14 w-14" aria-hidden="true">
<svg class="h-14 w-14 -rotate-90" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="28" cy="28" r="24" stroke-width="3" class="stroke-line"/>
<circle id="js-spinner-arc" cx="28" cy="28" r="24" stroke-width="3"
stroke-linecap="round"
stroke-dasharray="150.796"
stroke-dashoffset="37.699"
class="stroke-accent origin-center"
style="animation: clusev-spin 1.4s linear infinite; transform-origin: 50% 50%;" />
</svg>
</div>
<div id="js-status-running">
<h1 class="font-display text-2xl font-semibold text-ink">{{ __('update.heading') }}</h1>
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.subtitle') }}</p>
</div>
<div id="js-status-done" class="hidden">
<svg class="mx-auto mb-3 h-12 w-12 text-online" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<h1 class="font-display text-2xl font-semibold text-ink">{{ __('update.done_heading') }}</h1>
</div>
<div id="js-status-timeout" class="hidden">
<svg class="mx-auto mb-3 h-12 w-12 text-warning" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
<h1 class="font-display text-xl font-semibold text-warning">{{ __('update.timeout_heading') }}</h1>
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.timeout_hint') }}</p>
<button onclick="window.location.reload()" class="mt-4 inline-flex items-center gap-2 rounded-md border border-line bg-raised px-4 py-2 font-mono text-sm text-ink transition-colors hover:border-accent/40 hover:text-accent">
{{ __('update.reload_button') }}
</button>
</div>
</div>
{{-- Phase checklist --}}
<div class="mt-8">
<ol class="space-y-3" aria-label="Update phases">
@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)
<li class="flex items-center gap-3 rounded-lg border border-line bg-raised/50 px-4 py-3 transition-colors duration-500"
id="js-phase-{{ $phase['key'] }}"
data-phase-index="{{ $i }}">
{{-- Dot --}}
<span class="h-2 w-2 shrink-0 rounded-full bg-ink-4 transition-colors duration-500"
id="js-phase-dot-{{ $phase['key'] }}"></span>
{{-- Label --}}
<span class="flex-1 font-mono text-sm text-ink-3 transition-colors duration-500"
id="js-phase-label-{{ $phase['key'] }}">{{ $phase['label'] }}</span>
{{-- Check icon (hidden until complete) --}}
<svg id="js-phase-check-{{ $phase['key'] }}" class="h-4 w-4 shrink-0 text-online hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</li>
@endforeach
</ol>
</div>
{{-- Elapsed time --}}
<p class="mt-6 text-center font-mono text-[11px] text-ink-4" id="js-elapsed" aria-live="polite">
{{ __('update.elapsed') }}: <span id="js-elapsed-value">0s</span>
</p>
</div>{{-- /card --}}
</div>
</div>
{{-- Inline CSS for the spinner animation (no Alpine/external deps) --}}
<style>
@keyframes clusev-spin { to { transform: rotate(360deg); } }
</style>
{{-- 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. --}}
<script>
(function () {
'use strict';
// ── Return URL ─────────────────────────────────────────────────────────
// Sanitise to a same-origin relative path: must start with a single /,
// must NOT start with //, http, or contain backslashes.
var raw = new URLSearchParams(window.location.search).get('return') || '';
var returnUrl = '/';
if (raw && /^\/[^\/\\]/.test(raw) && !/^\/\//g.test(raw) && !/^https?:/i.test(raw)) {
returnUrl = raw;
}
// ── Phase timing heuristic (purely visual) ─────────────────────────────
// Approx durations (seconds) per phase before advancing the highlight.
var PHASE_KEYS = ['fetch', 'build', 'migrate', 'restart'];
var PHASE_TIMES = [15, 40, 60, 90]; // cumulative seconds to enter each phase
var currentPhase = -1;
function setPhaseActive(index) {
for (var i = 0; i < PHASE_KEYS.length; i++) {
var key = PHASE_KEYS[i];
var li = document.getElementById('js-phase-' + key);
var dot = document.getElementById('js-phase-dot-' + key);
var label = document.getElementById('js-phase-label-' + key);
var check = document.getElementById('js-phase-check-' + key);
if (!li) continue;
if (i < index) {
// completed
li.classList.remove('border-accent/30', 'bg-accent/5');
dot.style.backgroundColor = 'var(--color-online)';
label.style.color = 'var(--color-online)';
check.classList.remove('hidden');
} else if (i === index) {
// active
li.classList.add('border-accent/30', 'bg-accent/5');
dot.style.backgroundColor = 'var(--color-accent)';
label.style.color = 'var(--color-ink)';
check.classList.add('hidden');
} else {
// pending
li.classList.remove('border-accent/30', 'bg-accent/5');
dot.style.backgroundColor = '';
label.style.color = '';
check.classList.add('hidden');
}
}
currentPhase = index;
}
setPhaseActive(0);
// ── Elapsed timer ──────────────────────────────────────────────────────
var startTime = Date.now();
var elapsedEl = document.getElementById('js-elapsed-value');
var TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
function formatElapsed(ms) {
var s = Math.floor(ms / 1000);
var m = Math.floor(s / 60);
if (m > 0) return m + 'm ' + (s % 60) + 's';
return s + 's';
}
// ── DOM refs ───────────────────────────────────────────────────────────
var spinnerEl = document.getElementById('js-spinner');
var runningEl = document.getElementById('js-status-running');
var doneEl = document.getElementById('js-status-done');
var timeoutEl = document.getElementById('js-status-timeout');
// ── Polling ────────────────────────────────────────────────────────────
var consecutiveOk = 0;
var STABLE_REQUIRED = 2;
var pollInterval = null;
var tickInterval = null;
var done = false;
function showDone() {
if (done) return;
done = true;
clearInterval(pollInterval);
clearInterval(tickInterval);
// Show all phases complete
setPhaseActive(PHASE_KEYS.length);
spinnerEl.classList.add('hidden');
runningEl.classList.add('hidden');
doneEl.classList.remove('hidden');
setTimeout(function () {
window.location.assign(returnUrl);
}, 1000);
}
function showTimeout() {
if (done) return;
done = true;
clearInterval(pollInterval);
clearInterval(tickInterval);
spinnerEl.classList.add('hidden');
runningEl.classList.add('hidden');
timeoutEl.classList.remove('hidden');
}
function tick() {
var elapsed = Date.now() - startTime;
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
// Advance phase highlight based on elapsed time
for (var p = PHASE_KEYS.length - 1; p >= 0; p--) {
if (elapsed >= PHASE_TIMES[p] * 1000) {
if (currentPhase !== p) setPhaseActive(p);
break;
}
}
if (elapsed >= TIMEOUT_MS) {
showTimeout();
}
}
function poll() {
if (done) return;
fetch('/up', { cache: 'no-store', method: 'GET' })
.then(function (res) {
if (res.ok) {
consecutiveOk++;
if (consecutiveOk >= STABLE_REQUIRED) showDone();
} else {
consecutiveOk = 0;
}
})
.catch(function () {
// 502 / network error while the container is rebuilding — keep waiting
consecutiveOk = 0;
});
}
tickInterval = setInterval(tick, 1000);
tick(); // immediate first tick
// Small initial delay before the first poll — the teardown starts right after
// the browser receives the redirect, so we give it a moment to go down before
// we start checking if it's back up (avoids a false-positive on the old instance).
setTimeout(function () {
poll(); // first poll
pollInterval = setInterval(poll, 2500);
}, 3000);
}());
</script>
</body>
</html>

View File

@ -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');
});
});

View File

@ -0,0 +1,196 @@
<?php
namespace Tests\Feature;
use App\Livewire\Versions\Index;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Verifies the graceful update-progress redirect flow:
* (a) requestUpdate() still writes the sentinel, sets the in-progress Redis flag,
* respects the throttle, AND now redirects to route('update.progress').
* (b) GET /update-progress returns 200, contains the heading, and is Livewire-free.
*/
class UpdateProgressTest extends TestCase
{
use RefreshDatabase;
private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*';
protected function setUp(): void
{
parent::setUp();
Cache::flush();
app(DeploymentService::class)->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);
}
}