Separate checking for updates from applying them, and show live progress
tests / pest (push) Failing after 7m11s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

feat/granted-plans
nexxo 2026-07-28 20:23:06 +02:00
parent 973fcb3f2d
commit f05547921d
11 changed files with 493 additions and 99 deletions

View File

@ -384,12 +384,17 @@ class Settings extends Component
}
/**
* Ask the host-side agent to update this installation.
* Ask the host-side agent to update this installation a real run.
*
* Deliberately a request rather than an action: see UpdateChannel. The
* button cannot report success, because success means this container is
* restarted out from under the response so it reports that the update was
* asked for, and the page shows the outcome once the agent has written it.
*
* The sibling of requestCheck() below: this one applies whatever it finds,
* downtime and all, regardless of whether the last check found anything
* that reading is a few minutes old, and the agent re-fetches before it
* decides.
*/
public function requestUpdate(): void
{
@ -406,6 +411,27 @@ class Settings extends Component
: 'admin_settings.update_already_requested'));
}
/**
* Ask the host-side agent to look for updates never to apply one.
*
* For the operator who only wants an answer: no maintenance window, no
* restart, just the next tick's reading reported back.
*/
public function requestCheck(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->requestCheck($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_check_requested'
: 'admin_settings.update_already_requested'));
}
public function render()
{
$operator = $this->currentOperator();

View File

@ -4,6 +4,7 @@ namespace App\Services\Deployment;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Lang;
use Throwable;
/**
@ -26,6 +27,13 @@ use Throwable;
* The directory is inside the bind-mounted checkout, so both sides see it
* without anything being exposed over the network.
*
* The request carries a KIND, not a flag: "check" (look only the agent
* already fetches on every tick regardless, this just answers sooner than the
* next one would on its own) or "run" (apply it, downtime and all). Two
* different things an operator can ask for, not one request with a modifier
* an operator who only wants to know whether anything is new must not be
* routed through a real deployment to find out.
*
* Everything here degrades to "unknown" rather than throwing. A broken or
* absent status file must leave the settings page readable it is the page an
* operator opens when something is already wrong.
@ -35,13 +43,26 @@ final class UpdateChannel
/** Written by the panel, consumed by the agent. */
private const REQUEST = 'deploy/update-request.json';
/** A request the agent only checks against, and never acts on. */
private const KIND_CHECK = 'check';
/**
* A request the agent actually applies.
*
* Also the fallback for a request with no `kind` at all every request
* meant this before "check" existed, so an older panel's request (or one
* still in flight across a deploy of this very feature) must keep meaning
* it rather than silently doing nothing.
*/
private const KIND_RUN = 'run';
/** Written by the agent after every check and every run. */
private const STATUS = 'deploy/update-status.json';
/**
* The outcome of the last actual run, kept apart from the periodic check.
*
* In one file the next idle check would overwrite a failure five minutes
* In one file the next idle check would overwrite a failure a minute
* later, and an operator would usually never learn that the update failed.
*/
private const LAST_RUN = 'deploy/update-last-run.json';
@ -74,11 +95,13 @@ final class UpdateChannel
/**
* How stale the agent's last check may be before it counts as gone.
*
* The timer runs every five minutes; four missed ticks is a stopped agent,
* not a slow one. Without this, a single status file written once by an
* agent since disabled, broken or uninstalled leaves the button enabled
* forever, and pressing it writes a request nobody will ever collect while
* the page cheerfully reports an update in progress.
* The timer runs every minute; twenty missed ticks in a row is a stopped
* agent, not a slow one comfortably more than one bad tick (a hung
* fetch, a loaded host) can account for. Without this, a single status
* file written once by an agent since disabled, broken or uninstalled
* leaves the button enabled forever, and pressing it writes a request
* nobody will ever collect while the page cheerfully reports an update in
* progress.
*/
private const AGENT_STALE_AFTER_MINUTES = 20;
@ -96,6 +119,8 @@ final class UpdateChannel
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
$agentAlive = $this->agentIsAlive($status);
// Missing on a request an older panel wrote — see KIND_RUN.
$requestKind = $request['kind'] ?? self::KIND_RUN;
// A figure from an agent that has since stopped is not current, and
// "three updates behind" from last week reads exactly like now.
@ -115,7 +140,15 @@ final class UpdateChannel
'checked_at' => $checkedAt,
'agent_seen' => $agentAlive,
'running' => $agentAlive && ($request !== null || ($status['state'] ?? null) === 'running'),
// A pending CHECK must never read as "running": nothing is being
// applied, the site never enters maintenance mode, and the
// full-screen overlay exists only for the case where it does.
'running' => $agentAlive && (
($request !== null && $requestKind !== self::KIND_CHECK)
|| ($status['state'] ?? null) === 'running'
),
// A pending check, still waiting for the agent to pick it up.
'checking' => $agentAlive && $request !== null && $requestKind === self::KIND_CHECK,
'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null,
'requested_by' => $request['requested_by'] ?? null,
@ -291,18 +324,40 @@ final class UpdateChannel
}
/**
* Ask the agent to update.
* Ask the agent to update an actual run, downtime and all.
*
* Returns false when a request is already outstanding clicking twice must
* not queue two runs, and the second click is much more likely to be
* impatience than intent.
*/
public function request(string $by): bool
{
return $this->submit($by, self::KIND_RUN);
}
/**
* Ask the agent only to look never to apply anything.
*
* The agent already fetches on every tick regardless of this; the request
* exists so an operator who only wants an answer gets one on the next
* tick rather than waiting on whichever tick happens to come next on its
* own, and so the answer never runs through the "run" branch no
* maintenance mode, no restart, for a question that was only "is there
* something new".
*/
public function requestCheck(string $by): bool
{
return $this->submit($by, self::KIND_CHECK);
}
private function submit(string $by, string $kind): bool
{
// Both conditions, not just the request file. The agent DELETES the
// request before it starts — so between that moment and the end of the
// run there is nothing pending, and a second click from a stale tab
// would queue a whole redundant deployment for the next tick.
// request before it starts a run — so between that moment and the end
// of the run there is nothing pending, and a second click from a
// stale tab would queue a whole redundant deployment for the next
// tick. One request slot for both kinds: the agent handles one tick
// at a time regardless of what it is asked for.
if ($this->pendingRequest() !== null || $this->isRunning()) {
return false;
}
@ -310,6 +365,7 @@ final class UpdateChannel
$this->write(self::REQUEST, json_encode([
'requested_at' => Carbon::now()->toIso8601String(),
'requested_by' => $by,
'kind' => $kind,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return true;
@ -333,7 +389,7 @@ final class UpdateChannel
$key = 'admin_settings.update_error.'.$code;
if (! \Illuminate\Support\Facades\Lang::has($key)) {
if (! Lang::has($key)) {
return $code;
}

View File

@ -39,11 +39,16 @@ cat > /etc/systemd/system/clupilot-update-agent.timer <<'EOF'
Description=Check for CluPilot updates and run requested ones
[Timer]
# Every five minutes: fast enough that the button feels like it did something,
# slow enough that fetching from the repository is not a nuisance.
# Every minute: a `git fetch` against one remote is not a nuisance at this
# rate, and the panel now shows a live countdown to the next tick — a wait
# that reads as "obviously nothing is happening" at five minutes reads as
# "obviously working" at one.
OnBootSec=3min
OnUnitActiveSec=5min
AccuracySec=30s
OnUnitActiveSec=1min
# Tightened along with the interval above: at five minutes a 30s fuzz was a
# rounding error; at one minute it is half the interval, and the panel's
# countdown would sit at 0:00 for as long as systemd felt like waiting.
AccuracySec=5s
[Install]
WantedBy=timers.target
@ -114,7 +119,7 @@ if command -v caddy >/dev/null 2>&1 && [[ -f "$CADDYFILE" ]]; then
# old hard-coded list indefinitely.
: > "$ROOT/storage/app/deploy/.caddy-reload-pending"
chown "$APP_USER":"$APP_USER" "$ROOT/storage/app/deploy/.caddy-reload-pending"
echo " ! Caddy did not reload — the agent retries within a few minutes."
echo " ! Caddy did not reload — the agent retries within a minute."
fi
else
echo " ! Caddy rejected the change — restoring the previous config."
@ -272,7 +277,7 @@ systemctl daemon-reload
systemctl enable --now clupilot-update-agent.timer >/dev/null
# Run it once now, so the panel has a status to show instead of "never reported
# in" for the first five minutes.
# in" for the first minute.
systemctl start clupilot-update-agent.service || true
echo "Update agent installed and running (clupilot-update-agent.timer)."

View File

@ -31,7 +31,7 @@ RUNLOG="$STATE_DIR/update-last-run.log"
# that failed reports WHERE it failed instead of only that it did.
PHASE_FILE="$STATE_DIR/update-phase"
# The outcome of the last RUN, kept apart from the periodic check. Written into
# one file, the next idle tick five minutes later would overwrite a failure with
# one file, the next idle tick a minute later would overwrite a failure with
# "idle" — and an operator would usually never see that the update failed.
LASTRUN="$STATE_DIR/update-last-run.json"
LOCK="$STATE_DIR/.agent.lock"
@ -51,7 +51,7 @@ REQUEST_EXPIRES_MINUTES=30
# Reported so the console can say WHEN a queued update will start rather than
# "in a few minutes", which is the whole reason an operator sits there wondering
# whether anything is happening at all.
CHECK_INTERVAL_MINUTES=5
CHECK_INTERVAL_MINUTES=1
mkdir -p "$STATE_DIR"
@ -241,11 +241,28 @@ if (( REQUEST_AGE_MINUTES > REQUEST_EXPIRES_MINUTES )); then
exit 0
fi
# Consumed BEFORE the run, not after: update.sh restarts the container stack and
# may well kill this shell with it. A request left in place would be picked up
# again on the next tick and update in a loop.
# What was actually asked for: a real run, or only the check the block above
# already performed. A distinct field, not a flag folded into the existing
# request — "check" and "run" are two different things the panel can ask
# for, not one request with a modifier. Read before the file is consumed;
# a request written by an older panel has no such field and is treated as a
# run, which is everything a request has ever meant until now.
REQUEST_KIND="$(sed -n 's/.*"kind"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" 2>/dev/null | head -1)"
# Consumed BEFORE anything below runs, not after: update.sh restarts the
# container stack and may well kill this shell with it. A request left in
# place would be picked up again on the next tick and update in a loop.
rm -f "$REQUEST"
if [[ "$REQUEST_KIND" == "check" ]]; then
# Nothing further to do: the fetch and the BEHIND calculation above
# already answered it. The operator asked to look, not to touch
# anything, so this tick ends exactly like one that found no request —
# no maintenance mode, no restart, no phase file.
write_status idle "$FETCH_ERROR"
exit 0
fi
# The previous run's last step is not this run's first one. Left in place, the
# console would show a stale phase for the seconds before update.sh writes its
# own — and on a run that dies before writing any, for the whole run.

View File

@ -92,14 +92,20 @@ return [
'update_source' => 'Quelle',
'update_deployed' => 'Ausgerollt',
'update_checked' => 'Zuletzt geprüft',
'update_recheck' => 'Jetzt prüfen und aktualisieren',
// Zwei eigenständige Aktionen statt einer, die beides tat: schauen soll
// nicht erst durch eine echte Aktualisierung samt Wartungsfenster müssen.
'update_check' => 'Nach Aktualisierungen suchen',
'update_checking' => 'Prüfung läuft',
'update_now' => 'Jetzt aktualisieren',
'update_running' => 'Läuft gerade',
'update_available' => ':n Aktualisierung(en) verfügbar',
'update_current' => 'Aktuell',
'update_unknown' => 'Unbekannt',
'update_requested' => 'Aktualisierung angefordert — sie startet innerhalb weniger Minuten.',
'update_already_requested' => 'Eine Aktualisierung ist bereits angefordert.',
// Kurz gehalten, ohne eigene Zeitangabe: der Live-Countdown, der direkt im
// Anschluss erscheint (update_starts_in), trägt die Genauigkeit bereits.
'update_requested' => 'Aktualisierung angefordert.',
'update_check_requested' => 'Prüfung angefordert.',
'update_already_requested' => 'Es ist schon etwas angefordert — bitte kurz warten.',
'update_no_agent' => 'Der Update-Dienst auf dem Server läuft nicht. Einmalig auf dem Server einrichten: sudo bash /opt/clupilot/deploy/install-agent.sh',
'update_log' => 'Protokoll des letzten Laufs',
@ -121,9 +127,10 @@ return [
// 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.',
// Nur das Label — die Ziffern daneben zählen im Browser live herunter
// (resources/js/app.js), damit "Startet in 0:47" auch wirklich 0:47 sagt
// und nicht eine irgendwann errechnete Uhrzeit.
'update_starts_in' => 'Startet in',
'update_step' => 'Schritt: :step',
'update_since' => 'Läuft seit :when',
'update_offline_hint' => 'Während der Aktualisierung ist die Seite kurz im Wartungsmodus — diese Anzeige bleibt so lange stehen und meldet sich danach von selbst zurück.',

View File

@ -92,14 +92,20 @@ return [
'update_source' => 'Source',
'update_deployed' => 'Deployed',
'update_checked' => 'Last checked',
'update_recheck' => 'Check now and update',
// Two separate actions instead of one that did both: looking must not
// have to go through a real update and its maintenance window first.
'update_check' => 'Check for updates',
'update_checking' => 'Checking',
'update_now' => 'Update now',
'update_running' => 'Running',
'update_available' => ':n update(s) available',
'update_current' => 'Up to date',
'update_unknown' => 'Unknown',
'update_requested' => 'Update requested — it starts within a few minutes.',
'update_already_requested' => 'An update has already been requested.',
// Kept short, with no time of its own: the live countdown shown right
// after it (update_starts_in) already carries the precision.
'update_requested' => 'Update requested.',
'update_check_requested' => 'Check requested.',
'update_already_requested' => 'Something has already been requested — please wait a moment.',
'update_no_agent' => 'The server-side update service is not running. Set it up once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
'update_log' => 'Log of the last run',
@ -120,9 +126,10 @@ return [
// 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.',
// Label only — the digits beside it count down live in the browser
// (resources/js/app.js), so "Starts in 0:47" actually says 0:47 rather
// than a clock time computed once and left to go stale.
'update_starts_in' => 'Starts in',
'update_step' => 'Step: :step',
'update_since' => 'Running since :when',
'update_offline_hint' => 'During the update the site is briefly in maintenance mode — this view stays put and comes back on its own afterwards.',

View File

@ -93,6 +93,21 @@ window.fetch = async (...args) => {
return response;
};
// Minutes:seconds until `targetIso`, floored at zero. Shared by the
// maintenance overlay and the settings card's own countdowns — the one bit of
// arithmetic behind both, kept in one place so a rounding fix does not need
// to be made twice. Never assembles a sentence, only the digits: the words
// around it are always server-translated.
function minutesSeconds(targetIso, nowMs) {
if (!targetIso) return '0:00';
const remaining = Math.max(0, Math.round((new Date(targetIso).getTime() - nowMs) / 1000));
const m = Math.floor(remaining / 60);
const s = remaining % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
document.addEventListener('alpine:init', () => {
// ── Watching a deployment that restarts the watcher ──────────────────
//
@ -105,21 +120,34 @@ 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, step = null }) => ({
window.Alpine.data('updateWatcher', ({
url, commit, running, step = null, nextCheckAt = null, runningSince = null, log = 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.
// Already translated server-side ("Schritt: …", "Läuft seit …") — the
// client only ever displays these, never assembles them, so German
// text stays out of JavaScript entirely.
step,
runningSince,
log,
// Not text: the instant the agent will next look, for the live
// countdown below to count down to.
nextCheckAt,
now: Date.now(),
timer: null,
clock: null,
init() {
this.schedule();
// A second hand for the countdown, independent of how often
// `check()` actually polls — otherwise "Startet in" would jump in
// three-second steps instead of reading as live.
this.clock = setInterval(() => { this.now = Date.now(); }, 1000);
// Nothing to watch while the tab is hidden; pick straight back up
// rather than waiting out a whole interval on return.
document.addEventListener('visibilitychange', () => {
@ -127,6 +155,12 @@ document.addEventListener('alpine:init', () => {
});
},
// Queued: the agent has not picked the request up yet, so there is no
// step to show. Once it has, `step` arrives and this stops mattering.
get countdownLabel() {
return minutesSeconds(this.nextCheckAt, this.now);
},
schedule() {
clearTimeout(this.timer);
this.timer = setTimeout(() => this.check(), this.wasRunning ? 3000 : 20000);
@ -161,6 +195,9 @@ document.addEventListener('alpine:init', () => {
this.wasRunning = state.running;
this.step = state.step ?? null;
this.runningSince = state.running_since ?? null;
this.log = state.log ?? null;
this.nextCheckAt = state.next_check_at ?? null;
} catch {
// Expected while the containers are down. Keep asking: this is
// the middle of the very event being watched, not a fault.
@ -172,6 +209,31 @@ document.addEventListener('alpine:init', () => {
destroy() {
clearTimeout(this.timer);
clearInterval(this.clock);
},
}));
// ── A live "starts in" countdown ──────────────────────────────────────
// Used on the settings card wherever it is waiting on the agent's next
// tick — a queued check or a queued run. Self-contained rather than
// reading off updateWatcher above: this lives inside a Livewire-rendered
// card, and a poll-driven re-render is not guaranteed to preserve a
// nested component's state the way a plain re-render of this one is.
window.Alpine.data('countdown', (target) => ({
target,
now: Date.now(),
timer: null,
init() {
this.timer = setInterval(() => { this.now = Date.now(); }, 1000);
},
get label() {
return minutesSeconds(this.target, this.now);
},
destroy() {
clearInterval(this.timer);
},
}));

View File

@ -19,10 +19,18 @@
// 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();
$updateChannel = app(App\Services\Deployment\UpdateChannel::class);
$updateState = $updateChannel->state();
$updateStep = $updateState['phase'] !== null
? __('admin_settings.update_step', ['step' => $updateState['phase']])
: null;
// Only once a run has actually started, not while merely queued —
// otherwise this is the PREVIOUS run's leftovers, read on every page
// load whether or not anything is running at all.
$updateRunningSince = $updateState['started_at'] !== null
? __('admin_settings.update_since', ['when' => $updateState['started_at']->diffForHumans()])
: null;
$updateLog = $updateState['started_at'] !== null ? $updateChannel->lastLog() : null;
@endphp
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
@ -101,6 +109,9 @@
commit: @js($updateState['commit']),
running: @js($updateState['running']),
step: @js($updateStep),
nextCheckAt: @js($updateState['next_check_at']?->toIso8601String()),
runningSince: @js($updateRunningSince),
log: @js($updateLog),
})"
x-show="wasRunning"
x-cloak
@ -111,19 +122,35 @@
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">
<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>
{{-- 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. --}}
{{-- Queued: the agent has not picked the request up yet, so
there is no step. A live countdown instead of silence
"Startet in 0:47" reads as something happening; several
minutes of nothing reads as broken. --}}
<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>
x-show="!step">
{{ __('admin_settings.update_starts_in') }} <span x-text="countdownLabel" class="font-mono"></span>
</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>
</div>

View File

@ -22,7 +22,7 @@
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' }}>
wire:poll.{{ ($update['running'] || $update['checking']) ? '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">
@ -31,6 +31,10 @@
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
</span>
@elseif ($update['checking'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_checking') }}
</span>
@elseif ($update['available'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
@ -70,17 +74,24 @@
inside the component tag, and Blade then stops seeing a
component.
The button used to be offered ONLY when the last check had
found something. But "behind" is a READING, taken every few
minutes it is not the state of the world. Push a commit
and for the next few minutes the console insists everything
is current and refuses to do anything about it, which is
Two separate actions, always offered together: looking and
applying are different intentions, and an operator who only
wants to know whether anything is new must not be routed
through a real deployment to find out.
"Jetzt aktualisieren" is offered regardless of $available.
"behind" is a READING, taken up to a minute ago it is not
the state of the world. Push a commit and for that one
minute the console would otherwise insist everything is
current and refuse to do anything about it, which is
precisely how it was reported. The agent fetches before it
decides, so asking against a stale reading costs one fetch
and finds nothing; being locked out costs the deployment.
Still disabled for the two cases where the request really
would go nowhere: a run already in flight, and no agent. --}}
Both disabled for the two cases where neither would go
anywhere: a run already in flight, and no agent. Also
disabled while a check is already pending, so a second
click cannot collide with the first and be refused. --}}
<div class="flex shrink-0 items-center gap-2">
@if ($update['running'])
<x-ui.button variant="secondary" :disabled="true">
@ -89,16 +100,16 @@
</x-ui.button>
@elseif (! $update['agent_seen'])
<x-ui.button variant="secondary" :disabled="true">{{ __('admin_settings.update_unknown') }}</x-ui.button>
@elseif ($update['available'])
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
{{ __('admin_settings.update_now') }}
</x-ui.button>
@else
{{-- Named for what it does rather than for what the last
reading said: it re-checks, and updates if there is
anything to update. --}}
<x-ui.button variant="secondary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
{{ __('admin_settings.update_recheck') }}
<x-ui.button variant="secondary" :disabled="$update['checking']"
wire:click="requestCheck" wire:loading.attr="disabled" wire:target="requestCheck">
<x-ui.icon name="refresh" class="size-4 {{ $update['checking'] ? 'animate-spin' : '' }}" />
{{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }}
</x-ui.button>
<x-ui.button variant="primary" :disabled="$update['checking']"
wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
<x-ui.icon name="download" class="size-4" />
{{ __('admin_settings.update_now') }}
</x-ui.button>
@endif
</div>
@ -112,9 +123,12 @@
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
the shell: the service checks on a timer, so a queued update
does nothing at all for up to five minutes, and while it runs
the site is in maintenance mode and this page is unreachable.
Both of those look identical to "wedged" without a time. --}}
does nothing at all until the agent's next tick, and while it
runs the site is in maintenance mode and this page is
unreachable. Both of those look identical to "wedged" without
a time so a live countdown while queued, replacing what used
to be a static "spätestens um 17:19" that read as a promise
nothing then visibly kept. --}}
@if ($update['running'])
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
@if ($update['phase'])
@ -123,10 +137,18 @@
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
@endif
@elseif ($update['next_check_at'])
<p class="font-medium text-body">{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->local()->format('H:i')]) }}</p>
<p class="font-medium text-body" x-data="countdown('{{ $update['next_check_at']->toIso8601String() }}')">
{{ __('admin_settings.update_starts_in') }} <span x-text="label" class="font-mono"></span>
</p>
@endif
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
</div>
@elseif ($update['checking'] && $update['next_check_at'])
<div class="mt-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
<p class="font-medium text-body" x-data="countdown('{{ $update['next_check_at']->toIso8601String() }}')">
{{ __('admin_settings.update_starts_in') }} <span x-text="label" class="font-mono"></span>
</p>
</div>
@endif
@if ($update['last_state'] !== null && ! $update['running'])

View File

@ -80,6 +80,11 @@ Route::post('/logout', function () {
*/
Route::get('/update/state', function (UpdateChannel $channel) {
$state = $channel->state();
// Only once a run has actually started, not while merely queued: before
// that, lastLog() would return the PREVIOUS run's leftovers, which would
// sit under a "starts in 0:47" countdown looking like the current run
// already has a history.
$started = $state['started_at'] !== null;
return response()->json([
'running' => $state['running'],
@ -93,5 +98,18 @@ Route::get('/update/state', function (UpdateChannel $channel) {
'step' => $state['phase'] !== null
? __('admin_settings.update_step', ['step' => $state['phase']])
: null,
// The raw instant, not a sentence: the overlay ticks a live "Startet
// in 0:47" from this on the client, which needs an unambiguous point
// in time to count down to, not translated text.
'next_check_at' => $state['next_check_at']?->toIso8601String(),
// Ready to print — the same sentence update_since renders on the
// settings card — so the overlay never assembles it either.
'running_since' => $started
? __('admin_settings.update_since', ['when' => $state['started_at']->diffForHumans()])
: null,
// The tail of the run log: a run that is genuinely stuck is then
// visibly stuck AT a step, instead of a spinner nobody can read
// anything from.
'log' => $started ? $channel->lastLog() : null,
])->header('Cache-Control', 'no-store');
})->name('update.state');

View File

@ -1,6 +1,7 @@
<?php
use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\User;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
@ -14,7 +15,6 @@ use Livewire\Livewire;
* tests cover the panel's half: what it reports, what it refuses, and what it
* must never claim.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
@ -120,18 +120,20 @@ it('survives a status file that is corrupt', function () {
->assertOk();
});
it('says when a queued update will actually start', function () {
// The agent checks on a timer, so pressing the button visibly does nothing
// for minutes. Without a time that is indistinguishable from a broken
// button, which is precisely how it was read.
it('carries a live countdown target for a queued update, not a server-formatted clock time', function () {
// The agent checks on a timer, so pressing the button visibly does
// nothing until the next tick. Silence there reads as a broken button —
// reported exactly that way — so the card now counts down live in the
// browser (resources/js/app.js) instead of naming a clock time.
//
// Frozen: the assertion is about the arithmetic, and a clock that moves
// between writing the file and reading it turns 5 into 4.998.
// between writing the file and reading it turns 1 into 0.998.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'check_interval_minutes' => 1,
'behind' => 1,
]);
@ -140,21 +142,17 @@ it('says when a queued update will actually start', function () {
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String());
->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(1)->toIso8601String());
// The raw instant, not a clock time formatted server-side: there is no
// local()-vs-utc conversion left here for R19 to catch a regression of —
// the browser owns the countdown and localises it itself by construction
// (new Date() on an ISO string with an explicit offset). What matters is
// that the RIGHT instant reaches the page.
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
// Asserted against the operator's wall clock, not against whatever the
// view happens to do. The old version of this test built its expected
// value with the same call the view used, so it agreed with the bug:
// the console announced 15:21 for an update that ran at 17:21 and every
// test passed.
->assertSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->local()->format('H:i'),
]))
->assertDontSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->utc()->format('H:i'),
]));
->assertSee(__('admin_settings.update_starts_in'))
->assertSee($state['next_check_at']->toIso8601String(), escape: false);
});
it('names the step the deployment is on, in the operators language', function () {
@ -223,9 +221,7 @@ it('does not present the last failures step as this updates progress', fun
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->local()->format('H:i'),
]))
->assertSee(__('admin_settings.update_starts_in'))
->assertDontSee(__('admin_settings.update_phase.migrate'));
});
@ -343,8 +339,8 @@ it('shows the agents failure in the operators language', function () {
});
it('still shows a failed update after the next routine check', function () {
// The check runs every five minutes. Writing both into one file meant a
// failure was usually gone before anyone looked at it.
// The check runs on a timer. Writing both into one file meant a failure
// was usually gone before anyone looked at it.
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
@ -367,9 +363,92 @@ it('refuses a second request while the agent is mid-run', function () {
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
});
it('offers the update button even when the last check found nothing', function () {
/**
* "Nach Aktualisierungen suchen" check only, never apply.
*
* The request carries a `kind` so the agent can tell the two apart. These
* cover the panel's half: a check must never read as "running" (no
* maintenance mode, no full-screen overlay for a question that was only "is
* there something new"), and the one request slot is still shared, so
* pressing either button while the other is outstanding is refused the same
* way pressing the same one twice already was.
*/
it('writes a distinct request kind for a check, not a flag on the existing one', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertHasNoErrors();
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
expect($request['kind'])->toBe('check')
->and($request['requested_by'])->toBeString()->not->toBeEmpty();
});
it('does not treat a pending check as "running" — no maintenance overlay for a question', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$channel = app(UpdateChannel::class);
expect($channel->requestCheck('owner@example.com'))->toBeTrue();
$state = $channel->state();
expect($state['checking'])->toBeTrue()
->and($state['running'])->toBeFalse();
});
it('does treat a pending run as "running", not "checking"', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$channel = app(UpdateChannel::class);
expect($channel->request('owner@example.com'))->toBeTrue();
$state = $channel->state();
expect($state['running'])->toBeTrue()
->and($state['checking'])->toBeFalse();
});
it('does not queue a second check when the button is pressed twice', function () {
$channel = app(UpdateChannel::class);
expect($channel->requestCheck('someone@example.com'))->toBeTrue()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
});
it('blocks a check while a run is pending, and a run while a check is pending', function () {
$channel = app(UpdateChannel::class);
expect($channel->request('someone@example.com'))->toBeTrue()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
File::delete(storage_path('app/deploy/update-request.json'));
expect($channel->requestCheck('someone@example.com'))->toBeTrue()
->and($channel->request('someone@example.com'))->toBeFalse();
});
it('needs the capability to check, not merely an operator account', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertForbidden();
});
it('confirms a check was requested, worded for a check rather than an update', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertDispatched('notify', message: __('admin_settings.update_check_requested'));
});
it('offers "Jetzt aktualisieren" even when the last check found nothing', function () {
// Reported as "I cannot run an update, it says everything is current".
// `behind` is a READING taken every few minutes, not the state of the
// `behind` is a READING taken up to a minute ago, not the state of the
// world: push a commit and the console insisted it was up to date and
// refused to act on it. The agent fetches before deciding, so asking
// against a stale reading costs one fetch — being locked out costs the
@ -382,14 +461,29 @@ it('offers the update button even when the last check found nothing', function (
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_recheck'))
->assertSee(__('admin_settings.update_now'))
->call('requestUpdate');
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});
it('offers both actions together whenever the agent is reachable and idle', function () {
// Two separate buttons now, not one whose label changed with the
// reading: looking and applying are different intentions.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_check'))
->assertSee(__('admin_settings.update_now'));
});
it('still refuses when there is no agent to pick the request up', function () {
// The one case where the button really would go nowhere: nothing is
// The one case where either button really would go nowhere: nothing is
// running that would ever read the request file.
writeStatus([
'checked_at' => now()->subDay()->toIso8601String(),
@ -400,7 +494,8 @@ it('still refuses when there is no agent to pick the request up', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertDontSee(__('admin_settings.update_recheck'));
->assertDontSee(__('admin_settings.update_check'))
->assertDontSee(__('admin_settings.update_now'));
});
it('carries the watcher onto every console page, not only settings', function () {
@ -438,7 +533,7 @@ 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', 'step'])
->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step', 'next_check_at', 'running_since', 'log'])
->assertHeader('Cache-Control', 'no-store, private');
});
@ -470,8 +565,60 @@ it('carries no step in the watcher JSON before the deployment has announced one'
->assertJson(['step' => null]);
});
it('carries how long a run has been going in the watcher JSON, already worded', function () {
// Ready to print, the same sentence update_since renders on the settings
// card — the overlay never assembles this from a raw timestamp either.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinutes(2)->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['running_since' => __('admin_settings.update_since', [
'when' => now()->subMinutes(2)->diffForHumans(),
])]);
});
it('carries the tail of the run log in the watcher JSON once a run has actually started', function () {
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.log'), "line one\nline two\n");
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['log' => "line one\nline two"]);
});
it('carries no running-since or log while only queued, not yet actually running', function () {
// Otherwise this would be the PREVIOUS run's leftovers, arriving under a
// countdown that says the run has not started yet.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.log'), "an earlier run\n");
app(UpdateChannel::class)->request('owner@example.com');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['running_since' => null, 'log' => null])
->assertJsonStructure(['next_check_at']);
});
it('does not answer the watcher for somebody who is not an operator', function () {
$this->actingAs(\App\Models\User::factory()->create())
$this->actingAs(User::factory()->create())
->get(route('admin.update.state'))
->assertForbidden();
});