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
{{ __('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. --}} +{{ __('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') }}
+ {{ __('admin_settings.update_starts_in') }} +
+