diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 454bf88..dbc7436 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -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(); diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 5f07807..3a3395f 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -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; } diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index c286e57..1fdd712 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -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)." diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index c3d3e18..dec1617 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -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. diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index ffd62c4..c024590 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -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.', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 6620734..4846f5e 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -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.', diff --git a/resources/js/app.js b/resources/js/app.js index cba47a8..d94387d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); }, })); diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index ba92029..f2b6c4d 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -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
@@ -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" > -
-
+
+

{{ __('admin_settings.update_overlay_title') }}

{{ __('admin_settings.update_offline_hint') }}

- {{-- 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. --}}

+ x-show="!step"> + {{ __('admin_settings.update_starts_in') }} +

+ + {{-- 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. --}} +
+

+

+

+            
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index a6b3299..6c91978 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -22,7 +22,7 @@ own refresh of this card's own details (log tail, elapsed time) for as long as a Livewire connection exists. --}}
+ wire:poll.{{ ($update['running'] || $update['checking']) ? '3s' : '30s' }}>
@@ -31,6 +31,10 @@ {{ __('admin_settings.update_running') }} + @elseif ($update['checking']) + + {{ __('admin_settings.update_checking') }} + @elseif ($update['available']) {{ __('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. --}}
@if ($update['running']) @@ -89,16 +100,16 @@ @elseif (! $update['agent_seen']) {{ __('admin_settings.update_unknown') }} - @elseif ($update['available']) - - {{ __('admin_settings.update_now') }} - @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. --}} - - {{ __('admin_settings.update_recheck') }} + + + {{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }} + + + + {{ __('admin_settings.update_now') }} @endif
@@ -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'])
@if ($update['phase']) @@ -123,10 +137,18 @@

{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}

@endif @elseif ($update['next_check_at']) -

{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->local()->format('H:i')]) }}

+

+ {{ __('admin_settings.update_starts_in') }} +

@endif

{{ __('admin_settings.update_offline_hint') }}

+ @elseif ($update['checking'] && $update['next_check_at']) +
+

+ {{ __('admin_settings.update_starts_in') }} +

+
@endif @if ($update['last_state'] !== null && ! $update['running']) diff --git a/routes/admin.php b/routes/admin.php index 4267231..b0ab9d6 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 40864e4..ddd4ea9 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -1,6 +1,7 @@ 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 operator’s language', function () { @@ -223,9 +221,7 @@ it('does not present the last failure’s step as this update’s 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 agent’s failure in the operator’s 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(); });