625 lines
26 KiB
PHP
625 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Versions;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Services\DeploymentService;
|
|
use App\Services\ReleaseChecker;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Version & releases page. Everything shown here is real: the installed version comes from
|
|
* config, the latest published release is the newest git TAG in the channel — read from the
|
|
* project's public Git host over its read-only tag API (the production image ships no .git, so
|
|
* a local read would always be empty; local .git is only a dev fallback) — and the release
|
|
* history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
|
|
* (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
|
|
* the check honestly compares the installed version against the newest tag in the channel.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
/**
|
|
* 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
|
|
* watcher consumes before update.sh runs) — so the "update läuft" state survives BOTH a page
|
|
* reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
|
|
* backstop so a failed update can never wedge the banner. Completion = running version > base.
|
|
*/
|
|
private const UPDATE_FLAG = 'clusev:update-in-progress';
|
|
|
|
/** 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.
|
|
* 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. Synced to ?page= (omitted while 1). */
|
|
#[Url(as: 'page')]
|
|
public int $changelogPage = 1;
|
|
|
|
public ?string $lastChecked = null;
|
|
|
|
public ?string $updateStatus = null;
|
|
|
|
/** 'current' | 'update' | null — drives the banner tone. */
|
|
public ?string $updateState = null;
|
|
|
|
/** Set once an update has been requested this session — the stack is rebuilding. */
|
|
public bool $updateRequested = false;
|
|
|
|
/** Running version captured WHEN the update was requested — completion = version moved past it. */
|
|
public ?string $updateBaseVersion = null;
|
|
|
|
/** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
|
|
public int $updatePolls = 0;
|
|
|
|
/**
|
|
* On load, in order:
|
|
* 1. If an update is in flight (Redis marker): either announce it just finished (running
|
|
* version now past the marked base → success toast + clear) or resume the "läuft" state +
|
|
* poll. This is what makes a reload mid-update show the right thing (the marker lives in
|
|
* Redis, surviving both the reload and the app rebuild).
|
|
* 2. Otherwise surface an available update straight from the cached check (no network), so the
|
|
* button shows immediately when the badge already knows a newer version — no re-click.
|
|
*/
|
|
public function mount(): void
|
|
{
|
|
$installed = (string) config('clusev.version');
|
|
$marker = Cache::get(self::UPDATE_FLAG);
|
|
|
|
if (is_array($marker) && isset($marker['base'])) {
|
|
if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
|
|
// Finished while the operator was away (or reloaded) — announce + clear.
|
|
Cache::forget(self::UPDATE_FLAG);
|
|
$this->updateState = 'current';
|
|
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
|
|
$this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));
|
|
|
|
return;
|
|
}
|
|
// Still running — resume the notice + polling.
|
|
$this->updateRequested = true;
|
|
$this->updateBaseVersion = (string) $marker['base'];
|
|
|
|
return;
|
|
}
|
|
|
|
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
|
|
if ($latest !== null && $this->isNewer($latest, $installed)) {
|
|
$this->updateState = 'update';
|
|
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
|
|
* detected purely from the running version moving PAST the value captured at request time —
|
|
* the rebuilt container reports the new version, so no host round-trip is needed. Times out
|
|
* after ~5 min so a failed update surfaces a hint instead of spinning forever.
|
|
*/
|
|
public function pollUpdate(): void
|
|
{
|
|
if (! $this->updateRequested) {
|
|
return;
|
|
}
|
|
$this->updatePolls++;
|
|
$current = (string) config('clusev.version');
|
|
$this->updateBaseVersion ??= $current;
|
|
|
|
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
|
|
Cache::forget(self::UPDATE_FLAG);
|
|
$this->updateRequested = false;
|
|
$this->updatePolls = 0;
|
|
$this->updateState = 'current';
|
|
$this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
|
|
$this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
|
|
Cache::forget(self::UPDATE_FLAG);
|
|
$this->updateRequested = false;
|
|
$this->updatePolls = 0;
|
|
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-check on page load (wire:init) so an available update + button appear without the
|
|
* operator clicking "check" first. No-op while an update is running, so it never clobbers the
|
|
* "läuft"/poll state. Async (wire:init), so it never blocks the initial render.
|
|
*/
|
|
public function autoCheck(): void
|
|
{
|
|
if ($this->updateRequested) {
|
|
return;
|
|
}
|
|
|
|
$this->checkUpdates();
|
|
}
|
|
|
|
/**
|
|
* Honest update check: compare the installed version against the newest release
|
|
* tag visible in the channel. No external server, no stars, no CVE feed.
|
|
*/
|
|
public function checkUpdates(): void
|
|
{
|
|
$this->lastChecked = now()->format('H:i');
|
|
|
|
$channel = $this->channel();
|
|
$installed = (string) config('clusev.version');
|
|
// Force a fresh remote lookup — this is the explicit "check for updates" action.
|
|
$latest = $this->resolveLatestTag($channel, forceRemote: true);
|
|
|
|
if ($latest === null) {
|
|
$this->updateState = 'current';
|
|
$this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]);
|
|
} elseif ($this->isNewer($latest, $installed)) {
|
|
$this->updateState = 'update';
|
|
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
|
} else {
|
|
$this->updateState = 'current';
|
|
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Operator-triggered update: write the update sentinel a host watcher reacts to by running
|
|
* update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
|
|
* installed one is actually published in the channel (never fires blindly), is per-user
|
|
* throttled (auto-expiring — never a control-plane lockout) and audited. The container never
|
|
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
|
|
* say so and do not await a result.
|
|
*/
|
|
public function requestUpdate(DeploymentService $deployment): mixed
|
|
{
|
|
$channel = $this->channel();
|
|
$installed = (string) config('clusev.version');
|
|
$latest = $this->resolveLatestTag($channel, forceRemote: true);
|
|
|
|
// Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
|
|
$this->lastChecked = now()->format('H:i');
|
|
if ($latest === null || ! $this->isNewer($latest, $installed)) {
|
|
$this->updateState = 'current';
|
|
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
|
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
|
|
|
|
return null;
|
|
}
|
|
$this->updateState = 'update';
|
|
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
|
|
|
$key = 'update-request:'.Auth::id();
|
|
if (RateLimiter::tooManyAttempts($key, 3)) {
|
|
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
|
|
|
return null;
|
|
}
|
|
RateLimiter::hit($key, 600);
|
|
|
|
// Write the sentinel; if it can't be written (e.g. the bind-mounted dir isn't writable by
|
|
// the app user), surface that instead of a "running" state that would never resolve.
|
|
if (! $deployment->requestUpdate()) {
|
|
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
|
|
|
|
return null;
|
|
}
|
|
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
|
|
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
|
|
// moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
|
|
Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));
|
|
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => 'deploy.update_request',
|
|
'target' => $installed.' → '.$latest.' ('.$channel.')',
|
|
'ip' => request()->ip(),
|
|
]);
|
|
|
|
$this->updateRequested = true;
|
|
$this->updateBaseVersion = $installed; // completion = the running version moves past this
|
|
$this->updatePolls = 0;
|
|
$this->dispatch('notify', message: __('versions.update_started'));
|
|
|
|
// Redirect to the self-contained progress page BEFORE the container goes down.
|
|
// navigate:false forces a full browser navigation (the page must be Livewire-independent
|
|
// to survive the teardown — see resources/views/update-progress.blade.php).
|
|
$prev = url()->previous();
|
|
$parsedPrev = parse_url($prev, PHP_URL_PATH);
|
|
$returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
|
|
? $parsedPrev
|
|
: route('dashboard', absolute: false);
|
|
|
|
// Pass the pre-update version: the progress page finishes only when the running version
|
|
// moves past it (the old container answers /up throughout the build — see the page).
|
|
return $this->redirect(
|
|
route('update.progress', ['return' => $returnPath, 'from' => $installed]),
|
|
navigate: false,
|
|
);
|
|
}
|
|
|
|
/** Resolve the configured channel, clamped to the user-facing set. */
|
|
private function channel(): string
|
|
{
|
|
return app(ReleaseChecker::class)->channel();
|
|
}
|
|
|
|
/**
|
|
* Newest release tag for the channel. Prefers the public Git host's tag API (the
|
|
* production image ships NO .git, so a local read would always be empty there), and
|
|
* falls back to local .git tags for dev. The remote result is cached so a page render
|
|
* never makes a network call — only the explicit "check for updates" action does, by
|
|
* passing $forceRemote. Returns null when nothing usable is found anywhere.
|
|
*/
|
|
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
|
|
{
|
|
$checker = app(ReleaseChecker::class);
|
|
|
|
if ($forceRemote) {
|
|
$remote = $checker->refresh($channel);
|
|
if ($remote !== null) {
|
|
return $remote;
|
|
}
|
|
// Remote unreachable — fall through to whatever we last cached / can read locally.
|
|
}
|
|
|
|
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
|
|
}
|
|
|
|
/**
|
|
* Newest release tag from local .git, without needing the git binary. Reads loose refs
|
|
* under refs/tags plus packed-refs. Empty in the production image (no .git) — that is why
|
|
* the remote API is the primary source above.
|
|
*/
|
|
private function localLatestTag(string $channel): ?string
|
|
{
|
|
$root = base_path('.git');
|
|
$tags = [];
|
|
|
|
$dir = $root.'/refs/tags';
|
|
if (is_dir($dir)) {
|
|
foreach (scandir($dir) ?: [] as $entry) {
|
|
if ($entry !== '.' && $entry !== '..' && is_file($dir.'/'.$entry)) {
|
|
$tags[] = $entry;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
|
|
if (preg_match('#\srefs/tags/(\S+)$#', trim($line), $m)) {
|
|
$tags[] = $m[1];
|
|
}
|
|
}
|
|
|
|
return app(ReleaseChecker::class)->newestVersion($tags, $channel);
|
|
}
|
|
|
|
/** Is the given tag a newer semantic version than the installed one? */
|
|
private function isNewer(string $tag, string $installed): bool
|
|
{
|
|
return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>');
|
|
}
|
|
|
|
/**
|
|
* Resolve the deployed commit + branch. In production this comes from config (install.sh
|
|
* baked it into .env from the host's .git — the prod image ships none); in dev it falls back
|
|
* to a live .git read. Branch defaults to the channel only when nothing is known.
|
|
*/
|
|
private function build(): array
|
|
{
|
|
$sha = config('clusev.build.sha');
|
|
$branch = config('clusev.build.branch');
|
|
if ($sha || $branch) {
|
|
return ['sha' => $sha, 'branch' => $branch ?: $this->channel()];
|
|
}
|
|
|
|
$root = base_path('.git');
|
|
$head = @file_get_contents($root.'/HEAD');
|
|
|
|
if ($head === false) {
|
|
return ['sha' => null, 'branch' => $this->channel()];
|
|
}
|
|
|
|
$head = trim($head);
|
|
|
|
if (! str_starts_with($head, 'ref: ')) {
|
|
return ['sha' => substr($head, 0, 7), 'branch' => $this->channel()];
|
|
}
|
|
|
|
$ref = substr($head, 5);
|
|
$branch = preg_replace('#^refs/heads/#', '', $ref);
|
|
$sha = @file_get_contents($root.'/'.$ref);
|
|
|
|
if ($sha === false) {
|
|
// packed-refs fallback
|
|
foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
|
|
if (str_ends_with(trim($line), ' '.$ref)) {
|
|
$sha = explode(' ', trim($line))[0];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
|
|
}
|
|
|
|
/** Switch the visible major.minor series and jump back to its first page. */
|
|
public function selectSeries(string $series): void
|
|
{
|
|
$this->series = $series;
|
|
$this->changelogPage = 1;
|
|
}
|
|
|
|
/** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */
|
|
public function gotoChangelogPage(int $page): void
|
|
{
|
|
$this->changelogPage = max(1, $page);
|
|
}
|
|
|
|
/**
|
|
* Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series
|
|
* keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node,
|
|
* if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog
|
|
* show a compact series index + paginated detail instead of one endless list.
|
|
*
|
|
* @param array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}> $releases
|
|
* @return array<int, array{key:string, label:string, unreleased:bool, count:int, latest:?string, releases:array<int, array<string, mixed>>}>
|
|
*/
|
|
private function groupBySeries(array $releases): array
|
|
{
|
|
$buckets = [];
|
|
foreach ($releases as $rel) {
|
|
if ($rel['unreleased']) {
|
|
$key = 'unreleased';
|
|
} elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
|
|
$key = $m[1].'.'.$m[2];
|
|
} else {
|
|
$key = 'other';
|
|
}
|
|
$buckets[$key][] = $rel;
|
|
}
|
|
|
|
$series = [];
|
|
foreach ($buckets as $key => $rels) {
|
|
$dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels)));
|
|
$series[] = [
|
|
'key' => $key,
|
|
'label' => match ($key) {
|
|
'unreleased' => __('versions.unreleased'),
|
|
'other' => __('versions.series_other'),
|
|
default => $key,
|
|
},
|
|
'unreleased' => $key === 'unreleased',
|
|
'count' => count($rels),
|
|
'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest
|
|
'releases' => $rels,
|
|
];
|
|
}
|
|
|
|
// Unreleased first, then series by version descending, 'other' last.
|
|
usort($series, static function (array $a, array $b): int {
|
|
$rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1);
|
|
$ra = $rank($a);
|
|
$rb = $rank($b);
|
|
if ($ra !== $rb) {
|
|
return $ra <=> $rb;
|
|
}
|
|
|
|
return $ra === 1 ? version_compare($b['key'].'.0', $a['key'].'.0') : 0;
|
|
});
|
|
|
|
return $series;
|
|
}
|
|
|
|
/**
|
|
* Compact pagination window — the full range up to 7 pages, otherwise first + last plus a
|
|
* 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers.
|
|
*
|
|
* @return array<int, int|string>
|
|
*/
|
|
private function pageWindow(int $current, int $total): array
|
|
{
|
|
if ($total <= 7) {
|
|
return range(1, $total);
|
|
}
|
|
|
|
$pages = [1];
|
|
$from = max(2, $current - 1);
|
|
$to = min($total - 1, $current + 1);
|
|
if ($from > 2) {
|
|
$pages[] = '…';
|
|
}
|
|
for ($i = $from; $i <= $to; $i++) {
|
|
$pages[] = $i;
|
|
}
|
|
if ($to < $total - 1) {
|
|
$pages[] = '…';
|
|
}
|
|
$pages[] = $total;
|
|
|
|
return $pages;
|
|
}
|
|
|
|
/**
|
|
* Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
|
|
* version, optional date, and the changes grouped by Keep-a-Changelog
|
|
* subsection (Hinzugefügt / Geändert / Behoben / Sicherheit / …), newest
|
|
* first (Unreleased on top).
|
|
*
|
|
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
|
*/
|
|
private function releases(): array
|
|
{
|
|
$path = base_path('CHANGELOG.md');
|
|
if (! is_file($path)) {
|
|
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<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
|
*/
|
|
private function parseChangelog(string $content): array
|
|
{
|
|
$releases = [];
|
|
$current = null;
|
|
$group = null;
|
|
|
|
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) {
|
|
$releases[] = $current;
|
|
}
|
|
$version = trim($m[1]);
|
|
$unreleased = strcasecmp($version, 'Unreleased') === 0;
|
|
$current = [
|
|
'version' => $version,
|
|
'date' => isset($m[2]) ? trim($m[2]) : null,
|
|
'unreleased' => $unreleased,
|
|
'groups' => [],
|
|
];
|
|
$group = null;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($current === null) {
|
|
continue;
|
|
}
|
|
|
|
// Subsection header: "### Hinzugefügt"
|
|
if (preg_match('/^###\s+(.+?)\s*$/', $line, $m)) {
|
|
$group = trim($m[1]);
|
|
$current['groups'][$group] ??= [];
|
|
|
|
continue;
|
|
}
|
|
|
|
// Bullet line under the active subsection (handles wrapped continuations).
|
|
if ($group !== null && preg_match('/^\s*-\s+(.+?)\s*$/', $line, $m)) {
|
|
$current['groups'][$group][] = trim($m[1]);
|
|
|
|
continue;
|
|
}
|
|
if ($group !== null && $current['groups'][$group] !== []
|
|
&& preg_match('/^\s{2,}(\S.*)$/', $line, $m)) {
|
|
// Continuation of the previous bullet (Keep-a-Changelog wrapping).
|
|
$last = array_key_last($current['groups'][$group]);
|
|
$current['groups'][$group][$last] .= ' '.trim($m[1]);
|
|
}
|
|
}
|
|
|
|
if ($current !== null) {
|
|
$releases[] = $current;
|
|
}
|
|
|
|
// Drop empty nodes (e.g. a bare Unreleased) and link-reference tails.
|
|
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<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
|
*/
|
|
private function availableChangelog(string $installed, ?string $latestTag): array
|
|
{
|
|
if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) {
|
|
return [];
|
|
}
|
|
|
|
// Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v".
|
|
$content = app(ReleaseChecker::class)->fetchChangelog('v'.ltrim($latestTag, 'vV'));
|
|
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, '>'),
|
|
));
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$releases = $this->releases();
|
|
$seriesList = $this->groupBySeries($releases);
|
|
|
|
// Active series: keep the operator's choice while it still exists, else the newest.
|
|
$keys = array_column($seriesList, 'key');
|
|
$activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
|
|
$activeSeries = null;
|
|
foreach ($seriesList as $s) {
|
|
if ($s['key'] === $activeKey) {
|
|
$activeSeries = $s;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Paginate the active series' releases so a long history never becomes an endless scroll.
|
|
$activeReleases = $activeSeries['releases'] ?? [];
|
|
$totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
|
|
$page = min(max(1, $this->changelogPage), $totalPages);
|
|
$this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
|
|
$pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);
|
|
|
|
// Mark the series that holds the running version (a small "installed" cue in the rail).
|
|
$installed = ltrim((string) config('clusev.version'), 'vV');
|
|
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? $m[1].'.'.$m[2] : null;
|
|
|
|
// Passive (no network): cached remote result if a check has run, else local .git.
|
|
$latestTag = $this->resolveLatestTag($this->channel());
|
|
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an
|
|
// update, before the next check). Never present yourself as behind a release you've
|
|
// already passed — hide it until a fresh check resolves the true latest.
|
|
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
|
|
$latestTag = null;
|
|
}
|
|
|
|
return view('livewire.versions.index', [
|
|
'version' => config('clusev.version'),
|
|
'channel' => $this->channel(),
|
|
'repository' => config('clusev.repository'),
|
|
'license' => config('clusev.license'),
|
|
'build' => $this->build(),
|
|
'seriesList' => $seriesList,
|
|
'activeKey' => $activeKey,
|
|
'activeSeries' => $activeSeries,
|
|
'pagedReleases' => $pagedReleases,
|
|
'changelogPage' => $page,
|
|
'totalPages' => $totalPages,
|
|
'pageWindow' => $this->pageWindow($page, $totalPages),
|
|
'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'));
|
|
}
|
|
}
|