diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5c41d..5dc5b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.13] - 2026-06-19 + +### Behoben +- **Update-Status übersteht jetzt einen Reload (in Redis statt Sentinel gespeichert).** In 0.9.12 + wurde der laufende Zustand aus der Sentinel-Datei abgeleitet — die der Host-Watcher aber **vor** + dem Update verbraucht, sodass ein Reload mitten im Update nichts mehr anzeigte. Der „läuft"-Status + wird jetzt als Redis-Marker (mit der Ausgangsversion) persistiert: er übersteht sowohl den Reload + als auch den Container-Neustart. Nach einem Reload wird entweder „Update läuft" wieder aufgenommen + (inkl. Polling) oder — falls das Update inzwischen durch ist — direkt „Aktuell — vX" mit + Erfolgsmeldung angezeigt und der Marker gelöscht (15-Min-Backstop-TTL, damit nichts hängen bleibt). + ## [0.9.12] - 2026-06-19 ### Hinzugefügt diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 5ef9223..a70d1c8 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -27,6 +27,15 @@ class Index extends Component /** Only these channels are ever offered to users — there is no 'dev' channel. */ private const CHANNELS = ['stable', 'beta']; + /** + * Cache (Redis in prod) marker for an in-flight update: holds the version that was running + * when the update was requested. Persisted in Redis — NOT the sentinel file (which the host + * watcher consumes before update.sh runs) — so the "update läuft" state survives BOTH a page + * reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a + * backstop so a failed update can never wedge the banner. Completion = running version > base. + */ + private const UPDATE_FLAG = 'clusev:update-in-progress'; + public ?string $lastChecked = null; public ?string $updateStatus = null; @@ -44,19 +53,38 @@ class Index extends Component public int $updatePolls = 0; /** - * On load: resume the "running" state if an update sentinel is still pending (survives a - * reload mid-update), and — without any network call — surface an available update from the - * cached check so the button shows immediately (the badge already knew). No re-click needed. + * On load, in order: + * 1. If an update is in flight (Redis marker): either announce it just finished (running + * version now past the marked base → success toast + clear) or resume the "läuft" state + + * poll. This is what makes a reload mid-update show the right thing (the marker lives in + * Redis, surviving both the reload and the app rebuild). + * 2. Otherwise surface an available update straight from the cached check (no network), so the + * button shows immediately when the badge already knows a newer version — no re-click. */ - public function mount(DeploymentService $deployment): void + public function mount(): void { - if ($deployment->updateRequested()) { + $installed = (string) config('clusev.version'); + $marker = Cache::get(self::UPDATE_FLAG); + + if (is_array($marker) && isset($marker['base'])) { + if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) { + // Finished while the operator was away (or reloaded) — announce + clear. + Cache::forget(self::UPDATE_FLAG); + $this->updateState = 'current'; + $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]); + $this->dispatch('notify', message: __('versions.update_done', ['version' => $installed])); + + return; + } + // Still running — resume the notice + polling. $this->updateRequested = true; - $this->updateBaseVersion = (string) config('clusev.version'); + $this->updateBaseVersion = (string) $marker['base']; + + return; } $latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load - if (! $this->updateRequested && $latest !== null && $this->isNewer($latest, (string) config('clusev.version'))) { + if ($latest !== null && $this->isNewer($latest, $installed)) { $this->updateState = 'update'; $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]); } @@ -78,6 +106,7 @@ class Index extends Component $this->updateBaseVersion ??= $current; if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) { + Cache::forget(self::UPDATE_FLAG); $this->updateRequested = false; $this->updatePolls = 0; $this->updateState = 'current'; @@ -88,6 +117,7 @@ class Index extends Component } if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning. + Cache::forget(self::UPDATE_FLAG); $this->updateRequested = false; $this->updatePolls = 0; $this->dispatch('notify', message: __('versions.update_stalled'), level: 'error'); @@ -154,6 +184,10 @@ class Index extends Component RateLimiter::hit($key, 600); $deployment->requestUpdate(); + // 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 + // moves past this base. 15 min TTL > the poll timeout, so a reload can still resume. + Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15)); AuditEvent::create([ 'user_id' => Auth::id(), diff --git a/config/clusev.php b/config/clusev.php index e8f80b9..d150f16 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.12', + 'version' => '0.9.13', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index a33f4fe..a1d57ed 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -148,13 +148,29 @@ class VersionUpdateCheckTest extends TestCase ->assertSee('0.9.11'); } - public function test_a_pending_update_sentinel_resumes_the_running_state_on_load(): void + public function test_an_in_progress_marker_resumes_the_running_state_on_load(): void { - app(DeploymentService::class)->requestUpdate(); // sentinel present (update in flight) + // Redis marker (not the consumed sentinel) is what survives a reload mid-update. + config()->set('clusev.version', '0.9.11'); + Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15)); Livewire::test(Index::class)->assertSet('updateRequested', true); } + public function test_a_finished_update_is_announced_and_cleared_on_load(): void + { + // Reloaded after the rebuild: the running version is now past the marked base → done. + config()->set('clusev.version', '0.9.12'); + Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15)); + + Livewire::test(Index::class) + ->assertSet('updateRequested', false) + ->assertSet('updateState', 'current') + ->assertSee('0.9.12'); + + $this->assertNull(Cache::get('clusev:update-in-progress'), 'marker cleared once the update landed'); + } + public function test_poll_update_reports_completion_when_the_version_moves_past_the_base(): void { config()->set('clusev.version', '0.9.10');