diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 7d7414d..ffd62c4 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -116,6 +116,11 @@ return [ 'running' => 'läuft', ], + // Vollbild-Ansicht in der Konsolen-Hülle (layouts/admin.blade.php) — steht + // auf jeder Konsolenseite, solange eine Aktualisierung läuft, nicht nur + // auf der Einstellungsseite. + 'update_overlay_title' => 'Aktualisierung läuft.', + // Angefordert, aber der Dienst prüft im Takt — ohne Uhrzeit sitzt man davor // und weiß nicht, ob der Knopf überhaupt etwas getan hat. 'update_queued' => 'Angefordert. Der Update-Dienst startet sie spätestens um :time.', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index d7b19ff..6620734 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -116,6 +116,10 @@ return [ 'running' => 'running', ], + // Full-screen view in the console shell (layouts/admin.blade.php) — shown + // on every console page while an update is running, not just Settings. + 'update_overlay_title' => 'Update in progress.', + // Requested, but the service checks on a timer — without a time an operator // sits in front of it not knowing whether the button did anything. 'update_queued' => 'Requested. The update service starts it by :time at the latest.', diff --git a/resources/js/app.js b/resources/js/app.js index 07fdb27..cba47a8 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -105,13 +105,17 @@ document.addEventListener('alpine:init', () => { // This asks a plain JSON endpoint instead — no Livewire, no component // state, no assets — and, crucially, treats a failed request as the // restart rather than as an error worth giving up over. - window.Alpine.data('updateWatcher', ({ url, commit, running }) => ({ + window.Alpine.data('updateWatcher', ({ url, commit, running, step = null }) => ({ // The build this page was rendered from. When the server stops // agreeing, the page is stale by definition and must be replaced — // this is the only signal that survives the restart, since the run has // already ended by the time we can ask again. renderedCommit: commit, wasRunning: running, + // Already translated server-side ("Schritt: …") — the client only + // ever displays this, never assembles it, so German text stays out + // of JavaScript entirely. + step, timer: null, init() { @@ -156,6 +160,7 @@ document.addEventListener('alpine:init', () => { } this.wasRunning = state.running; + this.step = state.step ?? null; } catch { // Expected while the containers are down. Keep asking: this is // the middle of the very event being watched, not a fault. diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 18f9808..ba92029 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -11,7 +11,19 @@ corners. Colour comes from one place. --}} - @php $release = App\Services\Deployment\Release::current(); @endphp + @php + $release = App\Services\Deployment\Release::current(); + + // Seeds the page-wide update watcher below. Computed here, once, for + // every console page — not only Settings — because the thing being + // watched restarts the containers serving whichever page the + // operator happens to have open, so the watcher (and its overlay) + // must already exist before that happens, wherever they are. + $updateState = app(App\Services\Deployment\UpdateChannel::class)->state(); + $updateStep = $updateState['phase'] !== null + ? __('admin_settings.update_step', ['step' => $updateState['phase']]) + : null; + @endphp
+ {{-- Maintenance overlay: the console during a deployment. + Bound HERE, once, rather than on the settings page's own update + card — this is the only binding of updateWatcher (resources/js/app.js) + in the app, so it is already in the browser, and stays in the + browser, no matter which console page the deploy catches the + operator on. Reported bug: the settings card flickered and vanished + mid-run, and every OTHER console page showed nothing at all, because + nothing was watching there. + + Client-side is the only way this can work: the containers go down + mid-run, which is exactly when the view has to stay visible — a + server-rendered maintenance route cannot answer while it is itself + offline. + + No close affordance: the run does not stop because an operator + closed a dialog, so offering one would be a lie. It clears itself — + window.location.reload() from inside updateWatcher — the moment the + build commit changes or `running` goes false, landing the operator + back on the very page they were on. --}} + + @livewire('wire-elements-modal') @livewireScripts diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 2e29182..a6b3299 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -14,17 +14,15 @@ restarts the thing doing the watching. Mid-run the requests fail, and what answers afterwards is a NEW build being questioned by the old page's JavaScript — so the card sat on "läuft" until somebody - reloaded. The watcher below rides through that: it asks a plain - JSON endpoint, treats a failed request as the restart rather than - as an error, and reloads the page once the build it is looking at - is no longer the build it started with. --}} + reloaded. That gap is now covered by the full-screen overlay in + layouts/admin.blade.php, which binds the same watcher once for + every console page rather than here — this card no longer binds + it itself, so the two do not double-poll while this page is + open. wire:poll below is unrelated and stays: it is Livewire's + own refresh of this card's own details (log tail, elapsed time) + for as long as a Livewire connection exists. --}}
+ wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
diff --git a/routes/admin.php b/routes/admin.php index 3d377b8..4267231 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -86,5 +86,12 @@ Route::get('/update/state', function (UpdateChannel $channel) { 'commit' => $state['commit'], 'behind' => $state['behind'], 'last_finished_at' => $state['last_finished_at']?->toIso8601String(), + // Already localized ("Schritt: …"), the same sentence the settings + // card itself renders — so the maintenance overlay (layouts/admin) + // only ever displays a ready-made string and never assembles German + // text in JavaScript. + 'step' => $state['phase'] !== null + ? __('admin_settings.update_step', ['step' => $state['phase']]) + : null, ])->header('Cache-Control', 'no-store'); })->name('update.state'); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 6235045..40864e4 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -403,18 +403,33 @@ it('still refuses when there is no agent to pick the request up', function () { ->assertDontSee(__('admin_settings.update_recheck')); }); -it('watches the deployment in a way that survives the restart', function () { +it('carries the watcher onto every console page, not only settings', function () { // wire:poll cannot see the run through to the end, because the run - // restarts the containers answering the poll. + // restarts the containers answering the poll — so the watcher (and the + // maintenance overlay it drives) lives in the layout, not this one page, + // and has to be there however an operator reaches the console. + $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.overview')) + ->assertOk() + ->assertSee('updateWatcher(', escape: false); +}); + +it('binds the watcher exactly once when the settings page is open', function () { + // The settings card used to bind its own copy as well as the layout now + // doing so for every page — two pollers racing each other against the + // same endpoint. writeStatus([ 'checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 2, ]); - Livewire::actingAs(operator('Owner'), 'operator') - ->test(AdminSettings::class) - ->assertSee('updateWatcher', escape: false); + $html = $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.settings')) + ->assertOk() + ->getContent(); + + expect(substr_count($html, 'updateWatcher('))->toBe(1); }); it('answers the watcher without Livewire in the way', function () { @@ -423,10 +438,38 @@ it('answers the watcher without Livewire in the way', function () { $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() - ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at']) + ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step']) ->assertHeader('Cache-Control', 'no-store, private'); }); +it('carries the current step in the watcher JSON, already in the operator’s language', function () { + // The overlay only ever displays this string — it must arrive ready to + // print, the same sentence the settings card itself renders. + writeStatus([ + 'state' => 'running', + 'checked_at' => now()->toIso8601String(), + 'started_at' => now()->subMinute()->toIso8601String(), + 'behind' => 1, + ]); + writePhase('migrate'); + + $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.update.state')) + ->assertOk() + ->assertJson(['step' => __('admin_settings.update_step', [ + 'step' => __('admin_settings.update_phase.migrate'), + ])]); +}); + +it('carries no step in the watcher JSON before the deployment has announced one', function () { + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); + + $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.update.state')) + ->assertOk() + ->assertJson(['step' => null]); +}); + it('does not answer the watcher for somebody who is not an operator', function () { $this->actingAs(\App\Models\User::factory()->create()) ->get(route('admin.update.state'))