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). --}}