feat(update): real phase progress feed + deep-linkable changelog series/page

Update-progress phases were a pure time guess, so a fast update sat on "fetch"
then snapped all four green at once. Now the host updater publishes its real
macro-stage and the page tracks it:

- update.sh / install.sh write the current stage (fetch|build|restart|migrate|
  done) to run/update-phase.json — best-effort (|| true, never aborts an update).
- new public GET /update-status.json serves that stage (whitelisted) to the page.
- update-progress.blade.php drives the checklist from the feed in REAL order
  (fetch → build → restart → migrate), falling back to the time heuristic when no
  feed is present, and completes on the feed's 'done' (or the version flip, with a
  25s grace fail-safe). Like the 502 fix, the live experience lands one update
  after this ships (the page shown DURING an update is the old version's).

Also: Versions changelog series + page are now #[Url]-synced (?series=&page=),
so a series/page is shareable and survives reload / back-button.

Tests: /update-status.json (null / whitelisted / rejected stage), the page polls
the feed, and the changelog deep-link reads ?series=&page=.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation v0.9.32
boban 2026-06-20 22:08:41 +02:00
parent 552417fd4b
commit 084c21a869
9 changed files with 207 additions and 23 deletions

View File

@ -13,6 +13,23 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.32] - 2026-06-20
### Hinzugefügt
- **Serie & Seite als URL-Parameter.** Die gewählte Versionsreihe und die Seite des Änderungs­
protokolls stehen jetzt in der Adresse (`?series=0.8&page=2`) — teilbar und per Reload/Zurück-Taste
erhalten.
- **Echter Update-Fortschritt statt Zeit-Schätzung.** Der Host-Updater meldet seine aktuelle Phase
über eine Datei (`run/update-phase.json`); die Update-Seite liest sie und schaltet die Schritte
**abrufen → Image bauen → Dienste neu starten → migrieren** der Reihe nach echt weiter, statt alle
vier am Ende auf einmal grün springen zu lassen. Abschluss erfolgt über das reale „done"-Signal des
Updaters (Fallback: Versions-Wechsel). Greift — wie der 502-Fix — erst beim Update, das von einer
bereits mit 0.9.32 laufenden Instanz gestartet wird (die während eines Updates angezeigte Seite ist
stets noch die der alten Version); ohne Feed bleibt die bisherige Zeit-Heuristik als Fallback.
### Geändert
- Update-Phasen in realer Reihenfolge (Dienste-Neustart vor Datenbank-Migration).
## [0.9.31] - 2026-06-20
### Geändert

View File

@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
@ -39,10 +40,13 @@ class Index extends Component
/** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
private const CHANGELOG_PER_PAGE = 8;
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail. */
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail.
* Synced to ?series= so a series is deep-linkable / survives reload + back-button. */
#[Url(as: 'series')]
public string $series = '';
/** 1-based page within the selected series' release list. */
/** 1-based page within the selected series' release list. Synced to ?page= (omitted while 1). */
#[Url(as: 'page')]
public int $changelogPage = 1;
public ?string $lastChecked = null;

View File

@ -2,7 +2,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.31',
'version' => '0.9.32',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -22,6 +22,18 @@ warn() { printf '\033[33m ! %s\033[0m\n' "$*"; }
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
phase() { printf '\n\033[1m[%s] %s\033[0m\n' "$1" "$2"; }
# set_stage: publish the current macro-stage (fetch|build|restart|migrate|done) to a file the
# still-running app container serves to the browser's update-progress page, so it shows REAL
# progress instead of a time guess (run/ is bind-mounted into the container). Strictly best-effort —
# a write failure must NEVER abort the update, hence the guards + `return 0`.
STAGE_FILE="run/update-phase.json"
set_stage() {
mkdir -p run 2>/dev/null || true
printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$STAGE_FILE" 2>/dev/null || true
chmod 644 "$STAGE_FILE" 2>/dev/null || true
return 0
}
# ── .env mutators ────────────────────────────────────────────────────
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
force_kv() {
@ -225,6 +237,7 @@ info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet; Zeitzone ${HOST_TZ}"
# ── [4/9] image ──────────────────────────────────────────────────────
phase 4/9 "Image bauen"
set_stage build
if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi
info "Image bereit"
@ -239,6 +252,7 @@ esac
# ── [5/9] stack ──────────────────────────────────────────────────────
phase 5/9 "Stack starten"
set_stage restart
$COMPOSE up -d
# Caddyfile changes ship as edits to a bind-mounted file; `up -d` does NOT recreate caddy for a
# content-only change, so force it to re-read the (possibly updated) Caddyfile on every deploy.
@ -298,6 +312,7 @@ done
# ── [7/9] migrate + caches ───────────────────────────────────────────
phase 7/9 "Migrationen"
set_stage migrate
$COMPOSE exec -T -u app app php artisan migrate --force
$COMPOSE exec -T -u app app php artisan config:cache >/dev/null
$COMPOSE exec -T -u app app php artisan route:cache >/dev/null
@ -355,6 +370,9 @@ else
info "Statisches MOTD geschrieben (/etc/motd)"
fi
# Update fully applied — tell the in-browser progress page it may finish (real completion signal).
set_stage done
# ── closing banner (passwords shown ONLY here) ───────────────────────
echo
bold "Installation erfolgreich."

View File

@ -69,11 +69,12 @@
<div class="mt-8">
<ol class="space-y-3" aria-label="Update phases">
@php
// Real order of the host updater: git pull → docker build → stack up → migrate.
$phases = [
['key' => 'fetch', 'label' => __('update.phase_fetch')],
['key' => 'build', 'label' => __('update.phase_build')],
['key' => 'migrate', 'label' => __('update.phase_migrate')],
['key' => 'restart', 'label' => __('update.phase_restart')],
['key' => 'migrate', 'label' => __('update.phase_migrate')],
];
@endphp
@foreach ($phases as $i => $phase)
@ -103,8 +104,9 @@
</div>
{{-- Inline JS no external dependencies. Reads ?return= (redirect target) and ?from= (the
pre-update version). Polls /version.json every 2000 ms and finishes only when the running
version has moved past ?from= (or, for a same-version redeploy, after a down→up cycle),
pre-update version). Drives the phase checklist from the real /update-status.json feed
(falling back to a time heuristic when absent) and finishes when that feed reports 'done',
or when /version.json shows the running version moved past ?from= (or a down→up cycle),
stable for 2 polls. Times out after 10 minutes and shows a manual-reload prompt. --}}
<script>
(function () {
@ -119,10 +121,19 @@
var fromVersion = {{ \Illuminate\Support\Js::from($fromVersion) }};
var sawDown = false; // set once a poll fails (teardown) — lets a same-version redeploy finish
// ── Phase timing heuristic (purely visual) ─────────────────────────────
// Approx durations (seconds) per phase before advancing the highlight.
var PHASE_KEYS = ['fetch', 'build', 'migrate', 'restart'];
var PHASE_TIMES = [15, 40, 60, 90]; // cumulative seconds to enter each phase
// ── Real phase feed ────────────────────────────────────────────────────
// The host updater (update.sh / install.sh) writes the current macro-stage to
// /update-status.json; we map it onto the checklist so it tracks REAL progress instead of a
// time guess. pageStart (server epoch at render) lets us ignore a stale stage file left by a
// previous update. The feed only became active one release after this shipped (the page
// running DURING an update is the old version's) — so the heuristic below is the fallback.
var pageStart = {{ now()->timestamp }};
var STAGE_INDEX = { fetch: 0, build: 1, restart: 2, migrate: 3 };
var feedActive = false; // set once /update-status.json returns a fresh stage
// ── Phase timing heuristic (fallback when no real feed is available) ───
var PHASE_KEYS = ['fetch', 'build', 'restart', 'migrate'];
var PHASE_TIMES = [3, 18, 30, 42]; // cumulative seconds to enter each phase (rough guess)
var currentPhase = -1;
function setPhaseActive(index) {
@ -179,7 +190,9 @@
// ── Polling ────────────────────────────────────────────────────────────
var stableNew = 0; // consecutive polls that have seen the NEW app live
var STABLE_REQUIRED = 2; // require 2 in a row before redirecting (flap guard)
var versionChangedAt = 0; // ms timestamp when the NEW version first went live
var pollInterval = null;
var statusInterval = null;
var tickInterval = null;
var done = false;
@ -187,6 +200,7 @@
if (done) return;
done = true;
clearInterval(pollInterval);
clearInterval(statusInterval);
clearInterval(tickInterval);
// Show all phases complete
setPhaseActive(PHASE_KEYS.length);
@ -202,6 +216,7 @@
if (done) return;
done = true;
clearInterval(pollInterval);
clearInterval(statusInterval);
clearInterval(tickInterval);
spinnerEl.classList.add('hidden');
runningEl.classList.add('hidden');
@ -213,13 +228,23 @@
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
// Advance phase highlight based on elapsed time
// Real progress comes from the phase feed; only fall back to the time heuristic when no
// feed has answered yet.
if (!feedActive) {
for (var p = PHASE_KEYS.length - 1; p >= 0; p--) {
if (elapsed >= PHASE_TIMES[p] * 1000) {
if (currentPhase !== p) setPhaseActive(p);
break;
}
}
}
// Fail-safe: the feed is active but its 'done' never arrived within 25 s of the new
// version going live (the installer likely died after cutover) — the new app IS up, so
// finish anyway rather than spin to the 10-minute timeout.
if (versionChangedAt && feedActive && (Date.now() - versionChangedAt > 25000)) {
showDone();
}
if (elapsed >= TIMEOUT_MS) {
showTimeout();
@ -244,7 +269,13 @@
var isNew = (fromVersion && v && v !== fromVersion) || (sawDown && v);
if (isNew) {
stableNew++;
if (stableNew >= STABLE_REQUIRED) showDone();
if (stableNew >= STABLE_REQUIRED) {
if (!versionChangedAt) versionChangedAt = Date.now();
// No real feed → complete on the version flip (the robust signal). WITH a
// feed, wait for its 'done' stage so the migrate phase still shows; tick()
// has a grace-period fail-safe if 'done' never arrives.
if (!feedActive) showDone();
}
} else {
stableNew = 0; // still the old version (pre/during build) — keep waiting
}
@ -256,11 +287,32 @@
});
}
// ── Real phase feed poll ───────────────────────────────────────────────
function pollStatus() {
if (done) return;
fetch('/update-status.json', { cache: 'no-store', method: 'GET' })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) {
if (!data || !data.stage) return;
// Ignore a stage file left over from a previous update (written before this load).
if (typeof data.at === 'number' && data.at > 0 && data.at < pageStart - 30) return;
feedActive = true;
if (data.stage === 'done') { showDone(); return; }
var idx = STAGE_INDEX[data.stage];
if (typeof idx === 'number' && idx > currentPhase) setPhaseActive(idx);
})
.catch(function () { /* feed unreachable mid-cutover — keep the last known phase */ });
}
tickInterval = setInterval(tick, 1000);
tick(); // immediate first tick
// Start polling soon. Completion is gated on a CHANGED version (or a down→up cycle), so
// polling the still-up old instance early is harmless — it simply keeps waiting.
// Real phase feed: poll right away + briskly so the checklist tracks actual progress.
pollStatus();
statusInterval = setInterval(pollStatus, 1500);
// Version probe (completion). Gated on a CHANGED version (or a down→up cycle); with a real
// feed, completion waits for its 'done' stage (tick() has a grace-period fail-safe).
setTimeout(function () {
poll(); // first poll
pollInterval = setInterval(poll, 2000);

View File

@ -47,6 +47,25 @@ Route::get('/_caddy/ask', function (Request $request, DeploymentService $deploym
// container keeps answering /up throughout the long build, so /up alone can't signal completion.
Route::get('/version.json', fn () => response()->json(['version' => (string) config('clusev.version')]))->name('version.json');
// Coarse update-progress feed. The host updater (update.sh / install.sh) writes a macro-stage to
// run/update-phase.json (bind-mounted to storage/app/restart-signal/); the update-progress page
// polls this to show REAL progress (fetch → build → restart → migrate → done) instead of a time
// guess. PUBLIC and only ever echoes a whitelisted stage token — never any sensitive data.
Route::get('/update-status.json', function () {
$path = storage_path('app/restart-signal/update-phase.json');
$stage = null;
$at = 0;
if (is_file($path)) {
$data = json_decode((string) @file_get_contents($path), true);
if (is_array($data) && in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done'], true)) {
$stage = $data['stage'];
$at = (int) ($data['at'] ?? 0);
}
}
return response()->json(['stage' => $stage, 'at' => $at]);
})->name('update.status');
Route::middleware('guest')->group(function () {
Route::get('/login', Auth\Login::class)->name('login');
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');

View File

@ -231,4 +231,57 @@ class UpdateProgressTest extends TestCase
$this->assertStringContainsString('Datenbank wird migriert', $body);
$this->assertStringContainsString('Dienste werden neu gestartet', $body);
}
// ── (c) real phase feed ───────────────────────────────────────────────────
public function test_update_progress_page_polls_the_phase_status_feed(): void
{
$body = $this->get(route('update.progress'))->content();
$this->assertStringContainsString(
"fetch('/update-status.json'",
$body,
'the page must poll the real phase feed so the checklist tracks actual progress',
);
}
public function test_update_status_returns_a_null_stage_when_no_feed_file(): void
{
@unlink(storage_path('app/restart-signal/update-phase.json'));
$this->getJson('/update-status.json')
->assertOk()
->assertJson(['stage' => null]);
}
public function test_update_status_returns_a_whitelisted_stage_from_the_feed(): void
{
$dir = storage_path('app/restart-signal');
@mkdir($dir, 0775, true);
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'build', 'at' => 123]));
try {
$this->getJson('/update-status.json')
->assertOk()
->assertExactJson(['stage' => 'build', 'at' => 123]);
} finally {
@unlink($dir.'/update-phase.json');
}
}
public function test_update_status_rejects_an_unknown_stage(): void
{
$dir = storage_path('app/restart-signal');
@mkdir($dir, 0775, true);
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'evil', 'at' => 1]));
try {
// Only the whitelisted macro-stages are ever echoed back.
$this->getJson('/update-status.json')
->assertOk()
->assertJson(['stage' => null]);
} finally {
@unlink($dir.'/update-phase.json');
}
}
}

