Show a full-screen overlay on every console page while an update runs
tests / pest (push) Failing after 7m27s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-28 18:50:45 +02:00
parent 164145463c
commit c2681f2801
7 changed files with 143 additions and 18 deletions

View File

@ -116,6 +116,11 @@ return [
'running' => 'läuft', '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 // Angefordert, aber der Dienst prüft im Takt — ohne Uhrzeit sitzt man davor
// und weiß nicht, ob der Knopf überhaupt etwas getan hat. // und weiß nicht, ob der Knopf überhaupt etwas getan hat.
'update_queued' => 'Angefordert. Der Update-Dienst startet sie spätestens um :time.', 'update_queued' => 'Angefordert. Der Update-Dienst startet sie spätestens um :time.',

View File

@ -116,6 +116,10 @@ return [
'running' => 'running', '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 // 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. // 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.', 'update_queued' => 'Requested. The update service starts it by :time at the latest.',

View File

@ -105,13 +105,17 @@ document.addEventListener('alpine:init', () => {
// This asks a plain JSON endpoint instead — no Livewire, no component // This asks a plain JSON endpoint instead — no Livewire, no component
// state, no assets — and, crucially, treats a failed request as the // state, no assets — and, crucially, treats a failed request as the
// restart rather than as an error worth giving up over. // 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 // The build this page was rendered from. When the server stops
// agreeing, the page is stale by definition and must be replaced — // agreeing, the page is stale by definition and must be replaced —
// this is the only signal that survives the restart, since the run has // this is the only signal that survives the restart, since the run has
// already ended by the time we can ask again. // already ended by the time we can ask again.
renderedCommit: commit, renderedCommit: commit,
wasRunning: running, 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, timer: null,
init() { init() {
@ -156,6 +160,7 @@ document.addEventListener('alpine:init', () => {
} }
this.wasRunning = state.running; this.wasRunning = state.running;
this.step = state.step ?? null;
} catch { } catch {
// Expected while the containers are down. Keep asking: this is // Expected while the containers are down. Keep asking: this is
// the middle of the very event being watched, not a fault. // the middle of the very event being watched, not a fault.

View File

@ -11,7 +11,19 @@
corners. Colour comes from one place. corners. Colour comes from one place.
--}} --}}
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'"> <body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
@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
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden"> <div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
<x-shell.nav <x-shell.nav
@ -64,6 +76,57 @@
<span x-text="msg"></span> <span x-text="msg"></span>
</div> </div>
{{-- 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. --}}
<div
x-data="updateWatcher({
url: '{{ route('admin.update.state') }}',
commit: @js($updateState['commit']),
running: @js($updateState['running']),
step: @js($updateStep),
})"
x-show="wasRunning"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-[100] flex items-center justify-center bg-bg px-4"
role="alert"
aria-live="assertive"
>
<div class="w-full max-w-sm rounded-lg border border-line bg-surface p-8 text-center shadow-md">
<div class="mx-auto flex size-14 items-center justify-center rounded-pill bg-info-bg text-info">
<x-ui.icon name="refresh" class="size-7 animate-spin" />
</div>
<h1 class="mt-5 text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.update_overlay_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
{{-- Only while there is one to show a queued run has no phase
yet, and "Schritt:" with nothing after it is worse than
leaving the line out. --}}
<p class="mt-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm font-medium text-body"
x-show="step" x-text="step"></p>
</div>
</div>
@livewire('wire-elements-modal') @livewire('wire-elements-modal')
@livewireScripts @livewireScripts
</body> </body>

View File

@ -14,17 +14,15 @@
restarts the thing doing the watching. Mid-run the requests fail, restarts the thing doing the watching. Mid-run the requests fail,
and what answers afterwards is a NEW build being questioned by the 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 old page's JavaScript so the card sat on "läuft" until somebody
reloaded. The watcher below rides through that: it asks a plain reloaded. That gap is now covered by the full-screen overlay in
JSON endpoint, treats a failed request as the restart rather than layouts/admin.blade.php, which binds the same watcher once for
as an error, and reloads the page once the build it is looking at every console page rather than here this card no longer binds
is no longer the build it started with. --}} 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. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise" <div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ $update['running'] ? '3s' : '30s' }} wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
x-data="updateWatcher({
url: '{{ route('admin.update.state') }}',
commit: @js($update['commit']),
running: @js($update['running']),
})">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">

View File

@ -86,5 +86,12 @@ Route::get('/update/state', function (UpdateChannel $channel) {
'commit' => $state['commit'], 'commit' => $state['commit'],
'behind' => $state['behind'], 'behind' => $state['behind'],
'last_finished_at' => $state['last_finished_at']?->toIso8601String(), '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'); ])->header('Cache-Control', 'no-store');
})->name('update.state'); })->name('update.state');

View File

@ -403,18 +403,33 @@ it('still refuses when there is no agent to pick the request up', function () {
->assertDontSee(__('admin_settings.update_recheck')); ->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 // 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([ writeStatus([
'checked_at' => now()->toIso8601String(), 'checked_at' => now()->toIso8601String(),
'state' => 'idle', 'state' => 'idle',
'behind' => 2, 'behind' => 2,
]); ]);
Livewire::actingAs(operator('Owner'), 'operator') $html = $this->actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class) ->get(route('admin.settings'))
->assertSee('updateWatcher', escape: false); ->assertOk()
->getContent();
expect(substr_count($html, 'updateWatcher('))->toBe(1);
}); });
it('answers the watcher without Livewire in the way', function () { 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') $this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state')) ->get(route('admin.update.state'))
->assertOk() ->assertOk()
->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at']) ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step'])
->assertHeader('Cache-Control', 'no-store, private'); ->assertHeader('Cache-Control', 'no-store, private');
}); });
it('carries the current step in the watcher JSON, already in the operators 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 () { it('does not answer the watcher for somebody who is not an operator', function () {
$this->actingAs(\App\Models\User::factory()->create()) $this->actingAs(\App\Models\User::factory()->create())
->get(route('admin.update.state')) ->get(route('admin.update.state'))