@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)
| {{-- 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 |
|---|
Nothing on this page matches
The refinement hid all rows here. Clear it, or press Enter in the search box — that searches every installation.
Showing {{ number_format($domains->firstItem()) }}–{{ number_format($domains->lastItem()) }} of {{ number_format($domains->total()) }} {{ Str::plural('installation', $domains->total()) }} · of shown on this page