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',
|
||||
],
|
||||
|
||||
// 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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
Loading…
Reference in New Issue