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>}> $releases * @return array>}> */ 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 */ 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>}> */ 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>}> */ 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>}> */ 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 * Gitea) — 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 */ 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 Gitea), 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) ? $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')); } }