Show one design while updating, not two swapping mid-run

Reported from live: pressing update showed a blue panel first, which then
vanished and was replaced by a completely different "we are updating"
screen. Both were mine. The console drew its own overlay in the app's light
tokens; a moment later the containers went down and Laravel's 503 page took
over with its own standalone styling. One event, two faces, swapping while
an operator watches.

There is now one panel — resources/views/partials/updating-panel.blade.php —
rendered by both. It carries its own inline CSS and cpu- prefixed class
names because the 503 page is served with the application down: no compiled
stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes
from prefers-color-scheme for the same reason.

The operator detail (step, running since, log tail) rides inside the panel
behind a flag, since the same file is shown to customers on the 503 page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 10:20:40 +02:00
parent 15b536f393
commit 0671e8e119
4 changed files with 116 additions and 65 deletions

View File

@ -13,33 +13,8 @@
mid-deploy or freshly installed, which is exactly when the Vite
manifest may not exist. Pulling it in through @vite would throw an
exception instead of rendering the reassurance the visitor came for. --}}
<style>
:root { color-scheme: light dark; --bg: #f6f7f9; --fg: #0f172a; --muted: #64748b; --accent: #f97316; --card: #ffffff; --line: #e2e8f0; }
@media (prefers-color-scheme: dark) { :root { --bg: #0b0f19; --fg: #e2e8f0; --muted: #94a3b8; --card: #111827; --line: #1f2937; } }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 4rem 1.5rem; text-align: center;
background: var(--bg); color: var(--fg);
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 30rem; }
.mark { width: 2.5rem; height: 2.5rem; margin: 0 auto; border-radius: 0.75rem; background: var(--accent); }
h1 { margin: 1.75rem 0 0; font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; }
p { margin: 0.75rem 0 0; font-size: 0.9rem; line-height: 1.65; color: var(--muted); }
.badge { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 1.25rem; padding: 0.25rem 0.75rem;
border: 1px solid var(--line); border-radius: 9999px; background: var(--card);
font-size: 0.72rem; font-weight: 600; color: var(--accent); }
.dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: var(--accent); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .dot { animation: none; } }
.foot { margin-top: 2rem; font-size: 0.75rem; color: var(--muted); }
</style>
</head>
<body>
<main>
<div class="mark"></div>
<div class="badge"><span class="dot"></span>{{ __('updating.badge') }}</div>
<h1>{{ __('updating.title') }}</h1>
<p>{{ __('updating.body') }}</p>
<p class="foot">{{ __('updating.auto') }}</p>
</main>
<body style="margin:0;min-height:100vh">
@include('partials.updating-panel')
</body>
</html>

View File

@ -119,47 +119,23 @@
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"
class="fixed inset-0 z-[100] overflow-auto"
role="alert"
aria-live="assertive"
>
<div class="w-full max-w-lg 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-accent-subtle text-accent-text">
<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>
{{-- Queued: the agent has not picked the request up yet, so there
is no step yet either.
This used to count down to the agent's next tick. It was wrong
twice over. Wrong in fact: the countdown target was recomputed
on every poll, and where the reported interval was shorter than
the timer actually installed it landed in the past every time
and reset to a full minute so it ran backwards four seconds
and jumped, forever. And wrong in kind: with the agent woken
the moment the request lands there is nothing left to wait for.
A sentence, not a clock. --}}
<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">
{{ $updateState['instant'] ? __('admin_settings.update_starting') : __('admin_settings.update_starting_queued') }}
</p>
{{-- Running: the step, how long it has been going, and the tail
of the log a run that IS stuck is then visibly stuck AT a
step, not just a spinner nobody can read anything from. --}}
<div x-show="step" class="mt-4 space-y-2 text-left">
<p class="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm font-medium text-body" x-text="step"></p>
<p class="text-xs text-muted" x-show="runningSince" x-text="runningSince"></p>
<pre
x-show="log"
x-text="log"
class="max-h-40 overflow-auto rounded-lg border border-line bg-surface-2 p-3 text-left font-mono text-xs leading-relaxed text-body"
></pre>
</div>
</div>
{{-- The SAME panel the 503 page renders. It used to be a light card in
the app's own tokens, which meant an operator saw one design while
the application was still up and a completely different one the
moment the containers went down two faces for one event, swapping
mid-update. The operator detail rides along inside it rather than
beside it. --}}
@include('partials.updating-panel', [
'body' => __('admin_settings.update_offline_hint'),
'detail' => true,
'starting' => $updateState['instant']
? __('admin_settings.update_starting')
: __('admin_settings.update_starting_queued'),
])
</div>
@livewire('wire-elements-modal')

View File

@ -0,0 +1,71 @@
{{--
"We are updating" one panel, used in both places it can appear.
It showed up twice with two different faces: the console's own overlay in the
app's light tokens while the application was still up, and then this, the
503 page, once the containers went down. Two designs for one event, one
replacing the other mid-update. Reported as "the first screen must not be
there", and rightly.
The styles are inline and self-contained rather than Tailwind classes,
because the other place this renders is the 503 page shown while the
application is mid-deploy or freshly installed, exactly when the Vite
manifest may not exist. A stylesheet reference there would throw instead of
rendering the reassurance the visitor came for.
Prefixed class names: this is injected into the console's own document,
which has a stylesheet of its own, and `.badge` is not a name to claim
globally.
--}}
<style>
.cpu-wrap { --cpu-bg: #f6f7f9; --cpu-fg: #0f172a; --cpu-muted: #64748b; --cpu-accent: #f97316; --cpu-card: #ffffff; --cpu-line: #e2e8f0;
display: grid; place-items: center; text-align: center; padding: 4rem 1.5rem; min-height: 100%;
background: var(--cpu-bg); color: var(--cpu-fg);
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; }
@media (prefers-color-scheme: dark) {
.cpu-wrap { --cpu-bg: #0b0f19; --cpu-fg: #e2e8f0; --cpu-muted: #94a3b8; --cpu-card: #111827; --cpu-line: #1f2937; }
}
.cpu-main { max-width: 34rem; width: 100%; }
.cpu-mark { width: 2.5rem; height: 2.5rem; margin: 0 auto; border-radius: 0.75rem; background: var(--cpu-accent); }
.cpu-badge { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 1.25rem; padding: 0.25rem 0.75rem;
border: 1px solid var(--cpu-line); border-radius: 9999px; background: var(--cpu-card);
font-size: 0.72rem; font-weight: 600; color: var(--cpu-accent); }
.cpu-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: var(--cpu-accent); animation: cpu-pulse 1.6s ease-in-out infinite; }
@keyframes cpu-pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .cpu-dot { animation: none; } }
.cpu-title { margin: 1.75rem 0 0; font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; }
.cpu-body { margin: 0.75rem 0 0; font-size: 0.9rem; line-height: 1.65; color: var(--cpu-muted); }
.cpu-foot { margin-top: 2rem; font-size: 0.75rem; color: var(--cpu-muted); }
.cpu-detail { margin-top: 1.5rem; text-align: left; }
.cpu-step { margin: 0; padding: 0.7rem 0.9rem; border: 1px solid var(--cpu-line); border-radius: 0.5rem;
background: var(--cpu-card); font-size: 0.85rem; font-weight: 500; }
.cpu-since { margin: 0.5rem 0 0; font-size: 0.75rem; color: var(--cpu-muted); }
.cpu-log { margin: 0.5rem 0 0; max-height: 10rem; overflow: auto; padding: 0.7rem 0.9rem;
border: 1px solid var(--cpu-line); border-radius: 0.5rem; background: var(--cpu-card);
font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 0.72rem; line-height: 1.6; }
</style>
<div class="cpu-wrap">
<main class="cpu-main">
<div class="cpu-mark"></div>
<div class="cpu-badge"><span class="cpu-dot"></span>{{ __('updating.badge') }}</div>
<h1 class="cpu-title">{{ __('updating.title') }}</h1>
<p class="cpu-body">{{ $body ?? __('updating.body') }}</p>
{{-- Only in the console. A customer looking at the 503 has no use for a
deployment step, and an operator watching a run that is stuck has
no use for anything else: "läuft gerade" alone is what sends
somebody to the shell. The markup is Alpine-driven and inert
wherever Alpine is not which is why it is behind a flag rather
than always present. --}}
@if ($detail ?? false)
<div class="cpu-detail">
<p class="cpu-step" x-show="!step">{{ $starting ?? '' }}</p>
<p class="cpu-step" x-show="step" x-text="step" x-cloak></p>
<p class="cpu-since" x-show="runningSince" x-text="runningSince" x-cloak></p>
<pre class="cpu-log" x-show="log" x-text="log" x-cloak></pre>
</div>
@endif
<p class="cpu-foot">{{ __('updating.auto') }}</p>
</main>
</div>

View File

@ -761,3 +761,32 @@ it('does not answer the watcher for somebody who is not an operator', function (
->get(route('admin.update.state'))
->assertForbidden();
});
it('shows one design while updating, not two swapping mid-run', function () {
// Reported as "the first screen must not be there". It was the console's
// own overlay in the app's light tokens; then the containers went down and
// the 503 page took over with a completely different face. Two designs for
// one event, replacing each other mid-update.
//
// Both render the same partial now, so there is nothing left that can
// disagree.
$console = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
$maintenance = Illuminate\Support\Facades\File::get(resource_path('views/errors/503.blade.php'));
expect($console)->toContain("partials.updating-panel")
->and($maintenance)->toContain("partials.updating-panel")
// And nothing left behind that would render a second look.
->and($console)->not->toContain('update_overlay_title')
->and($maintenance)->not->toContain('<style>');
});
it('keeps the deployment step in the overlay, where an operator needs it', function () {
// The panel is shared with a page customers see, so the operator detail is
// behind a flag rather than always present — but it has to still be there,
// because "läuft gerade" on its own is what sends somebody to the shell.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('x-text="step"')
->toContain('x-text="runningSince"')
->toContain('x-text="log"');
});