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
parent
e39a5939f2
commit
05c34b81ed
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */ });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue