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
parent
ffc17966ef
commit
d2f70900ff
|
|
@ -171,7 +171,7 @@ class Index extends Component
|
||||||
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
|
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
|
||||||
* say so and do not await a result.
|
* say so and do not await a result.
|
||||||
*/
|
*/
|
||||||
public function requestUpdate(DeploymentService $deployment): void
|
public function requestUpdate(DeploymentService $deployment): mixed
|
||||||
{
|
{
|
||||||
$channel = $this->channel();
|
$channel = $this->channel();
|
||||||
$installed = (string) config('clusev.version');
|
$installed = (string) config('clusev.version');
|
||||||
|
|
@ -184,7 +184,7 @@ class Index extends Component
|
||||||
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
||||||
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
|
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
|
||||||
|
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
$this->updateState = 'update';
|
$this->updateState = 'update';
|
||||||
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
||||||
|
|
@ -193,7 +193,7 @@ class Index extends Component
|
||||||
if (RateLimiter::tooManyAttempts($key, 3)) {
|
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||||
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||||
|
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
RateLimiter::hit($key, 600);
|
RateLimiter::hit($key, 600);
|
||||||
|
|
||||||
|
|
@ -202,7 +202,7 @@ class Index extends Component
|
||||||
if (! $deployment->requestUpdate()) {
|
if (! $deployment->requestUpdate()) {
|
||||||
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
|
$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
|
// 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
|
// 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->updateBaseVersion = $installed; // completion = the running version moves past this
|
||||||
$this->updatePolls = 0;
|
$this->updatePolls = 0;
|
||||||
$this->dispatch('notify', message: __('versions.update_started'));
|
$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. */
|
/** Resolve the configured channel, clamped to the user-facing set. */
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -107,5 +107,9 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/versions', Versions\Index::class)->name('versions');
|
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
Route::get('/help', Help\Index::class)->name('help');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue