399 lines
16 KiB
PHP
399 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Livewire\Concerns\BuildsRunSteps;
|
|
use App\Models\Customer;
|
|
use App\Models\Host;
|
|
use App\Models\Instance;
|
|
use App\Models\MonitoringTarget;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use App\Services\Provisioning\HostCapacity;
|
|
use App\Support\Readiness;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Number;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* The console's front page — every figure on it comes from the database.
|
|
*
|
|
* It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts
|
|
* named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating
|
|
* business and reported nothing. Anything without a real source has been
|
|
* removed rather than approximated — the revenue trend line especially, because
|
|
* there is no revenue history to draw it from, and a plausible line is worse
|
|
* than no line.
|
|
*/
|
|
#[Layout('layouts.admin')]
|
|
class Overview extends Component
|
|
{
|
|
// current_step is an index into the pipeline, not a name. The provisioning
|
|
// page already owns the translation from one to the other; sharing it keeps
|
|
// the two pages from naming the same step differently.
|
|
use BuildsRunSteps;
|
|
|
|
/** A host is presumed missing once it has not checked in for this long. */
|
|
private const HOST_SILENT_AFTER_MINUTES = 30;
|
|
|
|
public function render()
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
$customersOpen = Customer::query()->where('status', '!=', 'closed')->whereNull('closed_at')->count();
|
|
$customersSuspended = Customer::query()->where('status', 'suspended')->count();
|
|
|
|
$instancesByStatus = Instance::query()
|
|
->selectRaw('status, count(*) as n')
|
|
->groupBy('status')
|
|
->pluck('n', 'status');
|
|
|
|
$hostsByStatus = Host::query()
|
|
->selectRaw('status, count(*) as n')
|
|
->groupBy('status')
|
|
->pluck('n', 'status');
|
|
|
|
return view('livewire.admin.overview', [
|
|
'kpis' => [
|
|
[
|
|
'label' => __('admin.kpi.customers'),
|
|
'value' => (string) $customersOpen,
|
|
'sub' => $customersSuspended > 0
|
|
? __('admin.kpi.customers_suspended', ['n' => $customersSuspended])
|
|
: null,
|
|
],
|
|
[
|
|
'label' => __('admin.kpi.instances'),
|
|
'value' => (string) ($instancesByStatus['active'] ?? 0),
|
|
'sub' => __('admin.kpi.instances_of', ['n' => $instancesByStatus->sum()]),
|
|
],
|
|
[
|
|
'label' => __('admin.kpi.hosts'),
|
|
'value' => (string) ($hostsByStatus['active'] ?? 0),
|
|
'sub' => __('admin.kpi.hosts_of', ['n' => $hostsByStatus->sum()]),
|
|
],
|
|
[
|
|
'label' => __('admin.kpi.mrr'),
|
|
'value' => $this->monthlyRevenue($locale),
|
|
'sub' => __('admin.kpi.mrr_sub'),
|
|
],
|
|
],
|
|
'newInstances' => $this->newInstancesPerMonth($locale),
|
|
'hostLoad' => $this->hostLoad(),
|
|
'runs' => $this->openRuns(),
|
|
'delivery' => $this->delivery(),
|
|
'notices' => $notices = $this->notices(),
|
|
'noticeCount' => count($notices),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* What the estate bills per month, net.
|
|
*
|
|
* Off the contracts, not the catalogue: a price rise must not inflate
|
|
* reported revenue for every grandfathered customer overnight. Add-ons
|
|
* included and yearly terms divided — totalMonthlyCents() owns both rules.
|
|
*
|
|
* Grouped by currency and never summed across them. Adding francs to euros
|
|
* produces a number that is wrong in a way nobody notices.
|
|
*/
|
|
private function monthlyRevenue(string $locale): string
|
|
{
|
|
$byCurrency = [];
|
|
|
|
Subscription::query()
|
|
->where('status', 'active')
|
|
->with('addons')
|
|
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
|
|
foreach ($subscriptions as $subscription) {
|
|
$currency = strtoupper((string) $subscription->currency);
|
|
$byCurrency[$currency] = ($byCurrency[$currency] ?? 0) + $subscription->totalMonthlyCents();
|
|
}
|
|
});
|
|
|
|
if ($byCurrency === []) {
|
|
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
|
|
}
|
|
|
|
return collect($byCurrency)
|
|
->map(fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale))
|
|
->implode(' · ');
|
|
}
|
|
|
|
/**
|
|
* Instances created per month over the last twelve.
|
|
*
|
|
* Deliberately "created", not "in operation": reconstructing how many were
|
|
* running at the end of some past month would need a history nobody keeps,
|
|
* and the honest version of that chart is this one with a truthful label.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function newInstancesPerMonth(string $locale): array
|
|
{
|
|
$start = Carbon::now()->startOfMonth()->subMonths(11);
|
|
|
|
$counts = Instance::query()
|
|
->where('created_at', '>=', $start)
|
|
->get(['created_at'])
|
|
->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m'));
|
|
|
|
$labels = [];
|
|
$data = [];
|
|
|
|
for ($i = 0; $i < 12; $i++) {
|
|
$month = $start->copy()->addMonths($i);
|
|
$labels[] = $month->local()->locale($locale)->isoFormat('MMM');
|
|
$data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0);
|
|
}
|
|
|
|
return [
|
|
'empty' => array_sum($data) === 0,
|
|
'config' => [
|
|
'type' => 'bar',
|
|
'data' => [
|
|
'labels' => $labels,
|
|
'datasets' => [[
|
|
'label' => __('admin.new_instances'),
|
|
'data' => $data,
|
|
'backgroundColor' => 'token:accent',
|
|
'borderRadius' => 3,
|
|
'maxBarThickness' => 26,
|
|
]],
|
|
],
|
|
'options' => [
|
|
'scales' => [
|
|
'x' => ['grid' => ['display' => false]],
|
|
// Whole instances only — a y-axis offering "1.5 instances"
|
|
// is the giveaway that a chart was never looked at.
|
|
'y' => ['beginAtZero' => true, 'ticks' => ['precision' => 0], 'grid' => ['color' => 'token:border']],
|
|
],
|
|
'plugins' => ['legend' => ['display' => false]],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Storage committed on each host, against the capacity placement may use.
|
|
*
|
|
* Deliberately the model's own committedGb()/freeGb()/usedPct() rather than
|
|
* a quicker sum here. Placement counts the VM disk allocation, ignores a
|
|
* failed instance that never got a VM, and subtracts the host's reserve —
|
|
* a dashboard doing its own arithmetic would show a host as comfortable
|
|
* while placement was already refusing to put anything on it, and the
|
|
* disagreement would only surface when an order failed.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function hostLoad(): array
|
|
{
|
|
return Host::query()
|
|
// Preloaded so listing hosts is one query, not one per host.
|
|
->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb')
|
|
->orderBy('name')
|
|
->get()
|
|
->map(function (Host $host) {
|
|
$usable = $host->freeGb();
|
|
$committed = $host->committedGb();
|
|
// Same ratio usedPct() states, computed from the two values
|
|
// already in hand rather than asking the host to sum again.
|
|
$pct = $usable > 0 ? min(100, (int) round($committed / $usable * 100)) : 0;
|
|
|
|
return [
|
|
'name' => $host->name,
|
|
'pct' => $pct,
|
|
'detail' => $usable > 0 ? "{$committed} / {$usable} GB" : __('admin.host_no_capacity'),
|
|
'level' => match (true) {
|
|
$usable === 0 => 'unknown',
|
|
$pct >= 90 => 'high',
|
|
$pct >= 75 => 'warn',
|
|
default => 'ok',
|
|
},
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* What each package can be promised today, and who is waiting.
|
|
*
|
|
* The same question the price sheet and the customer's own overview ask,
|
|
* answered from the same place — an operator must be able to see on the
|
|
* front page what a visitor is being told, without opening the shop in
|
|
* another tab. A package with no host big enough is not unsellable: the
|
|
* order is taken, parked, and rolled out once a machine arrives, which is
|
|
* exactly what "bereitgestellt in 2-3 Werktagen" means.
|
|
*
|
|
* @return array{plans: array<int, array{name: string, needs: int, state: string}>, queued: int}
|
|
*/
|
|
private function delivery(): array
|
|
{
|
|
$capacity = app(HostCapacity::class);
|
|
|
|
try {
|
|
$sellable = app(PlanCatalogue::class)->sellable();
|
|
} catch (\Throwable) {
|
|
// A catalogue that cannot be read has its own alarm. It does not
|
|
// get to take the console's front page down with it.
|
|
$sellable = [];
|
|
}
|
|
|
|
$plans = [];
|
|
|
|
foreach ($sellable as $key => $plan) {
|
|
$needs = (int) ($plan['disk_gb'] ?? 0);
|
|
|
|
$plans[] = [
|
|
'name' => (string) ($plan['name'] ?? $key),
|
|
'needs' => $needs,
|
|
'state' => $capacity->deliveryFor($needs),
|
|
];
|
|
}
|
|
|
|
return ['plans' => $plans, 'queued' => $capacity->queueDemand()['count']];
|
|
}
|
|
|
|
/**
|
|
* Provisioning runs that have not finished.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function openRuns(): array
|
|
{
|
|
return ProvisioningRun::query()
|
|
->whereIn('status', [
|
|
ProvisioningRun::STATUS_PENDING,
|
|
ProvisioningRun::STATUS_RUNNING,
|
|
ProvisioningRun::STATUS_WAITING,
|
|
ProvisioningRun::STATUS_PAUSED,
|
|
])
|
|
// Nested through the morph, so naming the customer does not cost a
|
|
// query per run.
|
|
->with(['subject' => fn ($morph) => $morph->morphWith([Instance::class => ['customer']])])
|
|
->latest('id')
|
|
->limit(6)
|
|
->get()
|
|
->map(fn (ProvisioningRun $run) => [
|
|
'subject' => $this->describeSubject($run),
|
|
'step' => $this->currentStepLabel($run),
|
|
'status' => $run->status,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Everything currently wrong, from the records that know it.
|
|
*
|
|
* There is no invented alert here. If this list is empty, nothing in the
|
|
* database is reporting a problem — which is a statement worth being able
|
|
* to trust.
|
|
*
|
|
* @return array<int, array<string, string>>
|
|
*/
|
|
private function notices(): array
|
|
{
|
|
$notices = [];
|
|
|
|
$failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
|
|
if ($failedRuns > 0) {
|
|
$notices[] = [
|
|
'level' => 'warning',
|
|
'text' => __('admin.notice.failed_runs', ['n' => $failedRuns]),
|
|
// Every notice carries the page that shows the thing it is
|
|
// about. A warning an operator cannot act on from where they
|
|
// read it is a warning they go looking for by hand, and the
|
|
// count in the header was not even a link to the list.
|
|
'route' => 'admin.provisioning',
|
|
];
|
|
}
|
|
|
|
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
|
|
$notices[] = [
|
|
'level' => 'warning',
|
|
'text' => __('admin.notice.host_error', ['host' => $name]),
|
|
'route' => 'admin.hosts',
|
|
];
|
|
}
|
|
|
|
$silent = Host::query()
|
|
->where('status', 'active')
|
|
->where(fn ($q) => $q
|
|
->whereNull('last_seen_at')
|
|
->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
|
|
->pluck('name');
|
|
foreach ($silent as $name) {
|
|
$notices[] = [
|
|
'level' => 'warning',
|
|
'text' => __('admin.notice.host_silent', [
|
|
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
|
|
]),
|
|
'route' => 'admin.hosts',
|
|
];
|
|
}
|
|
|
|
// Distinct instances, not targets: one instance can be watched by
|
|
// several checks, and counting checks would report three outages where
|
|
// one machine is down. And only verdicts the sync job has refreshed —
|
|
// an unchecked row is not a healthy one, nor a failing one.
|
|
$down = MonitoringTarget::query()
|
|
->where('status', 'down')
|
|
->where('checked_at', '>=', Carbon::now()->subMinutes(20))
|
|
->distinct()
|
|
->count('instance_id');
|
|
if ($down > 0) {
|
|
$notices[] = [
|
|
'level' => 'warning',
|
|
'text' => __('admin.notice.monitoring_down', ['n' => $down]),
|
|
'route' => 'admin.instances',
|
|
];
|
|
}
|
|
|
|
// Capacity, before an order finds out.
|
|
//
|
|
// The platform books thick: a package reserves its whole `disk_gb` on
|
|
// one host at placement. So the catalogue can go on offering a package
|
|
// no host has room for, and the first anyone hears of it is a PAID
|
|
// order failing at the reservation step — after the money.
|
|
//
|
|
// Listed per package with the shortfall, because "buy a server" is not
|
|
// the decision; "buy a server big enough for the package I am selling"
|
|
// is.
|
|
foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) {
|
|
$notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [
|
|
'plan' => $plan['name'],
|
|
'needs' => $plan['needs'],
|
|
'largest' => $plan['largest'],
|
|
])];
|
|
}
|
|
|
|
// What is not yet set up at all, as opposed to everything above (what
|
|
// IS set up but currently unhealthy). Readiness::blocking() is the
|
|
// same list App\Livewire\Admin\Readiness renders in full — this is
|
|
// only the count and a link to it, not a second copy of the reasons.
|
|
$blockingReadiness = count(Readiness::blocking());
|
|
if ($blockingReadiness > 0) {
|
|
$notices[] = [
|
|
'level' => 'warning',
|
|
'text' => trans_choice('admin.notice.readiness_blocking', $blockingReadiness, ['n' => $blockingReadiness]),
|
|
'route' => 'admin.readiness',
|
|
];
|
|
}
|
|
|
|
return $notices;
|
|
}
|
|
|
|
private function describeSubject(ProvisioningRun $run): string
|
|
{
|
|
$subject = $run->subject;
|
|
|
|
return match (true) {
|
|
$subject instanceof Instance => $subject->customer?->name ?? $subject->subdomain ?? '—',
|
|
$subject instanceof Host => $subject->name,
|
|
default => class_basename($run->subject_type ?? '').' #'.$run->subject_id,
|
|
};
|
|
}
|
|
}
|