View File

@ -140,6 +140,15 @@ class VersionsChangelogTest extends TestCase
Livewire::test(Index::class)->assertViewHas('totalReleases', fn ($count): bool => is_int($count) && $count >= 1);
}
public function test_series_and_page_are_deep_linkable_via_the_query_string(): void
{
// ?series= + ?page= drive the view so a series/page is shareable and survives reload.
Livewire::withQueryParams(['series' => '0.9', 'page' => 2])
->test(Index::class)
->assertSet('series', '0.9')
->assertSet('changelogPage', 2);
}
public function test_chevron_right_icon_renders_a_real_path(): void
{
// The accordion disclosure caret and the pagination "Next" button both use x-icon

View File

@ -19,6 +19,18 @@ set -euo pipefail
cd "$(dirname "$0")"
REPO_DIR="$(pwd)"
# set_stage: publish a coarse update stage for the in-browser progress screen (best-effort — the
# app container serves run/update-phase.json to it). A write failure must never fail the update.
# Written first as "fetch" so the screen leaves the time-guess behind the moment the pull begins,
# and so a stale stage from a previous update is reset.
set_stage() {
mkdir -p "$REPO_DIR/run" 2>/dev/null || true
printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$REPO_DIR/run/update-phase.json" 2>/dev/null || true
chmod 644 "$REPO_DIR/run/update-phase.json" 2>/dev/null || true
return 0
}
set_stage fetch
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }