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
parent
552417fd4b
commit
084c21a869
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
install.sh
18
install.sh
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -177,16 +188,19 @@
|
|||
var timeoutEl = document.getElementById('js-status-timeout');
|
||||
|
||||
// ── 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 pollInterval = null;
|
||||
var tickInterval = null;
|
||||
var done = false;
|
||||
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;
|
||||
|
||||
function showDone() {
|
||||
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,14 +228,24 @@
|
|||
|
||||
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
|
||||
|
||||
// Advance phase highlight based on elapsed time
|
||||
for (var p = PHASE_KEYS.length - 1; p >= 0; p--) {
|
||||
if (elapsed >= PHASE_TIMES[p] * 1000) {
|
||||
if (currentPhase !== p) setPhaseActive(p);
|
||||
break;
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
update.sh
12
update.sh
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue