Show a full-screen overlay on every console page while an update runs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
164145463c
commit
c2681f2801
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 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 () {
|
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'))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue