CluPilotCloud/app/Livewire/Admin/Overview.php

300 lines
12 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 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(),
'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->format('Y-m'));
$labels = [];
$data = [];
for ($i = 0; $i < 12; $i++) {
$month = $start->copy()->addMonths($i);
$labels[] = $month->locale($locale)->isoFormat('MMM');
$data[] = (int) ($counts[$month->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();
}
/**
* 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])];
}
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])];
}
$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,
])];
}
// Distinct instances, not targets: one instance can be watched by
// several checks, and counting checks would report three outages where
// one machine is down.
$down = MonitoringTarget::query()->where('status', '!=', 'up')->distinct()->count('instance_id');
if ($down > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
}
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,
};
}
}