fix(versions): show what's-new (v-prefixed ref) + sidebar update badge (v0.9.51)
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 <noreply@anthropic.com>feat/v1-foundation v0.9.51
parent
c6597be59e
commit
4b2aee3941
18
CHANGELOG.md
18
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._
|
_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
|
## [0.9.50] - 2026-06-21
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\ReleaseChecker;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the cached latest-release tag so the global sidebar update badge stays accurate without
|
||||||
|
* the operator first opening the Versions page. Anonymous + read-only against the public Git host.
|
||||||
|
*/
|
||||||
|
class CheckUpdate extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'clusev:check-update';
|
||||||
|
|
||||||
|
protected $description = 'Refresh the cached latest-release tag (keeps the sidebar update badge warm).';
|
||||||
|
|
||||||
|
public function handle(ReleaseChecker $checker): int
|
||||||
|
{
|
||||||
|
$tag = $checker->refresh();
|
||||||
|
$this->info($tag !== null ? "latest release: v{$tag}" : 'remote unreachable — keeping the previous cache');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
namespace App\Livewire\Versions;
|
namespace App\Livewire\Versions;
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
use App\Models\AuditEvent;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Services\DeploymentService;
|
use App\Services\DeploymentService;
|
||||||
|
use App\Services\ReleaseChecker;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
@ -25,9 +25,6 @@ use Livewire\Component;
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
class Index extends Component
|
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
|
* 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
|
* 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. */
|
/** Resolve the configured channel, clamped to the user-facing set. */
|
||||||
private function channel(): string
|
private function channel(): string
|
||||||
{
|
{
|
||||||
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
|
return app(ReleaseChecker::class)->channel();
|
||||||
|
|
||||||
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -269,58 +264,17 @@ class Index extends Component
|
||||||
*/
|
*/
|
||||||
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
|
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
|
||||||
{
|
{
|
||||||
$key = 'clusev:latest-release:'.$channel;
|
$checker = app(ReleaseChecker::class);
|
||||||
|
|
||||||
if ($forceRemote) {
|
if ($forceRemote) {
|
||||||
$remote = $this->fetchRemoteLatestTag($channel);
|
$remote = $checker->refresh($channel);
|
||||||
if ($remote !== null) {
|
if ($remote !== null) {
|
||||||
Cache::put($key, $remote, now()->addMinutes(30));
|
|
||||||
|
|
||||||
return $remote;
|
return $remote;
|
||||||
}
|
}
|
||||||
// Remote unreachable — fall through to whatever we last cached / can read locally.
|
// Remote unreachable — fall through to whatever we last cached / can read locally.
|
||||||
}
|
}
|
||||||
|
|
||||||
$cached = Cache::get($key);
|
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
|
||||||
if (is_string($cached) && $cached !== '') {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->localLatestTag($channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Latest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -348,37 +302,7 @@ class Index extends Component
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->newestVersion($tags, $channel);
|
return app(ReleaseChecker::class)->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<int, string> $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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is the given tag a newer semantic version than the installed one? */
|
/** Is the given tag a newer semantic version than the installed one? */
|
||||||
|
|
@ -624,7 +548,8 @@ class Index extends Component
|
||||||
return [];
|
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) {
|
if ($content === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the newest published release tag for the configured channel and tells whether the
|
||||||
|
* installed version is behind it. Shared by the Versions page (which forces a fresh check on the
|
||||||
|
* explicit action) and by the global sidebar update badge + the scheduled refresh (which read /
|
||||||
|
* warm a short-lived cache so no page render or sidebar ever makes a network call).
|
||||||
|
*
|
||||||
|
* Anonymous + read-only against the public Git host's tag API — no token ever leaves the panel.
|
||||||
|
*/
|
||||||
|
class ReleaseChecker
|
||||||
|
{
|
||||||
|
private const CHANNELS = ['stable', 'beta'];
|
||||||
|
|
||||||
|
/** Cache the resolved tag long enough that the scheduled refresh keeps it continuously warm. */
|
||||||
|
private const TTL_MINUTES = 60;
|
||||||
|
|
||||||
|
private function cacheKey(string $channel): string
|
||||||
|
{
|
||||||
|
return 'clusev:latest-release:'.$channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function channel(): string
|
||||||
|
{
|
||||||
|
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
|
||||||
|
|
||||||
|
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last resolved latest tag (no network). Null when unknown. */
|
||||||
|
public function cachedLatest(?string $channel = null): ?string
|
||||||
|
{
|
||||||
|
$value = Cache::get($this->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/<owner>/<repo>/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<int, string> $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// 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
|
// 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
|
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ return [
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Hilfe',
|
'nav_help' => 'Hilfe',
|
||||||
|
'update_available' => 'Update verfügbar',
|
||||||
|
|
||||||
// Sidebar — user / 2FA badge
|
// Sidebar — user / 2FA badge
|
||||||
'twofa_on' => '2FA aktiv',
|
'twofa_on' => '2FA aktiv',
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ return [
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Help',
|
'nav_help' => 'Help',
|
||||||
|
'update_available' => 'Update available',
|
||||||
|
|
||||||
// Sidebar — user / 2FA badge
|
// Sidebar — user / 2FA badge
|
||||||
'twofa_on' => '2FA on',
|
'twofa_on' => '2FA on',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@props(['icon', 'href' => '#', 'active' => false])
|
@props(['icon', 'href' => '#', 'active' => false, 'badge' => null])
|
||||||
<a href="{{ $href }}"
|
<a href="{{ $href }}"
|
||||||
@class([
|
@class([
|
||||||
'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors',
|
'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors',
|
||||||
|
|
@ -8,4 +8,8 @@
|
||||||
@if ($active) aria-current="page" @endif>
|
@if ($active) aria-current="page" @endif>
|
||||||
<x-icon :name="$icon" @class(['h-[18px] w-[18px] shrink-0', 'text-accent' => $active]) />
|
<x-icon :name="$icon" @class(['h-[18px] w-[18px] shrink-0', 'text-accent' => $active]) />
|
||||||
<span class="truncate">{{ $slot }}</span>
|
<span class="truncate">{{ $slot }}</span>
|
||||||
|
@if ($badge !== null && $badge !== '')
|
||||||
|
<span class="ml-auto inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-accent px-1 font-mono text-[10px] font-semibold tabular-nums text-void"
|
||||||
|
title="{{ __('shell.update_available') }}">{{ $badge }}</span>
|
||||||
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -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). --}}
|
{{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}
|
||||||
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"
|
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"
|
||||||
:class="{ '!translate-x-0': nav }"
|
:class="{ '!translate-x-0': nav }"
|
||||||
|
|
@ -34,7 +38,7 @@
|
||||||
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
|
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
|
||||||
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
|
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
|
||||||
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
|
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
|
||||||
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">{{ __('shell.nav_versions') }}</x-nav-item>
|
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null">{{ __('shell.nav_versions') }}</x-nav-item>
|
||||||
<x-nav-item icon="help-circle" href="/help" :active="request()->is('help*')">{{ __('shell.nav_help') }}</x-nav-item>
|
<x-nav-item icon="help-circle" href="/help" :active="request()->is('help*')">{{ __('shell.nav_help') }}</x-nav-item>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,6 @@ Schedule::command('clusev:prune-audit')->daily();
|
||||||
|
|
||||||
// Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale).
|
// Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale).
|
||||||
Schedule::command('clusev:wg-sample')->everyMinute();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ReleaseChecker;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared release lookup behind the Versions page, the sidebar update badge and the scheduled
|
||||||
|
* refresh. updateAvailable() is a pure cache read (no network); refresh() does the remote check.
|
||||||
|
*/
|
||||||
|
class ReleaseCheckerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_available_is_true_when_the_cache_holds_a_newer_tag(): void
|
||||||
|
{
|
||||||
|
config()->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,11 @@ class VersionUpdateCheckTest extends TestCase
|
||||||
->assertSee('Brandneues Feature XYZ')
|
->assertSee('Brandneues Feature XYZ')
|
||||||
->assertSee('Fehler ABC behoben')
|
->assertSee('Fehler ABC behoben')
|
||||||
->assertDontSee('Schon installiert'); // 0.9.4 == installed → excluded
|
->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
|
public function test_no_whats_new_preview_when_already_current(): void
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue