clusev/app/Livewire/Versions/Index.php

670 lines
29 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
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$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)) {
// Prefix the series key with a non-numeric 'v' so the ?series= URL value is never a
// bare decimal — Livewire's #[Url] otherwise type-juggles "0.10" → 0.1 (the trailing
// zero is lost), silently switching the selected series on reload. The label strips it.
$key = 'v'.$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 => ltrim($key, 'v'), // display the dotted series (the 'v' is only for the URL)
},
'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(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.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, '>'),
));
}
/**
* Links shown on the "Projekt" card. EVERY install lists the public open-core repo. The
* dev/release-control box ALSO lists its actual update source first (which may be the private
* upstream) — staging and stable never do, so a non-dev server can never expose a private source
* regardless of how its CLUSEV_REPOSITORY is set. (The update SOURCE is still
* config('clusev.repository'); this only governs the displayed links.)
*
* release_controls=true is the dev-box invariant: it also gates the dev-only /release page + the
* host release bridge, so a staging/stable box never has it on — and even if it did, that box's
* CLUSEV_REPOSITORY is the public repo anyway. The public link is built from the public slug
* ONLY, and a malformed slug (empty / slashed / a full URL) falls back to the canonical public
* repo — never to a private host.
*
* @return list<array{url: string, host: string, path: string}>
*/
private function projectLinks(): array
{
$slug = trim((string) config('clusev.public_slug'), '/');
if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) {
$slug = 'clusev/clusev';
}
$urls = ['https://github.com/'.$slug];
// Dev box only: ALSO surface the actual update source (e.g. the private upstream), shown first.
// Require a real URL (a parseable host) so a misconfigured/garbage source is dropped rather
// than rendered as an empty link — the public repo still shows.
if (config('clusev.release_controls')) {
$source = (string) config('clusev.repository');
if (parse_url($source, PHP_URL_HOST) !== null && ! in_array($source, $urls, true)) {
array_unshift($urls, $source);
}
}
return array_map(static fn (string $u): array => [
'url' => $u,
'host' => (string) parse_url($u, PHP_URL_HOST),
'path' => trim((string) parse_url($u, PHP_URL_PATH), '/'),
], $urls);
}
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) ? 'v'.$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(),
'repositories' => $this->projectLinks(),
'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'));
}
}