From 4b2aee39412d1ddaf4aab4d29aab78f1c215a1ef Mon Sep 17 00:00:00 2001
From: boban
Date: Mon, 22 Jun 2026 00:19:27 +0200
Subject: [PATCH] fix(versions): show what's-new (v-prefixed ref) + sidebar
update badge (v0.9.51)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two fixes the operator asked for:
- The "what's new" preview was empty. availableChangelog fetched CHANGELOG.md at
ref="0.9.50", but release tags are "v0.9.50" — the raw fetch 404'd and the
panel silently stayed empty. Use the v-prefixed tag as the ref. The existing
test masked it with a wildcard fake; tightened with Http::assertSent.
- The sidebar now shows a "1" badge on the Version item when an update is
available, so an update is visible on every page, not only the Versions page.
Driven by a new ReleaseChecker service: updateAvailable() is a pure cache read
(no network in the sidebar); a scheduled clusev:check-update (every 30 min,
anonymous/read-only) keeps the cache warm so the badge is accurate even before
the operator opens the Versions page.
Refactor: the remote tag lookup (fetch + newest-version) moved out of the
Versions component into ReleaseChecker; the component delegates. nav-item gains
an optional badge prop.
Tests: ReleaseChecker (cache-read update-available, refresh caches, command warms
cache, sidebar badge renders only when available) + the v-prefixed-ref assertion.
371 pass, Pint clean, /versions loads 200 with no console errors; badge + the
"What's new in v0.9.50" panel verified in the browser. Lang parity kept.
Co-Authored-By: Claude Opus 4.8
---
CHANGELOG.md | 18 +++
app/Console/Commands/CheckUpdate.php | 25 ++++
app/Livewire/Versions/Index.php | 91 ++----------
app/Services/ReleaseChecker.php | 134 ++++++++++++++++++
config/clusev.php | 2 +-
lang/de/shell.php | 1 +
lang/en/shell.php | 1 +
resources/views/components/nav-item.blade.php | 6 +-
resources/views/components/sidebar.blade.php | 6 +-
routes/console.php | 3 +
tests/Feature/ReleaseCheckerTest.php | 91 ++++++++++++
tests/Feature/VersionUpdateCheckTest.php | 5 +
12 files changed, 297 insertions(+), 86 deletions(-)
create mode 100644 app/Console/Commands/CheckUpdate.php
create mode 100644 app/Services/ReleaseChecker.php
create mode 100644 tests/Feature/ReleaseCheckerTest.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ac9f84..d7a541a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,24 @@ 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.51] - 2026-06-22
+
+### Behoben
+- **„Was ist neu" wurde nicht angezeigt.** Der Vorschau-Block lud die `CHANGELOG.md` am Tag `0.9.50`,
+ die Release-Tags heißen aber `v0.9.50` — der Abruf lief ins Leere (404) und der Block blieb leer.
+ Jetzt wird die richtige `v`-präfixierte Tag-Referenz verwendet; die Änderungen der verfügbaren
+ Version erscheinen wieder vor dem Update.
+
+### Hinzugefügt
+- **Update-Hinweis in der Seitenleiste.** Ist eine neue Version verfügbar, zeigt der Menüpunkt
+ **Version** jetzt ein kleines **„1"-Abzeichen** — man sieht ein Update also auf jeder Seite, nicht
+ erst auf der Version-Seite. Der Status wird von einer geplanten Prüfung (alle 30 Min, anonym und
+ nur lesend gegen den Git-Host) im Cache warm gehalten; die Seitenleiste macht selbst keinen Netzabruf.
+
+### Geändert
+- Die Release-Prüfung (Version-Seite, Seitenleisten-Abzeichen, geplante Prüfung) nutzt jetzt einen
+ gemeinsamen `ReleaseChecker`-Dienst (vorher in der Version-Seite verkapselt).
+
## [0.9.50] - 2026-06-21
### Hinzugefügt
diff --git a/app/Console/Commands/CheckUpdate.php b/app/Console/Commands/CheckUpdate.php
new file mode 100644
index 0000000..4a6e3ad
--- /dev/null
+++ b/app/Console/Commands/CheckUpdate.php
@@ -0,0 +1,25 @@
+refresh();
+ $this->info($tag !== null ? "latest release: v{$tag}" : 'remote unreachable — keeping the previous cache');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php
index 7c0735a..3b71570 100644
--- a/app/Livewire/Versions/Index.php
+++ b/app/Livewire/Versions/Index.php
@@ -3,8 +3,8 @@
namespace App\Livewire\Versions;
use App\Models\AuditEvent;
-use App\Models\Setting;
use App\Services\DeploymentService;
+use App\Services\ReleaseChecker;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@@ -25,9 +25,6 @@ use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
- /** Only these channels are ever offered to users — there is no 'dev' channel. */
- private const CHANNELS = ['stable', 'beta'];
-
/**
* Cache (Redis in prod) marker for an in-flight update: holds the version that was running
* when the update was requested. Persisted in Redis — NOT the sentinel file (which the host
@@ -255,9 +252,7 @@ class Index extends Component
/** Resolve the configured channel, clamped to the user-facing set. */
private function channel(): string
{
- $channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
-
- return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
+ return app(ReleaseChecker::class)->channel();
}
/**
@@ -269,58 +264,17 @@ class Index extends Component
*/
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
{
- $key = 'clusev:latest-release:'.$channel;
+ $checker = app(ReleaseChecker::class);
if ($forceRemote) {
- $remote = $this->fetchRemoteLatestTag($channel);
+ $remote = $checker->refresh($channel);
if ($remote !== null) {
- Cache::put($key, $remote, now()->addMinutes(30));
-
return $remote;
}
// Remote unreachable — fall through to whatever we last cached / can read locally.
}
- $cached = Cache::get($key);
- if (is_string($cached) && $cached !== '') {
- return $cached;
- }
-
- return $this->localLatestTag($channel);
- }
-
- /**
- * Latest tag from the public Git host's API (Gitea: /api/v1/repos///tags).
- * Anonymous + read-only (the repo is public) — no token ever leaves the panel. Returns
- * null on any failure (offline, non-2xx, malformed) so the caller degrades gracefully.
- */
- private function fetchRemoteLatestTag(string $channel): ?string
- {
- $repo = (string) config('clusev.repository');
- if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
- return null;
- }
- [, $base, $owner, $name] = $m;
-
- try {
- $res = Http::timeout(5)->acceptJson()
- ->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
-
- if (! $res->successful()) {
- return null;
- }
-
- $names = array_map(
- static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
- (array) $res->json(),
- );
-
- return $this->newestVersion($names, $channel);
- } catch (\Throwable $e) {
- report($e);
-
- return null;
- }
+ return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
}
/**
@@ -348,37 +302,7 @@ class Index extends Component
}
}
- return $this->newestVersion($tags, $channel);
- }
-
- /**
- * Pick the newest semantic version from a list of tag names, scoped to the channel:
- * 'stable' accepts only plain vX.Y.Z; 'beta' also accepts prereleases (vX.Y.Z-…), so a
- * beta user is still offered a newer stable. Leading "v" is normalised off the result.
- *
- * @param array $tagNames
- */
- private function newestVersion(array $tagNames, string $channel): ?string
- {
- $pattern = $channel === 'beta'
- ? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
- : '/^v?\d+\.\d+\.\d+$/';
-
- $versions = [];
- foreach (array_unique($tagNames) as $tag) {
- $tag = trim((string) $tag);
- if ($tag !== '' && preg_match($pattern, $tag)) {
- $versions[] = ltrim($tag, 'vV');
- }
- }
-
- if ($versions === []) {
- return null;
- }
-
- usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
-
- return $versions[0];
+ return app(ReleaseChecker::class)->newestVersion($tags, $channel);
}
/** Is the given tag a newer semantic version than the installed one? */
@@ -624,7 +548,8 @@ class Index extends Component
return [];
}
- $content = $this->fetchRemoteChangelog($latestTag);
+ // Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v".
+ $content = $this->fetchRemoteChangelog('v'.ltrim($latestTag, 'vV'));
if ($content === null) {
return [];
}
diff --git a/app/Services/ReleaseChecker.php b/app/Services/ReleaseChecker.php
new file mode 100644
index 0000000..7d8eed7
--- /dev/null
+++ b/app/Services/ReleaseChecker.php
@@ -0,0 +1,134 @@
+cacheKey($channel ?? $this->channel()));
+
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+
+ /**
+ * Whether a newer release than the installed version is known from the cache (no network) — the
+ * single source for the sidebar badge. A stale cache below the installed version never counts.
+ */
+ public function updateAvailable(): bool
+ {
+ $latest = $this->cachedLatest();
+ if ($latest === null) {
+ return false;
+ }
+
+ return version_compare(ltrim($latest, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '>');
+ }
+
+ /**
+ * Force a fresh remote lookup, cache it, and return the newest tag for the channel. Null on any
+ * failure (offline, non-2xx, malformed) so callers degrade gracefully.
+ */
+ public function refresh(?string $channel = null): ?string
+ {
+ $channel ??= $this->channel();
+ $tag = $this->fetchRemoteLatestTag($channel);
+ if ($tag !== null) {
+ Cache::put($this->cacheKey($channel), $tag, now()->addMinutes(self::TTL_MINUTES));
+ }
+
+ return $tag;
+ }
+
+ /**
+ * Newest tag from the public Git host's API (Gitea: /api/v1/repos///tags).
+ * Returns the version WITHOUT the leading "v" (e.g. "0.9.50"); null on any failure.
+ */
+ public function fetchRemoteLatestTag(string $channel): ?string
+ {
+ $repo = (string) config('clusev.repository');
+ if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
+ return null;
+ }
+ [, $base, $owner, $name] = $m;
+
+ try {
+ $res = Http::timeout(5)->acceptJson()
+ ->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
+
+ if (! $res->successful()) {
+ return null;
+ }
+
+ $names = array_map(
+ static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
+ (array) $res->json(),
+ );
+
+ return $this->newestVersion($names, $channel);
+ } catch (\Throwable $e) {
+ report($e);
+
+ return null;
+ }
+ }
+
+ /**
+ * Highest version among the tag names, honouring the channel (stable rejects prereleases).
+ * Returns the version without the "v" prefix.
+ *
+ * @param array $tagNames
+ */
+ public function newestVersion(array $tagNames, string $channel): ?string
+ {
+ $pattern = $channel === 'beta'
+ ? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
+ : '/^v?\d+\.\d+\.\d+$/';
+
+ $versions = [];
+ foreach (array_unique($tagNames) as $tag) {
+ $tag = trim((string) $tag);
+ if ($tag !== '' && preg_match($pattern, $tag)) {
+ $versions[] = ltrim($tag, 'vV');
+ }
+ }
+
+ if ($versions === []) {
+ return null;
+ }
+
+ usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
+
+ return $versions[0];
+ }
+}
diff --git a/config/clusev.php b/config/clusev.php
index bbde523..51cbe3b 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.50',
+ 'version' => '0.9.51',
// 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/shell.php b/lang/de/shell.php
index adc8c2a..522e96f 100644
--- a/lang/de/shell.php
+++ b/lang/de/shell.php
@@ -23,6 +23,7 @@ return [
'nav_system' => 'System',
'nav_versions' => 'Version',
'nav_help' => 'Hilfe',
+ 'update_available' => 'Update verfügbar',
// Sidebar — user / 2FA badge
'twofa_on' => '2FA aktiv',
diff --git a/lang/en/shell.php b/lang/en/shell.php
index 19b9e9f..55a2671 100644
--- a/lang/en/shell.php
+++ b/lang/en/shell.php
@@ -23,6 +23,7 @@ return [
'nav_system' => 'System',
'nav_versions' => 'Version',
'nav_help' => 'Help',
+ 'update_available' => 'Update available',
// Sidebar — user / 2FA badge
'twofa_on' => '2FA on',
diff --git a/resources/views/components/nav-item.blade.php b/resources/views/components/nav-item.blade.php
index 4f7e6a6..8026ab2 100644
--- a/resources/views/components/nav-item.blade.php
+++ b/resources/views/components/nav-item.blade.php
@@ -1,4 +1,4 @@
-@props(['icon', 'href' => '#', 'active' => false])
+@props(['icon', 'href' => '#', 'active' => false, 'badge' => null])
$active]) />
{{ $slot }}
+ @if ($badge !== null && $badge !== '')
+ {{ $badge }}
+ @endif
diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php
index b931f08..06011b4 100644
--- a/resources/views/components/sidebar.blade.php
+++ b/resources/views/components/sidebar.blade.php
@@ -1,3 +1,7 @@
+@php
+ // Global "update available" cue for the Version nav item — a cache read only (no network).
+ $updateAvailable = app(\App\Services\ReleaseChecker::class)->updateAvailable();
+@endphp
{{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}
{{ __('shell.nav_settings') }}
{{ __('shell.nav_system') }}
- {{ __('shell.nav_versions') }}
+ {{ __('shell.nav_versions') }}
{{ __('shell.nav_help') }}
diff --git a/routes/console.php b/routes/console.php
index 8e1d432..931fd2a 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -15,3 +15,6 @@ Schedule::command('clusev:prune-audit')->daily();
// Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale).
Schedule::command('clusev:wg-sample')->everyMinute();
+
+// Refresh the cached latest-release tag so the sidebar update badge stays warm (cache TTL is 60 min).
+Schedule::command('clusev:check-update')->everyThirtyMinutes();
diff --git a/tests/Feature/ReleaseCheckerTest.php b/tests/Feature/ReleaseCheckerTest.php
new file mode 100644
index 0000000..608b3d6
--- /dev/null
+++ b/tests/Feature/ReleaseCheckerTest.php
@@ -0,0 +1,91 @@
+set('clusev.version', '0.9.10');
+ Cache::put('clusev:latest-release:stable', '0.9.11', now()->addMinutes(30));
+
+ $this->assertTrue(app(ReleaseChecker::class)->updateAvailable());
+ }
+
+ public function test_update_available_is_false_when_cache_is_at_or_below_installed_or_empty(): void
+ {
+ config()->set('clusev.version', '0.9.11');
+
+ $this->assertFalse(app(ReleaseChecker::class)->updateAvailable(), 'empty cache → no badge');
+
+ Cache::put('clusev:latest-release:stable', '0.9.11', now()->addMinutes(30));
+ $this->assertFalse(app(ReleaseChecker::class)->updateAvailable(), 'equal version → no badge');
+
+ Cache::put('clusev:latest-release:stable', '0.9.9', now()->addMinutes(30));
+ $this->assertFalse(app(ReleaseChecker::class)->updateAvailable(), 'stale older cache → no badge');
+ }
+
+ public function test_update_available_does_no_network(): void
+ {
+ Http::fake();
+ config()->set('clusev.version', '0.9.10');
+ Cache::put('clusev:latest-release:stable', '0.9.12', now()->addMinutes(30));
+
+ $this->assertTrue(app(ReleaseChecker::class)->updateAvailable());
+ Http::assertNothingSent();
+ }
+
+ public function test_refresh_fetches_and_caches_the_newest_tag(): void
+ {
+ Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.13'], ['name' => 'v0.9.12']])]);
+
+ $tag = app(ReleaseChecker::class)->refresh('stable');
+
+ $this->assertSame('0.9.13', $tag);
+ $this->assertSame('0.9.13', Cache::get('clusev:latest-release:stable'));
+ }
+
+ public function test_check_update_command_warms_the_cache(): void
+ {
+ Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.20']])]);
+
+ $this->artisan('clusev:check-update')->assertSuccessful();
+
+ $this->assertSame('0.9.20', Cache::get('clusev:latest-release:stable'));
+ }
+
+ public function test_sidebar_shows_the_update_badge_only_when_an_update_is_available(): void
+ {
+ Http::fake(); // any stray check must not hit the network
+ config()->set('clusev.version', '0.9.10');
+ $this->actingAs(User::factory()->create(['must_change_password' => false]));
+
+ // No cached latest → no badge.
+ $this->get('/audit')->assertOk()->assertDontSee(__('shell.update_available'));
+
+ // Cached newer release → the sidebar Version item carries the badge.
+ Cache::put('clusev:latest-release:stable', '0.9.11', now()->addMinutes(30));
+ $this->get('/audit')->assertOk()->assertSee(__('shell.update_available'));
+ }
+}
diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php
index 9b55ea3..102a39a 100644
--- a/tests/Feature/VersionUpdateCheckTest.php
+++ b/tests/Feature/VersionUpdateCheckTest.php
@@ -71,6 +71,11 @@ class VersionUpdateCheckTest extends TestCase
->assertSee('Brandneues Feature XYZ')
->assertSee('Fehler ABC behoben')
->assertDontSee('Schon installiert'); // 0.9.4 == installed → excluded
+
+ // Tags are published as "vX.Y.Z" — the CHANGELOG.md must be fetched at the v-prefixed ref,
+ // not the bare version, or it 404s on a real host and the preview silently stays empty.
+ Http::assertSent(fn ($req) => ! str_contains($req->url(), 'raw/CHANGELOG.md')
+ || str_contains($req->url(), 'ref=v0.9.6'));
}
public function test_no_whats_new_preview_when_already_current(): void