@extends('layouts.admin') @use('App\Models\UpdateLog') @use('App\Models\UpdateVersion') @use('App\Repositories\VersionRepository') @section('title', 'Versions') @section('heading', 'CMS Versions & Updates') @section('subheading', 'Manage releases, update packages and deployment channels.') @php $canManage = auth()->user()?->isOwner() ?? false; // allForAdmin() counts logs but does not load the author, which the drawer // shows. One query here beats one per release. $versions->loadMissing('creator'); /* | Update activity, in ONE grouped query, keyed by version then status. | | withCount('logs') on the collection already counts EVERY log row, which is | mostly routine update checks — using that as "downloads" would overstate | uptake by an order of magnitude. This breakdown is what lets the page say | downloaded, installed and failed separately, and mean them. */ $activity = UpdateLog::query() ->selectRaw('update_version_id, status, COUNT(*) as total') ->whereNotNull('update_version_id') ->groupBy('update_version_id', 'status') ->get() ->groupBy('update_version_id') ->map(fn ($rows) => $rows->pluck('total', 'status')); $countsFor = fn ($version) => $activity->get($version->id, collect()); // The release the update API would actually serve a stable CMS install — // the same call the API makes, so the "Latest" badge cannot disagree with it. $latest = app(VersionRepository::class)->latestForChannel(UpdateVersion::CHANNEL_STABLE, UpdateVersion::PRODUCT_CMS); $latestId = $latest?->id; $maxDownloads = (int) $versions->max(fn ($v) => (int) ($countsFor($v)['downloaded'] ?? 0)); // The single model the drawer, the filters, the sort and the CSV export all // read from, so none of them has to scrape the table out of the DOM. $rows = $versions->map(function ($version) use ($countsFor, $canManage) { $counts = $countsFor($version); return [ 'id' => $version->id, 'version' => $version->version, 'name' => $version->release_name, 'product' => $version->product ?: UpdateVersion::PRODUCT_CMS, 'channel' => $version->channel, 'status' => $version->status, 'type' => $version->package_type, 'notes' => $version->release_notes, 'minSupported' => $version->min_supported_version, 'security' => (bool) $version->security_update, 'force' => (bool) $version->force_update, 'hasPackage' => $version->hasPackage(), 'signed' => filled($version->signature), 'fileName' => $version->hasPackage() ? $version->downloadName() : null, 'size' => (int) $version->file_size, 'checksum' => $version->checksum_sha256, 'author' => $version->creator?->name, 'publishedAt' => $version->published_at?->toIso8601String(), 'checks' => (int) ($counts['checked'] ?? 0), 'downloads' => (int) ($counts['downloaded'] ?? 0), 'installed' => (int) ($counts['installed'] ?? 0), 'failed' => (int) ($counts['failed'] ?? 0), 'showUrl' => route('versions.show', $version), 'editUrl' => $canManage ? route('versions.edit', $version) : null, // Only the moments this record actually stores. No invented steps. 'timeline' => [ ['label' => 'Created', 'at' => $version->created_at?->format('d M Y H:i'), 'done' => (bool) $version->created_at], ['label' => 'Release date', 'at' => $version->release_date?->format('d M Y'), 'done' => (bool) $version->release_date], ['label' => 'Package attached', 'at' => $version->hasPackage() ? $version->updated_at?->format('d M Y H:i') : null, 'done' => $version->hasPackage()], ['label' => 'Signed', 'at' => filled($version->signature) ? 'Signature attached' : null, 'done' => filled($version->signature)], ['label' => 'Published', 'at' => $version->published_at?->format('d M Y H:i'), 'done' => (bool) $version->published_at], ], ]; })->values(); $blank = new UpdateVersion([ 'product' => UpdateVersion::PRODUCT_CMS, 'channel' => UpdateVersion::CHANNEL_STABLE, 'package_type' => 'full', ]); @endphp @section('header-actions') @if ($canManage) {{-- The layout renders this outside the page component, so the click dispatches an event that bubbles to window instead of calling the method directly. Still a real link with JavaScript off. --}} New @endif @endsection @section('content')
{{-- ── Statistics ──────────────────────────────────────────────── --}} {{-- ── Toolbar ─────────────────────────────────────────────────── --}}
Export this view Export selected
{{-- ── Quick filters ───────────────────────────────────────── --}}
@foreach ([['stable', 'Stable'], ['beta', 'Beta']] as [$value, $label]) @endforeach @foreach ([ ['security', 'Security', 'shield'], ['force', 'Force', 'alert'], ['unsigned', 'Unsigned', 'lock'], ] as [$value, $label, $icon]) @endforeach @foreach ([ ['published', 'Published', 'rocket'], ['hidden', 'Hidden', 'eye'], ['draft', 'Draft', 'edit'], ['archived', 'Archived', 'archive'], ] as [$value, $label, $icon]) @endforeach
{{-- ── Bulk actions ────────────────────────────────────────────── --}} @if ($canManage)
selected
{{-- One request per release, and publish can legitimately refuse. The count moving is the difference between "working" and "frozen"; the reload afterwards carries the reason. --}}
Applying… of
@endif {{-- ── Table ───────────────────────────────────────────────────── --}}
@for ($i = 0; $i < 7; $i++)
@endfor
@if ($versions->isEmpty())
@if ($canManage)
@endif
@else
@foreach ($versions as $version) @include('versions.partials.row', [ 'version' => $version, 'counts' => $countsFor($version), 'isLatest' => $version->id === $latestId, 'maxDownloads' => $maxDownloads, 'canManage' => $canManage, ]) @endforeach
{{-- indeterminate exists only as a DOM property, so it has to be assigned, not bound. --}} Version Release Notes Channel Package Flags Status Published Downloads Actions

No releases match these filters

Nothing in the catalogue fits. Widen the search, or clear the filters to see all {{ $versions->count() }}.

{{-- ── Footer ──────────────────────────────────────────── --}}
Rows Showing of filtered from {{ $versions->count() }} {{ Str::plural('release', $versions->count()) }}
@endif
@include('versions.partials.details') @if ($canManage) @include('versions.partials.upload-modal', ['version' => $blank]) @endif {{-- ── Toast ───────────────────────────────────────────────────── --}}
@endsection