@extends('layouts.admin') @use('App\Models\LicenseLog') @use('App\Models\UpdateVersion') @use('App\Repositories\VersionRepository') @section('title', 'Domains') @section('heading', 'Installations & Domains') @section('subheading', 'Manage licensed domains, installations, and verification status.') @php $canManage = auth()->user()?->isOwner() ?? false; $search = (string) request('q', ''); $graceDays = (int) config('licensing.grace_period_days', 7); // The controller eager-loads customer and license but not the licence's // package, which the drawer shows. One extra query here beats twenty. $domains->getCollection()->loadMissing('license.package'); /* | Derived check-in state. | | next_verification_at is the appointment the SERVER gave the install on its | last check-in, so measuring against it is measuring against the system's | own expectation rather than a number invented for this page. Past that | date the install is late; past the grace period it has effectively gone | quiet, which is the same window the client uses before restricting access. */ $stateOf = function ($domain) use ($graceDays) { if ($domain->status === 'blocked') return 'blocked'; if ($domain->status === 'inactive') return 'inactive'; if ($domain->last_verification_at === null) return 'pending'; if ($domain->next_verification_at === null) return 'verified'; if ($domain->next_verification_at->isFuture()) return 'verified'; return $domain->next_verification_at->copy()->addDays($graceDays)->isFuture() ? 'overdue' : 'offline'; }; $stateCopy = [ 'verified' => ['Verified', 'Checked in within its scheduled window.'], 'overdue' => ['Overdue', 'Has missed its scheduled check-in but is still inside the grace period.'], 'offline' => ['Offline', 'Silent for longer than the grace period allows.'], 'pending' => ['Pending', 'Registered but has never checked in.'], 'inactive' => ['Inactive', 'The installation record is marked inactive.'], 'blocked' => ['Blocked', 'The installation record is blocked.'], ]; /* | The release the update API would actually serve a stable CMS install. Used | so the "Latest / Update available" badge can never disagree with what a | customer's installer is told. */ $latestCms = app(VersionRepository::class) ->latestForChannel(UpdateVersion::CHANNEL_STABLE, UpdateVersion::PRODUCT_CMS)?->version; $cmsOf = function ($domain) use ($latestCms) { if (! $domain->cms_version) { return ['tone' => 'muted', 'label' => 'Unknown', 'why' => 'This installation has not reported a version.', 'classes' => 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10']; } if (! $latestCms) { return ['tone' => 'muted', 'label' => 'Reported', 'why' => 'No published CMS release to compare against yet.', 'classes' => 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10']; } $comparison = version_compare($domain->cms_version, $latestCms); if ($comparison < 0) { return ['tone' => 'warn', 'label' => 'Update', 'why' => "v{$latestCms} is published and available to this install.", 'classes' => 'bg-amber-50 text-amber-700 ring-amber-500/25 dark:bg-amber-500/10 dark:text-amber-400 dark:ring-amber-500/30']; } if ($comparison > 0) { return ['tone' => 'muted', 'label' => 'Ahead', 'why' => "Newer than the latest published release (v{$latestCms}).", 'classes' => 'bg-blue-50 text-blue-700 ring-blue-500/25 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/30']; } return ['tone' => 'ok', 'label' => 'Latest', 'why' => 'Running the latest published release.', 'classes' => 'bg-emerald-50 text-emerald-700 ring-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:ring-emerald-500/30']; }; /* | Audit entries for the installations on this page, in ONE bounded query, | grouped in PHP. The drawer needs real history; N queries for N drawers | that may never be opened is not the way to get it. */ $logsByDomain = $domains->isEmpty() ? collect() : LicenseLog::query() ->whereIn('domain_id', $domains->pluck('id')) ->latest('created_at') ->limit(300) ->get(['id', 'domain_id', 'event', 'ip_address', 'message', 'created_at']) ->groupBy('domain_id') ->map(fn ($entries) => $entries->take(8)->map(fn ($log) => [ 'id' => $log->id, 'event' => $log->event, 'label' => ucfirst(str_replace('_', ' ', $log->event)), 'ip' => $log->ip_address, 'message' => $log->message, 'at' => $log->created_at?->format('d M Y H:i'), 'ago' => $log->created_at?->diffForHumans(short: true), ])->values()); // The single model the drawer, the refinement, the sort and the CSV export // all read from, so none of them has to scrape the table out of the DOM. $rows = $domains->map(function ($domain) use ($stateOf, $stateCopy, $cmsOf) { $state = $stateOf($domain); $cms = $cmsOf($domain); $license = $domain->license; return [ 'id' => $domain->id, 'domain' => $domain->domain, 'customer' => $domain->customer?->company_name, 'email' => $domain->customer?->email, 'ip' => $domain->current_ip, 'cms' => $domain->cms_version, 'cmsTone' => $cms['tone'], 'cmsLabel' => $domain->cms_version ? $cms['label'] : null, 'status' => $domain->status, 'state' => $state, 'stateLabel' => $stateCopy[$state][0], 'stateWhy' => $stateCopy[$state][1], 'verifiedAt' => $domain->last_verification_at?->toIso8601String(), 'verifiedLabel' => $domain->last_verification_at?->format('d M Y H:i'), 'nextAt' => $domain->next_verification_at?->toIso8601String(), 'nextLabel' => $domain->next_verification_at?->format('d M Y H:i'), 'installedAt' => $domain->installed_at?->toIso8601String(), 'installedLabel' => $domain->installed_at?->format('d M Y'), 'created' => $domain->created_at?->toIso8601String(), 'licenseKey' => $license?->license_key, 'licenseState' => $license?->effectiveStatus(), 'licenseUrl' => $license ? route('licenses.show', $license) : null, 'package' => $license?->package?->name, 'expiresLabel' => $license?->expires_at?->format('d M Y'), 'logsUrl' => route('logs.index', ['q' => $domain->domain]), ]; })->values(); $customerNames = $rows->pluck('customer')->filter()->unique()->sort()->values(); $cmsVersions = $rows->pluck('cms')->filter()->unique()->sortDesc()->values(); @endphp @section('header-actions') {{-- Installations are not created from here. A domain row is written by the activation API when a customer's install redeems a key — a row created by hand would have no install secret and could never verify. So the primary action is the step that actually leads to one. --}} @if ($canManage) New @endif @endsection @section('content')
{{-- ── Statistics ──────────────────────────────────────────────── --}} {{-- ── Toolbar ─────────────────────────────────────────────────── --}}
{{-- Typing narrows what is on screen; Enter asks the server. The hint under the field says exactly that, because the two cover different amounts of data. --}} @if (filled($search)) Reset @endif {{-- View controls carry no `name`, so submitting the form never turns a view preference into a query string. --}}
@if ($customerNames->count() > 1) @endif @if ($cmsVersions->count() > 1) @endif Export this view Export selected
{{-- ── Quick filters ───────────────────────────────────────── --}}
@foreach ([ ['verified', 'Verified', 'check-circle'], ['pending', 'Pending', 'clock'], ['overdue', 'Overdue', 'alert'], ['offline', 'Offline', 'x-circle'], ] as [$value, $label, $icon]) @endforeach @foreach ([['today', 'Today'], ['7', 'Last 7 days'], ['30', 'Last 30 days']] as [$value, $label]) @endforeach
{{-- ── Bulk actions ────────────────────────────────────────────── --}} {{-- Installations have no state-changing endpoints of their own, so a selection is for getting data out, not for acting on rows. Every action offered here is one this page can genuinely perform. --}}
selected
{{-- ── Table ───────────────────────────────────────────────────── --}}
@for ($i = 0; $i < 7; $i++)
@endfor
@if ($domains->isEmpty())
@if (filled($search)) Clear search @endif @if ($canManage) Issue License @endif
@else
@foreach ($domains as $domain) @include('domains.partials.row', [ 'domain' => $domain, 'state' => $stateOf($domain), 'cms' => $cmsOf($domain), 'canManage' => $canManage, ]) @endforeach
{{-- indeterminate exists only as a DOM property, so it has to be assigned, not bound. --}} Domain Customer License CMS Version Current IP Last Verification Status Actions
{{-- Every row on this page was refined away. The server still has results, so this is not the empty state. --}}

Nothing on this page matches

The refinement hid all rows here. Clear it, or press Enter in the search box — that searches every installation.

{{-- ── Footer ──────────────────────────────────────────── --}}

Showing {{ number_format($domains->firstItem()) }}–{{ number_format($domains->lastItem()) }} of {{ number_format($domains->total()) }} {{ Str::plural('installation', $domains->total()) }} · of shown on this page

! $domains->previousPageUrl()]) aria-label="Previous page"> @php // A window around the current page. A hundred numbered // links is not navigation. $last = $domains->lastPage(); $current = $domains->currentPage(); $from = max(1, min($current - 2, $last - 4)); $to = min($last, max($current + 2, 5)); @endphp @if ($from > 1) 1 @if ($from > 2)…@endif @endif @for ($page = $from; $page <= $to; $page++) $page === $current, ]) @if ($page === $current) aria-current="page" @endif>{{ $page }} @endfor @if ($to < $last) @if ($to < $last - 1)…@endif {{ $last }} @endif ! $domains->nextPageUrl()]) aria-label="Next page">
@endif
@include('domains.partials.details') {{-- ── Toast ───────────────────────────────────────────────────── --}}
@endsection