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',
],
// 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.',

View File

@ -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.',

View File

@ -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.

View File

@ -11,7 +11,19 @@
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'">
@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">
<x-shell.nav
@ -64,6 +76,57 @@
<span x-text="msg"></span>
</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')
@livewireScripts
</body>

View File

@ -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. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ $update['running'] ? '3s' : '30s' }}
x-data="updateWatcher({
url: '{{ route('admin.update.state') }}',
commit: @js($update['commit']),
running: @js($update['running']),
})">
wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<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'],
'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');

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'));
});
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 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 () {
$this->actingAs(\App\Models\User::factory()->create())
->get(route('admin.update.state'))