diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b6606..e0f0830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,14 @@ 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.48] - 2026-06-21 +## [0.9.49] - 2026-06-21 + +### Hinzugefügt +- **Was ist neu — vor dem Update sichtbar.** Ist eine neue Version verfügbar, zeigt die Version-Seite + jetzt ein „Das ist neu in vX.Y.Z"-Feld mit dem Änderungsprotokoll der **verfügbaren** Version(en) — + geladen aus der `CHANGELOG.md` des neuesten Release-Tags. So sieht man **vor** dem Aktualisieren, + was sich ändert, und muss das Update nicht erst durchführen. (Anonym + nur lesend, kurz gecacht; + bei fehlender Verbindung bleibt der Update-Knopf unverändert nutzbar.) ### Geändert - **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 228fb5c..7c0735a 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -542,11 +542,22 @@ class Index extends Component return []; } + return $this->parseChangelog((string) file_get_contents($path)); + } + + /** + * Parse a Keep-a-Changelog document into release nodes (shared by the local history and the + * remote "what's new" preview). + * + * @return array>}> + */ + private function parseChangelog(string $content): array + { $releases = []; $current = null; $group = null; - foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) { + foreach (preg_split('/\R/', $content) as $line) { // Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]" if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) { if ($current !== null) { @@ -599,6 +610,69 @@ class Index extends Component return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== [])); } + /** + * The changelog entries the operator would GAIN by updating: the remote CHANGELOG.md at the + * latest tag, parsed and filtered to released versions newer than the installed one — so the + * "what's new" can be read BEFORE applying the update. Empty when there is no update or the + * fetch fails (degrade gracefully; the update button is unaffected). + * + * @return array>}> + */ + private function availableChangelog(string $installed, ?string $latestTag): array + { + if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) { + return []; + } + + $content = $this->fetchRemoteChangelog($latestTag); + if ($content === null) { + return []; + } + + $inst = ltrim($installed, 'vV'); + + return array_values(array_filter( + $this->parseChangelog($content), + static fn (array $r): bool => ! $r['unreleased'] + && preg_match('/^\d+\.\d+\.\d+/', $r['version']) === 1 + && version_compare(ltrim($r['version'], 'vV'), $inst, '>'), + )); + } + + /** + * Fetch CHANGELOG.md from the public Git host at a given ref (tag). Anonymous + read-only; + * cached briefly so the page render never repeats the network call. Null on any failure. + */ + private function fetchRemoteChangelog(string $ref): ?string + { + $repo = (string) config('clusev.repository'); + if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) { + return null; + } + [, $base, $owner, $name] = $m; + + $key = 'clusev:remote-changelog:'.$ref; + $cached = Cache::get($key); + if (is_string($cached)) { + return $cached; + } + + try { + $res = Http::timeout(5)->get("{$base}/api/v1/repos/{$owner}/{$name}/raw/CHANGELOG.md", ['ref' => $ref]); + if (! $res->successful()) { + return null; + } + $body = $res->body(); + Cache::put($key, $body, now()->addMinutes(10)); // only successes are cached + + return $body; + } catch (\Throwable $e) { + report($e); + + return null; + } + } + public function render() { $releases = $this->releases(); @@ -651,6 +725,10 @@ class Index extends Component 'currentSeries' => $currentSeries, 'totalReleases' => count($releases), 'latestTag' => $latestTag, + // What the operator would gain by updating — shown BEFORE applying (only when behind). + 'availableUpdates' => $this->updateState === 'update' + ? $this->availableChangelog((string) config('clusev.version'), $latestTag) + : [], ])->title(__('versions.title')); } } diff --git a/config/clusev.php b/config/clusev.php index 1697ebe..7b66523 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.48', + 'version' => '0.9.49', // 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 diff --git a/lang/de/versions.php b/lang/de/versions.php index a28d781..659205a 100644 --- a/lang/de/versions.php +++ b/lang/de/versions.php @@ -18,6 +18,10 @@ return [ 'status_update_available' => 'Update verfügbar: v:latest (Kanal: :channel).', 'status_current' => 'Aktuell — v:installed (Kanal: :channel).', + // "What's new" preview (shown before updating) + 'whats_new_title' => 'Das ist neu in :tag', + 'whats_new_subtitle' => 'vor dem Aktualisieren lesen', + // Changelog panel 'changelog_title' => 'Änderungsprotokoll', 'changelog_subtitle' => ':count Versionen', diff --git a/lang/en/versions.php b/lang/en/versions.php index 17d2f65..5b6bb0c 100644 --- a/lang/en/versions.php +++ b/lang/en/versions.php @@ -18,6 +18,10 @@ return [ 'status_update_available' => 'Update available: v:latest (channel: :channel).', 'status_current' => 'Up to date — v:installed (channel: :channel).', + // "What's new" preview (shown before updating) + 'whats_new_title' => "What's new in :tag", + 'whats_new_subtitle' => 'read before updating', + // Changelog panel 'changelog_title' => 'Changelog', 'changelog_subtitle' => ':count versions', diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index a8a24b9..02de018 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -93,6 +93,37 @@ @endif + {{-- What's new in the available release — read BEFORE updating (fetched from the remote + CHANGELOG.md at the latest tag). Only shown when behind a published release. --}} + @if ($isUpdate && ! empty($availableUpdates)) + +
+ @foreach ($availableUpdates as $rel) +
! $loop->first])> +

+ v{{ $rel['version'] }}@if ($rel['date']) · {{ $rel['date'] }}@endif +

+
+ @foreach ($rel['groups'] as $group => $items) +
+ {{ $sectionLabel($group) }} +
    + @foreach ($items as $text) +
  • + + {{ $text }} +
  • + @endforeach +
+
+ @endforeach +
+
+ @endforeach +
+
+ @endif +
{{-- Release history — grouped into major.minor series, paginated (no endless scroll) --}} diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index ce51a43..9b55ea3 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -52,6 +52,37 @@ class VersionUpdateCheckTest extends TestCase ->assertSee('0.9.6'); } + public function test_shows_the_available_versions_changelog_before_updating(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([ + self::TAGS_URL => Http::response([['name' => 'v0.9.6'], ['name' => 'v0.9.5'], ['name' => 'v0.9.4']]), + 'git.bave.dev/api/v1/repos/boban/clusev/raw/CHANGELOG.md*' => Http::response( + "# Changelog\n\n## [0.9.6] - 2026-06-22\n\n### Hinzugefügt\n- Brandneues Feature XYZ.\n\n". + "## [0.9.5] - 2026-06-22\n\n### Behoben\n- Fehler ABC behoben.\n\n". + "## [0.9.4] - 2026-06-21\n\n### Geändert\n- Schon installiert.\n" + ), + ]); + + Livewire::test(Index::class) + ->call('checkUpdates') + ->assertSet('updateState', 'update') + ->assertViewHas('availableUpdates', fn ($u) => count($u) === 2) // 0.9.6 + 0.9.5 only + ->assertSee('Brandneues Feature XYZ') + ->assertSee('Fehler ABC behoben') + ->assertDontSee('Schon installiert'); // 0.9.4 == installed → excluded + } + + public function test_no_whats_new_preview_when_already_current(): void + { + config()->set('clusev.version', '0.9.9'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + Livewire::test(Index::class) + ->call('checkUpdates') + ->assertViewHas('availableUpdates', []); + } + public function test_reports_current_when_installed_is_at_or_above_the_latest_tag(): void { config()->set('clusev.version', '0.9.9');