fix(update): make the update-progress screen survive a browser refresh

The self-update progress screen reset its elapsed timer to 0 and dropped the
phase checklist back to step one on every browser refresh — and the long image
build made it look like the update had restarted. Root causes: the timer and
phase state lived only in page JS (Date.now() at page load), and a
30s-before-page-load staleness guard rejected the real phase feed whenever a
stage had run longer than 30s (which the image build always does).

- DeploymentService: requestUpdate() now also writes a best-effort
  run/update-started.json {at,from}. New updateStartedAt()/updatePhase() expose
  the server-side start + current macro-stage; updatePhase() requires a positive
  timestamp so a corrupt or stale 'done' can never drive a premature finish.
  clearUpdateRequest() also removes the start marker.
- routes: /update-status.json delegates to updatePhase(); /update-progress
  passes startedAt/initialPhase/hasFeed. A stage is trusted only when tied to
  THIS update's start (startedAt) and written at/after it — a leftover stage from
  a previous run can no longer resume or trigger a premature completion.
- update-progress.blade.php: the elapsed timer is anchored to the server-side
  start (counts the real elapsed across a refresh); the checklist resumes at the
  live stage; the live feed is authoritative (corrects any time-heuristic
  overshoot) and the heuristic stays off once a real stage is known.

Verified: 28 UpdateProgressTest cases + full suite (426) green, pint clean, and a
headless browser load+reload mid-update keeps the timer counting and the phase in
place with zero console errors. Codex review: two premature-completion edge cases
found and fixed (timestamp validation + start-anchored freshness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-24 23:05:54 +02:00
parent e39a5939f2
commit 05c34b81ed
4 changed files with 255 additions and 27 deletions

View File

@ -52,6 +52,20 @@ class DeploymentService
*/
private const UPDATE_FILE = 'app/restart-signal/update.request';
/**
* Update start marker written (best-effort) when an update is requested, on the same
* bind-mounted volume. The update-progress page reads it so its elapsed timer and live phase
* checklist are anchored to the SERVER-side start, not to page-load time a browser refresh
* mid-update no longer resets the timer to zero nor drops the checklist back to phase one.
*/
private const UPDATE_STARTED_FILE = 'app/restart-signal/update-started.json';
/**
* Coarse update macro-stage the host updater (update.sh / install.sh) writes as it progresses
* (fetch build restart migrate done). Same bind-mounted directory. Read-only token.
*/
private const UPDATE_PHASE_FILE = 'app/restart-signal/update-phase.json';
/**
* The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
* (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
@ -209,6 +223,58 @@ class DeploymentService
return storage_path(self::UPDATE_FILE);
}
/** Absolute path of the update-start marker on the shared (bind-mounted) volume. */
public function updateStartedPath(): string
{
return storage_path(self::UPDATE_STARTED_FILE);
}
/**
* Epoch seconds when the current update was requested, or null if there is no readable marker.
* The update-progress page anchors its elapsed timer and live phase feed to this so both
* survive a browser refresh instead of resetting on every page load. Never throws.
*/
public function updateStartedAt(): ?int
{
$path = $this->updateStartedPath();
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
$at = is_array($data) ? ($data['at'] ?? null) : null;
return is_int($at) && $at > 0 ? $at : null;
}
/**
* The current update macro-stage written by the host updater, or null when absent/unrecognised.
* Only ever returns a whitelisted stage token (never arbitrary file content). Shape:
* ['stage' => 'fetch'|'build'|'restart'|'migrate'|'done', 'at' => <epoch>]. Never throws.
*
* @return array{stage: string, at: int}|null
*/
public function updatePhase(): ?array
{
$path = storage_path(self::UPDATE_PHASE_FILE);
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
if (! is_array($data) || ! in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done'], true)) {
return null;
}
// Require a real timestamp: the progress page's freshness logic ties a stage to THIS update
// via its `at`. A missing/zero/non-int `at` (corrupt file, or the host's date-failed `echo 0`
// fallback) must NOT pass as a current stage — otherwise a stale 'done' could trigger a
// premature completion redirect. Treat it as "no stage" and let the page fall back.
$at = $data['at'] ?? null;
if (! is_int($at) || $at <= 0) {
return null;
}
return ['stage' => (string) $data['stage'], 'at' => $at];
}
/**
* Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped
* clusev-update.path unit) reacts by running update.sh git pull + idempotent re-install
@ -226,7 +292,19 @@ class DeploymentService
// Returns false when the (bind-mounted) sentinel dir is not writable by the app user —
// the caller surfaces that instead of showing a "running" state that never resolves.
return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false;
$written = @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false;
if ($written) {
// Best-effort start marker (a failure here must never fail the update): lets the
// progress page render a refresh-proof elapsed time and accept the live phase feed
// regardless of how long the current stage has been running.
@file_put_contents($this->updateStartedPath(), json_encode([
'at' => now()->timestamp,
'from' => (string) config('clusev.version'),
]).PHP_EOL);
}
return $written;
}
/** True while an update request is pending (the host watcher clears it once handled). */
@ -235,13 +313,14 @@ class DeploymentService
return is_file($this->updateSignalPath());
}
/** Remove the update sentinel (tests / manual reset; the host watcher deletes it normally). */
/** Remove the update sentinel + its start marker (tests / manual reset; the host watcher deletes
* the sentinel normally). */
public function clearUpdateRequest(): void
{
$path = $this->updateSignalPath();
if (is_file($path)) {
@unlink($path);
foreach ([$this->updateSignalPath(), $this->updateStartedPath()] as $path) {
if (is_file($path)) {
@unlink($path);
}
}
}

View File

@ -128,8 +128,16 @@
// previous update. The feed only became active one release after this shipped (the page
// running DURING an update is the old version's) — so the heuristic below is the fallback.
var pageStart = {{ now()->timestamp }};
// Server epoch when THIS update was requested (or null on a pre-fix install). Both the
// elapsed timer and the phase-feed staleness guard are anchored to it, so a browser refresh
// mid-update keeps counting from the real start and keeps the checklist at the live phase.
var startedAt = {{ $startedAt !== null ? (int) $startedAt : 'null' }};
var STAGE_INDEX = { fetch: 0, build: 1, restart: 2, migrate: 3 };
var feedActive = false; // set once /update-status.json returns a fresh stage
// True from the start when the server already saw a fresh stage for THIS update — then the
// checklist is driven by the real feed and the time heuristic stays off (so a refresh with a
// large elapsed time cannot make the heuristic overshoot past the real phase). Otherwise the
// heuristic is the fallback until /update-status.json answers.
var feedActive = {{ $hasFeed ? 'true' : 'false' }};
// ── Phase timing heuristic (fallback when no real feed is available) ───
var PHASE_KEYS = ['fetch', 'build', 'restart', 'migrate'];
@ -167,10 +175,15 @@
currentPhase = index;
}
setPhaseActive(0);
// Resume at the live phase the server reported (not back to phase one) — refresh-proof.
setPhaseActive({{ (int) $initialPhase }});
// ── Elapsed timer ──────────────────────────────────────────────────────
var startTime = Date.now();
// Anchored to the server-side start: (pageStart - startedAt) seconds have already elapsed at
// render; the client clock only adds the smooth delta since this page loaded. A refresh
// therefore resumes the true elapsed time instead of restarting from zero.
var clientLoad = Date.now();
var baseElapsedMs = startedAt ? Math.max(0, pageStart - startedAt) * 1000 : 0;
var elapsedEl = document.getElementById('js-elapsed-value');
var TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
@ -224,7 +237,7 @@
}
function tick() {
var elapsed = Date.now() - startTime;
var elapsed = baseElapsedMs + (Date.now() - clientLoad);
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
@ -294,12 +307,20 @@
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) {
if (!data || !data.stage) return;
// Ignore a stage file left over from a previous update (written before this load).
if (typeof data.at === 'number' && data.at > 0 && data.at < pageStart - 30) return;
// Only trust a stage we can tie to THIS update: we must know when the update
// began (startedAt) and the stage must have been written at/after it. No start
// marker, a missing/zero timestamp, or a stage predating this update → ignore the
// feed (the time heuristic drives). This both keeps a long-running stage of the
// current update (old timestamp, still valid) AND blocks a stale 'done' from a
// previous run triggering a premature completion on refresh.
if (!startedAt || typeof data.at !== 'number' || data.at < startedAt - 5) return;
feedActive = true;
if (data.stage === 'done') { showDone(); return; }
var idx = STAGE_INDEX[data.stage];
if (typeof idx === 'number' && idx > currentPhase) setPhaseActive(idx);
// The live feed is authoritative — the host writes stages monotonically forward,
// so apply it even if not greater than the current index, correcting any earlier
// time-heuristic overshoot rather than being suppressed by it.
if (typeof idx === 'number') setPhaseActive(idx);
})
.catch(function () { /* feed unreachable mid-cutover — keep the last known phase */ });
}

View File

@ -54,19 +54,10 @@ Route::get('/version.json', fn () => response()->json(['version' => (string) con
// run/update-phase.json (bind-mounted to storage/app/restart-signal/); the update-progress page
// polls this to show REAL progress (fetch → build → restart → migrate → done) instead of a time
// guess. PUBLIC and only ever echoes a whitelisted stage token — never any sensitive data.
Route::get('/update-status.json', function () {
$path = storage_path('app/restart-signal/update-phase.json');
$stage = null;
$at = 0;
if (is_file($path)) {
$data = json_decode((string) @file_get_contents($path), true);
if (is_array($data) && in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done'], true)) {
$stage = $data['stage'];
$at = (int) ($data['at'] ?? 0);
}
}
Route::get('/update-status.json', function (DeploymentService $deployment) {
$phase = $deployment->updatePhase();
return response()->json(['stage' => $stage, 'at' => $at]);
return response()->json(['stage' => $phase['stage'] ?? null, 'at' => $phase['at'] ?? 0]);
})->name('update.status');
Route::middleware('guest')->group(function () {
@ -148,14 +139,35 @@ Route::middleware('auth')->group(function () {
// 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', function (Request $request) {
Route::get('/update-progress', function (Request $request, DeploymentService $deployment) {
$parsed = parse_url((string) $request->query('return', ''), PHP_URL_PATH);
$return = (is_string($parsed) && str_starts_with($parsed, '/') && ! str_starts_with($parsed, '//')) ? $parsed : '/';
// Pre-update version (sanitised to the semver charset). The page finishes only once the
// running version has moved past this — see resources/views/update-progress.blade.php.
$from = preg_replace('/[^0-9A-Za-z.\-]/', '', (string) $request->query('from', ''));
return view('update-progress', ['returnPath' => $return, 'fromVersion' => $from]);
// Server-side update start + current macro-stage so the page is REFRESH-PROOF: the
// elapsed timer counts from the real start, and the checklist resumes at the live phase
// (not back to phase one). A stage is only trusted when we know when THIS update began
// (startedAt, written by requestUpdate) AND the stage was written at/after it — so a
// leftover stage from a previous run can never drive the checklist or a premature finish.
$startedAt = $deployment->updateStartedAt();
$phase = $deployment->updatePhase();
$stageIndex = ['fetch' => 0, 'build' => 1, 'restart' => 2, 'migrate' => 3, 'done' => 4];
$initialPhase = 0;
$hasFeed = false; // a fresh real stage exists → drive the checklist from the feed, not the time guess
if ($phase !== null && $startedAt !== null && $phase['at'] >= $startedAt - 5) {
$initialPhase = $stageIndex[$phase['stage']] ?? 0;
$hasFeed = true;
}
return view('update-progress', [
'returnPath' => $return,
'fromVersion' => $from,
'startedAt' => $startedAt,
'initialPhase' => $initialPhase,
'hasFeed' => $hasFeed,
]);
})->name('update.progress');
});
});

View File

@ -287,4 +287,120 @@ class UpdateProgressTest extends TestCase
@unlink($dir.'/update-phase.json');
}
}
// ── (c) resumable progress (survives a browser refresh) ───────────────────
public function test_request_update_writes_a_recent_start_marker(): void
{
config()->set('clusev.version', '0.9.4');
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
$before = now()->timestamp;
Livewire::test(Index::class)->call('requestUpdate');
$startedAt = app(DeploymentService::class)->updateStartedAt();
$this->assertIsInt($startedAt, 'requestUpdate must record when the update was requested');
$this->assertGreaterThanOrEqual($before, $startedAt);
$this->assertLessThanOrEqual(now()->timestamp + 1, $startedAt);
}
public function test_update_started_at_is_null_without_a_marker(): void
{
$this->assertNull(app(DeploymentService::class)->updateStartedAt());
}
public function test_clear_update_request_also_removes_the_start_marker(): 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->assertNotNull(app(DeploymentService::class)->updateStartedAt());
app(DeploymentService::class)->clearUpdateRequest();
$this->assertNull(app(DeploymentService::class)->updateStartedAt());
}
public function test_update_progress_embeds_the_start_marker_when_present(): void
{
$deployment = app(DeploymentService::class);
@mkdir(dirname($deployment->updateStartedPath()), 0775, true);
file_put_contents($deployment->updateStartedPath(), json_encode(['at' => 1700000000, 'from' => '0.9.4']));
$body = $this->get(route('update.progress'))->content();
$this->assertStringContainsString(
'var startedAt = 1700000000;',
$body,
'the page must embed the server-side update start so the elapsed timer survives a refresh',
);
}
public function test_update_progress_embeds_null_start_when_absent(): void
{
$body = $this->get(route('update.progress'))->content();
$this->assertStringContainsString('var startedAt = null;', $body);
}
public function test_update_progress_resumes_the_phase_from_the_feed_file(): void
{
$deployment = app(DeploymentService::class);
$dir = dirname($deployment->updateStartedPath());
@mkdir($dir, 0775, true);
$now = now()->timestamp;
file_put_contents($deployment->updateStartedPath(), json_encode(['at' => $now - 120, 'from' => '0.9.4']));
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'restart', 'at' => $now - 90]));
try {
$body = $this->get(route('update.progress'))->content();
// restart = index 2: the checklist must initialise at phase 2 (not reset to 0), even
// though the stage was written 90s ago — far past the old 30s staleness window.
$this->assertStringContainsString('setPhaseActive(2);', $body);
// A fresh real stage drives the checklist from the feed — the time heuristic stays off
// so a large elapsed time on refresh cannot overshoot past the real phase.
$this->assertStringContainsString('var feedActive = true;', $body);
} finally {
@unlink($dir.'/update-phase.json');
}
}
public function test_update_status_ignores_a_stage_without_a_valid_timestamp(): void
{
$dir = storage_path('app/restart-signal');
@mkdir($dir, 0775, true);
// A whitelisted stage but NO timestamp must not pass as a current stage — otherwise a stale
// 'done' could drive a premature completion redirect on the progress page.
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'done']));
try {
$this->getJson('/update-status.json')
->assertOk()
->assertJson(['stage' => null]);
} finally {
@unlink($dir.'/update-phase.json');
}
}
public function test_update_progress_ignores_a_phase_that_predates_this_update(): void
{
$deployment = app(DeploymentService::class);
$dir = dirname($deployment->updateStartedPath());
@mkdir($dir, 0775, true);
$now = now()->timestamp;
// The start marker is NEWER than the leftover stage (a 'done' from a previous run) — the page
// must NOT resume it: it stays at phase one with the feed off (heuristic drives) so the old
// 'done' can't trigger a premature finish.
file_put_contents($deployment->updateStartedPath(), json_encode(['at' => $now, 'from' => '0.9.4']));
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'done', 'at' => $now - 300]));
try {
$body = $this->get(route('update.progress'))->content();
$this->assertStringContainsString('setPhaseActive(0);', $body);
$this->assertStringContainsString('var feedActive = false;', $body);
} finally {
@unlink($dir.'/update-phase.json');
}
}
}