Separate checking for updates from applying them, and show live progress
parent
973fcb3f2d
commit
f05547921d
|
|
@ -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
|
* Deliberately a request rather than an action: see UpdateChannel. The
|
||||||
* button cannot report success, because success means this container is
|
* button cannot report success, because success means this container is
|
||||||
* restarted out from under the response — so it reports that the update was
|
* 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.
|
* 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
|
public function requestUpdate(): void
|
||||||
{
|
{
|
||||||
|
|
@ -406,6 +411,27 @@ class Settings extends Component
|
||||||
: 'admin_settings.update_already_requested'));
|
: '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()
|
public function render()
|
||||||
{
|
{
|
||||||
$operator = $this->currentOperator();
|
$operator = $this->currentOperator();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Services\Deployment;
|
||||||
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Lang;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,6 +27,13 @@ use Throwable;
|
||||||
* The directory is inside the bind-mounted checkout, so both sides see it
|
* The directory is inside the bind-mounted checkout, so both sides see it
|
||||||
* without anything being exposed over the network.
|
* 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
|
* 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
|
* absent status file must leave the settings page readable — it is the page an
|
||||||
* operator opens when something is already wrong.
|
* operator opens when something is already wrong.
|
||||||
|
|
@ -35,13 +43,26 @@ final class UpdateChannel
|
||||||
/** Written by the panel, consumed by the agent. */
|
/** Written by the panel, consumed by the agent. */
|
||||||
private const REQUEST = 'deploy/update-request.json';
|
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. */
|
/** Written by the agent after every check and every run. */
|
||||||
private const STATUS = 'deploy/update-status.json';
|
private const STATUS = 'deploy/update-status.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The outcome of the last actual run, kept apart from the periodic check.
|
* 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.
|
* later, and an operator would usually never learn that the update failed.
|
||||||
*/
|
*/
|
||||||
private const LAST_RUN = 'deploy/update-last-run.json';
|
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.
|
* 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,
|
* The timer runs every minute; twenty missed ticks in a row is a stopped
|
||||||
* not a slow one. Without this, a single status file written once — by an
|
* agent, not a slow one — comfortably more than one bad tick (a hung
|
||||||
* agent since disabled, broken or uninstalled — leaves the button enabled
|
* fetch, a loaded host) can account for. Without this, a single status
|
||||||
* forever, and pressing it writes a request nobody will ever collect while
|
* file written once — by an agent since disabled, broken or uninstalled —
|
||||||
* the page cheerfully reports an update in progress.
|
* 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;
|
private const AGENT_STALE_AFTER_MINUTES = 20;
|
||||||
|
|
||||||
|
|
@ -96,6 +119,8 @@ final class UpdateChannel
|
||||||
|
|
||||||
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
|
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
|
||||||
$agentAlive = $this->agentIsAlive($status);
|
$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
|
// A figure from an agent that has since stopped is not current, and
|
||||||
// "three updates behind" from last week reads exactly like now.
|
// "three updates behind" from last week reads exactly like now.
|
||||||
|
|
@ -115,7 +140,15 @@ final class UpdateChannel
|
||||||
'checked_at' => $checkedAt,
|
'checked_at' => $checkedAt,
|
||||||
|
|
||||||
'agent_seen' => $agentAlive,
|
'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_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null,
|
||||||
'requested_by' => $request['requested_by'] ?? 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
|
* 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
|
* not queue two runs, and the second click is much more likely to be
|
||||||
* impatience than intent.
|
* impatience than intent.
|
||||||
*/
|
*/
|
||||||
public function request(string $by): bool
|
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
|
// Both conditions, not just the request file. The agent DELETES the
|
||||||
// request before it starts — so between that moment and the end of the
|
// request before it starts a run — so between that moment and the end
|
||||||
// run there is nothing pending, and a second click from a stale tab
|
// of the run there is nothing pending, and a second click from a
|
||||||
// would queue a whole redundant deployment for the next tick.
|
// 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()) {
|
if ($this->pendingRequest() !== null || $this->isRunning()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +365,7 @@ final class UpdateChannel
|
||||||
$this->write(self::REQUEST, json_encode([
|
$this->write(self::REQUEST, json_encode([
|
||||||
'requested_at' => Carbon::now()->toIso8601String(),
|
'requested_at' => Carbon::now()->toIso8601String(),
|
||||||
'requested_by' => $by,
|
'requested_by' => $by,
|
||||||
|
'kind' => $kind,
|
||||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -333,7 +389,7 @@ final class UpdateChannel
|
||||||
|
|
||||||
$key = 'admin_settings.update_error.'.$code;
|
$key = 'admin_settings.update_error.'.$code;
|
||||||
|
|
||||||
if (! \Illuminate\Support\Facades\Lang::has($key)) {
|
if (! Lang::has($key)) {
|
||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,16 @@ cat > /etc/systemd/system/clupilot-update-agent.timer <<'EOF'
|
||||||
Description=Check for CluPilot updates and run requested ones
|
Description=Check for CluPilot updates and run requested ones
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
# Every five minutes: fast enough that the button feels like it did something,
|
# Every minute: a `git fetch` against one remote is not a nuisance at this
|
||||||
# slow enough that fetching from the repository is not a nuisance.
|
# 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
|
OnBootSec=3min
|
||||||
OnUnitActiveSec=5min
|
OnUnitActiveSec=1min
|
||||||
AccuracySec=30s
|
# 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]
|
[Install]
|
||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
|
|
@ -114,7 +119,7 @@ if command -v caddy >/dev/null 2>&1 && [[ -f "$CADDYFILE" ]]; then
|
||||||
# old hard-coded list indefinitely.
|
# old hard-coded list indefinitely.
|
||||||
: > "$ROOT/storage/app/deploy/.caddy-reload-pending"
|
: > "$ROOT/storage/app/deploy/.caddy-reload-pending"
|
||||||
chown "$APP_USER":"$APP_USER" "$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
|
fi
|
||||||
else
|
else
|
||||||
echo " ! Caddy rejected the change — restoring the previous config."
|
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
|
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
|
# 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
|
systemctl start clupilot-update-agent.service || true
|
||||||
|
|
||||||
echo "Update agent installed and running (clupilot-update-agent.timer)."
|
echo "Update agent installed and running (clupilot-update-agent.timer)."
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ RUNLOG="$STATE_DIR/update-last-run.log"
|
||||||
# that failed reports WHERE it failed instead of only that it did.
|
# that failed reports WHERE it failed instead of only that it did.
|
||||||
PHASE_FILE="$STATE_DIR/update-phase"
|
PHASE_FILE="$STATE_DIR/update-phase"
|
||||||
# The outcome of the last RUN, kept apart from the periodic check. Written into
|
# 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.
|
# "idle" — and an operator would usually never see that the update failed.
|
||||||
LASTRUN="$STATE_DIR/update-last-run.json"
|
LASTRUN="$STATE_DIR/update-last-run.json"
|
||||||
LOCK="$STATE_DIR/.agent.lock"
|
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
|
# 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
|
# "in a few minutes", which is the whole reason an operator sits there wondering
|
||||||
# whether anything is happening at all.
|
# whether anything is happening at all.
|
||||||
CHECK_INTERVAL_MINUTES=5
|
CHECK_INTERVAL_MINUTES=1
|
||||||
|
|
||||||
mkdir -p "$STATE_DIR"
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
|
|
@ -241,11 +241,28 @@ if (( REQUEST_AGE_MINUTES > REQUEST_EXPIRES_MINUTES )); then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Consumed BEFORE the run, not after: update.sh restarts the container stack and
|
# What was actually asked for: a real run, or only the check the block above
|
||||||
# may well kill this shell with it. A request left in place would be picked up
|
# already performed. A distinct field, not a flag folded into the existing
|
||||||
# again on the next tick and update in a loop.
|
# 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"
|
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
|
# 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
|
# 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.
|
# own — and on a run that dies before writing any, for the whole run.
|
||||||
|
|
|
||||||
|
|
@ -92,14 +92,20 @@ return [
|
||||||
'update_source' => 'Quelle',
|
'update_source' => 'Quelle',
|
||||||
'update_deployed' => 'Ausgerollt',
|
'update_deployed' => 'Ausgerollt',
|
||||||
'update_checked' => 'Zuletzt geprüft',
|
'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_now' => 'Jetzt aktualisieren',
|
||||||
'update_running' => 'Läuft gerade',
|
'update_running' => 'Läuft gerade',
|
||||||
'update_available' => ':n Aktualisierung(en) verfügbar',
|
'update_available' => ':n Aktualisierung(en) verfügbar',
|
||||||
'update_current' => 'Aktuell',
|
'update_current' => 'Aktuell',
|
||||||
'update_unknown' => 'Unbekannt',
|
'update_unknown' => 'Unbekannt',
|
||||||
'update_requested' => 'Aktualisierung angefordert — sie startet innerhalb weniger Minuten.',
|
// Kurz gehalten, ohne eigene Zeitangabe: der Live-Countdown, der direkt im
|
||||||
'update_already_requested' => 'Eine Aktualisierung ist bereits angefordert.',
|
// 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_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',
|
'update_log' => 'Protokoll des letzten Laufs',
|
||||||
|
|
||||||
|
|
@ -121,9 +127,10 @@ return [
|
||||||
// auf der Einstellungsseite.
|
// auf der Einstellungsseite.
|
||||||
'update_overlay_title' => 'Aktualisierung läuft.',
|
'update_overlay_title' => 'Aktualisierung läuft.',
|
||||||
|
|
||||||
// Angefordert, aber der Dienst prüft im Takt — ohne Uhrzeit sitzt man davor
|
// Nur das Label — die Ziffern daneben zählen im Browser live herunter
|
||||||
// und weiß nicht, ob der Knopf überhaupt etwas getan hat.
|
// (resources/js/app.js), damit "Startet in 0:47" auch wirklich 0:47 sagt
|
||||||
'update_queued' => 'Angefordert. Der Update-Dienst startet sie spätestens um :time.',
|
// und nicht eine irgendwann errechnete Uhrzeit.
|
||||||
|
'update_starts_in' => 'Startet in',
|
||||||
'update_step' => 'Schritt: :step',
|
'update_step' => 'Schritt: :step',
|
||||||
'update_since' => 'Läuft seit :when',
|
'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.',
|
'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.',
|
||||||
|
|
|
||||||
|
|
@ -92,14 +92,20 @@ return [
|
||||||
'update_source' => 'Source',
|
'update_source' => 'Source',
|
||||||
'update_deployed' => 'Deployed',
|
'update_deployed' => 'Deployed',
|
||||||
'update_checked' => 'Last checked',
|
'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_now' => 'Update now',
|
||||||
'update_running' => 'Running',
|
'update_running' => 'Running',
|
||||||
'update_available' => ':n update(s) available',
|
'update_available' => ':n update(s) available',
|
||||||
'update_current' => 'Up to date',
|
'update_current' => 'Up to date',
|
||||||
'update_unknown' => 'Unknown',
|
'update_unknown' => 'Unknown',
|
||||||
'update_requested' => 'Update requested — it starts within a few minutes.',
|
// Kept short, with no time of its own: the live countdown shown right
|
||||||
'update_already_requested' => 'An update has already been requested.',
|
// 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_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',
|
'update_log' => 'Log of the last run',
|
||||||
|
|
||||||
|
|
@ -120,9 +126,10 @@ return [
|
||||||
// on every console page while an update is running, not just Settings.
|
// on every console page while an update is running, not just Settings.
|
||||||
'update_overlay_title' => 'Update in progress.',
|
'update_overlay_title' => 'Update in progress.',
|
||||||
|
|
||||||
// Requested, but the service checks on a timer — without a time an operator
|
// Label only — the digits beside it count down live in the browser
|
||||||
// sits in front of it not knowing whether the button did anything.
|
// (resources/js/app.js), so "Starts in 0:47" actually says 0:47 rather
|
||||||
'update_queued' => 'Requested. The update service starts it by :time at the latest.',
|
// than a clock time computed once and left to go stale.
|
||||||
|
'update_starts_in' => 'Starts in',
|
||||||
'update_step' => 'Step: :step',
|
'update_step' => 'Step: :step',
|
||||||
'update_since' => 'Running since :when',
|
'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.',
|
'update_offline_hint' => 'During the update the site is briefly in maintenance mode — this view stays put and comes back on its own afterwards.',
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,21 @@ window.fetch = async (...args) => {
|
||||||
return response;
|
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', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
// ── Watching a deployment that restarts the watcher ──────────────────
|
// ── 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
|
// This asks a plain JSON endpoint instead — no Livewire, no component
|
||||||
// state, no assets — and, crucially, treats a failed request as the
|
// state, no assets — and, crucially, treats a failed request as the
|
||||||
// restart rather than as an error worth giving up over.
|
// 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
|
// The build this page was rendered from. When the server stops
|
||||||
// agreeing, the page is stale by definition and must be replaced —
|
// agreeing, the page is stale by definition and must be replaced —
|
||||||
// this is the only signal that survives the restart, since the run has
|
// this is the only signal that survives the restart, since the run has
|
||||||
// already ended by the time we can ask again.
|
// already ended by the time we can ask again.
|
||||||
renderedCommit: commit,
|
renderedCommit: commit,
|
||||||
wasRunning: running,
|
wasRunning: running,
|
||||||
// Already translated server-side ("Schritt: …") — the client only
|
// Already translated server-side ("Schritt: …", "Läuft seit …") — the
|
||||||
// ever displays this, never assembles it, so German text stays out
|
// client only ever displays these, never assembles them, so German
|
||||||
// of JavaScript entirely.
|
// text stays out of JavaScript entirely.
|
||||||
step,
|
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,
|
timer: null,
|
||||||
|
clock: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.schedule();
|
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
|
// Nothing to watch while the tab is hidden; pick straight back up
|
||||||
// rather than waiting out a whole interval on return.
|
// rather than waiting out a whole interval on return.
|
||||||
document.addEventListener('visibilitychange', () => {
|
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() {
|
schedule() {
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
this.timer = setTimeout(() => this.check(), this.wasRunning ? 3000 : 20000);
|
this.timer = setTimeout(() => this.check(), this.wasRunning ? 3000 : 20000);
|
||||||
|
|
@ -161,6 +195,9 @@ document.addEventListener('alpine:init', () => {
|
||||||
|
|
||||||
this.wasRunning = state.running;
|
this.wasRunning = state.running;
|
||||||
this.step = state.step ?? null;
|
this.step = state.step ?? null;
|
||||||
|
this.runningSince = state.running_since ?? null;
|
||||||
|
this.log = state.log ?? null;
|
||||||
|
this.nextCheckAt = state.next_check_at ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
// Expected while the containers are down. Keep asking: this is
|
// Expected while the containers are down. Keep asking: this is
|
||||||
// the middle of the very event being watched, not a fault.
|
// the middle of the very event being watched, not a fault.
|
||||||
|
|
@ -172,6 +209,31 @@ document.addEventListener('alpine:init', () => {
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
clearTimeout(this.timer);
|
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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,18 @@
|
||||||
// watched restarts the containers serving whichever page the
|
// watched restarts the containers serving whichever page the
|
||||||
// operator happens to have open, so the watcher (and its overlay)
|
// operator happens to have open, so the watcher (and its overlay)
|
||||||
// must already exist before that happens, wherever they are.
|
// 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
|
$updateStep = $updateState['phase'] !== null
|
||||||
? __('admin_settings.update_step', ['step' => $updateState['phase']])
|
? __('admin_settings.update_step', ['step' => $updateState['phase']])
|
||||||
: null;
|
: 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
|
@endphp
|
||||||
|
|
||||||
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||||
|
|
@ -101,6 +109,9 @@
|
||||||
commit: @js($updateState['commit']),
|
commit: @js($updateState['commit']),
|
||||||
running: @js($updateState['running']),
|
running: @js($updateState['running']),
|
||||||
step: @js($updateStep),
|
step: @js($updateStep),
|
||||||
|
nextCheckAt: @js($updateState['next_check_at']?->toIso8601String()),
|
||||||
|
runningSince: @js($updateRunningSince),
|
||||||
|
log: @js($updateLog),
|
||||||
})"
|
})"
|
||||||
x-show="wasRunning"
|
x-show="wasRunning"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|
@ -111,19 +122,35 @@
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
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="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-info-bg text-info">
|
<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" />
|
<x-ui.icon name="refresh" class="size-7 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="mt-5 text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.update_overlay_title') }}</h1>
|
<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>
|
<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
|
{{-- Queued: the agent has not picked the request up yet, so
|
||||||
yet, and "Schritt:" with nothing after it is worse than
|
there is no step. A live countdown instead of silence —
|
||||||
leaving the line out. --}}
|
"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"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
own refresh of this card's own details (log tail, elapsed time)
|
own refresh of this card's own details (log tail, elapsed time)
|
||||||
for as long as a Livewire connection exists. --}}
|
for as long as a Livewire connection exists. --}}
|
||||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
|
<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="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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="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 class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
|
||||||
</span>
|
</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'])
|
@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="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']]) }}
|
<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
|
inside the component tag, and Blade then stops seeing a
|
||||||
component.
|
component.
|
||||||
|
|
||||||
The button used to be offered ONLY when the last check had
|
Two separate actions, always offered together: looking and
|
||||||
found something. But "behind" is a READING, taken every few
|
applying are different intentions, and an operator who only
|
||||||
minutes — it is not the state of the world. Push a commit
|
wants to know whether anything is new must not be routed
|
||||||
and for the next few minutes the console insists everything
|
through a real deployment to find out.
|
||||||
is current and refuses to do anything about it, which is
|
|
||||||
|
"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
|
precisely how it was reported. The agent fetches before it
|
||||||
decides, so asking against a stale reading costs one fetch
|
decides, so asking against a stale reading costs one fetch
|
||||||
and finds nothing; being locked out costs the deployment.
|
and finds nothing; being locked out costs the deployment.
|
||||||
|
|
||||||
Still disabled for the two cases where the request really
|
Both disabled for the two cases where neither would go
|
||||||
would go nowhere: a run already in flight, and no agent. --}}
|
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">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
@if ($update['running'])
|
@if ($update['running'])
|
||||||
<x-ui.button variant="secondary" :disabled="true">
|
<x-ui.button variant="secondary" :disabled="true">
|
||||||
|
|
@ -89,16 +100,16 @@
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
@elseif (! $update['agent_seen'])
|
@elseif (! $update['agent_seen'])
|
||||||
<x-ui.button variant="secondary" :disabled="true">{{ __('admin_settings.update_unknown') }}</x-ui.button>
|
<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
|
@else
|
||||||
{{-- Named for what it does rather than for what the last
|
<x-ui.button variant="secondary" :disabled="$update['checking']"
|
||||||
reading said: it re-checks, and updates if there is
|
wire:click="requestCheck" wire:loading.attr="disabled" wire:target="requestCheck">
|
||||||
anything to update. --}}
|
<x-ui.icon name="refresh" class="size-4 {{ $update['checking'] ? 'animate-spin' : '' }}" />
|
||||||
<x-ui.button variant="secondary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
|
{{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }}
|
||||||
{{ __('admin_settings.update_recheck') }}
|
</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>
|
</x-ui.button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,9 +123,12 @@
|
||||||
|
|
||||||
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
|
{{-- 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
|
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
|
does nothing at all until the agent's next tick, and while it
|
||||||
the site is in maintenance mode and this page is unreachable.
|
runs the site is in maintenance mode and this page is
|
||||||
Both of those look identical to "wedged" without a time. --}}
|
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['running'])
|
||||||
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
|
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
|
||||||
@if ($update['phase'])
|
@if ($update['phase'])
|
||||||
|
|
@ -123,10 +137,18 @@
|
||||||
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
|
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
|
||||||
@endif
|
@endif
|
||||||
@elseif ($update['next_check_at'])
|
@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
|
@endif
|
||||||
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
|
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
|
||||||
</div>
|
</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
|
@endif
|
||||||
|
|
||||||
@if ($update['last_state'] !== null && ! $update['running'])
|
@if ($update['last_state'] !== null && ! $update['running'])
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,11 @@ Route::post('/logout', function () {
|
||||||
*/
|
*/
|
||||||
Route::get('/update/state', function (UpdateChannel $channel) {
|
Route::get('/update/state', function (UpdateChannel $channel) {
|
||||||
$state = $channel->state();
|
$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([
|
return response()->json([
|
||||||
'running' => $state['running'],
|
'running' => $state['running'],
|
||||||
|
|
@ -93,5 +98,18 @@ Route::get('/update/state', function (UpdateChannel $channel) {
|
||||||
'step' => $state['phase'] !== null
|
'step' => $state['phase'] !== null
|
||||||
? __('admin_settings.update_step', ['step' => $state['phase']])
|
? __('admin_settings.update_step', ['step' => $state['phase']])
|
||||||
: null,
|
: 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');
|
])->header('Cache-Control', 'no-store');
|
||||||
})->name('update.state');
|
})->name('update.state');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Admin\Settings as AdminSettings;
|
use App\Livewire\Admin\Settings as AdminSettings;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Deployment\UpdateChannel;
|
use App\Services\Deployment\UpdateChannel;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
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
|
* tests cover the panel's half: what it reports, what it refuses, and what it
|
||||||
* must never claim.
|
* must never claim.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
File::deleteDirectory(storage_path('app/deploy'));
|
File::deleteDirectory(storage_path('app/deploy'));
|
||||||
});
|
});
|
||||||
|
|
@ -120,18 +120,20 @@ it('survives a status file that is corrupt', function () {
|
||||||
->assertOk();
|
->assertOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('says when a queued update will actually start', function () {
|
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
|
// The agent checks on a timer, so pressing the button visibly does
|
||||||
// for minutes. Without a time that is indistinguishable from a broken
|
// nothing until the next tick. Silence there reads as a broken button —
|
||||||
// button, which is precisely how it was read.
|
// 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
|
// 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'));
|
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
|
||||||
|
|
||||||
writeStatus([
|
writeStatus([
|
||||||
'state' => 'idle',
|
'state' => 'idle',
|
||||||
'checked_at' => now()->toIso8601String(),
|
'checked_at' => now()->toIso8601String(),
|
||||||
'check_interval_minutes' => 5,
|
'check_interval_minutes' => 1,
|
||||||
'behind' => 1,
|
'behind' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -140,21 +142,17 @@ it('says when a queued update will actually start', function () {
|
||||||
$state = app(UpdateChannel::class)->state();
|
$state = app(UpdateChannel::class)->state();
|
||||||
|
|
||||||
expect($state['running'])->toBeTrue()
|
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')
|
Livewire::actingAs(operator('Owner'), 'operator')
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
// Asserted against the operator's wall clock, not against whatever the
|
->assertSee(__('admin_settings.update_starts_in'))
|
||||||
// view happens to do. The old version of this test built its expected
|
->assertSee($state['next_check_at']->toIso8601String(), escape: false);
|
||||||
// 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'),
|
|
||||||
]));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names the step the deployment is on, in the operator’s language', function () {
|
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')
|
Livewire::actingAs(operator('Owner'), 'operator')
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
->assertSee(__('admin_settings.update_queued', [
|
->assertSee(__('admin_settings.update_starts_in'))
|
||||||
'time' => $state['next_check_at']->local()->format('H:i'),
|
|
||||||
]))
|
|
||||||
->assertDontSee(__('admin_settings.update_phase.migrate'));
|
->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 () {
|
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
|
// The check runs on a timer. Writing both into one file meant a failure
|
||||||
// failure was usually gone before anyone looked at it.
|
// was usually gone before anyone looked at it.
|
||||||
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
|
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
|
||||||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
|
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();
|
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".
|
// 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
|
// 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
|
// refused to act on it. The agent fetches before deciding, so asking
|
||||||
// against a stale reading costs one fetch — being locked out costs the
|
// 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')
|
Livewire::actingAs(operator('Owner'), 'operator')
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
->assertSee(__('admin_settings.update_recheck'))
|
->assertSee(__('admin_settings.update_now'))
|
||||||
->call('requestUpdate');
|
->call('requestUpdate');
|
||||||
|
|
||||||
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
|
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 () {
|
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.
|
// running that would ever read the request file.
|
||||||
writeStatus([
|
writeStatus([
|
||||||
'checked_at' => now()->subDay()->toIso8601String(),
|
'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')
|
Livewire::actingAs(operator('Owner'), 'operator')
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
->assertSee(__('admin_settings.update_unknown'))
|
->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 () {
|
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')
|
$this->actingAs(operator('Owner'), 'operator')
|
||||||
->get(route('admin.update.state'))
|
->get(route('admin.update.state'))
|
||||||
->assertOk()
|
->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');
|
->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]);
|
->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 () {
|
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'))
|
->get(route('admin.update.state'))
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue