CluPilotCloud/app/Livewire/Dashboard.php

374 lines
14 KiB
PHP

<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The customer's Betriebsblatt: what they have, and the evidence that it is
* being looked after.
*
* Every figure on this page comes from a record. Where something is not
* measured yet — storage consumption, restore tests — the page says nothing
* rather than showing a plausible number: this is the sheet a customer forwards
* to their auditor, and one invented line on it is worse than a short register.
*/
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
use ResolvesCustomer;
public function render()
{
$customer = $this->customer();
// The instance that is actually in service. A cancelled one still has
// rows, and would otherwise keep filling this page after it is gone.
$instance = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
$contract = $instance?->subscription;
$maintenance = MaintenanceWindow::forInstance($instance)->first();
$domain = $this->domain($instance);
return view('livewire.dashboard', [
'customer' => $customer,
'instance' => $instance,
'contract' => $contract,
'domain' => $domain,
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
// The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
// fortnight, and drawing one at zero would tell its owner their
// data had vanished.
'disk' => $instance !== null ? $this->disk($instance) : null,
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
'seatBreakdown' => $this->seatBreakdown($customer),
'trend' => $instance !== null
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
: [],
'proofs' => $this->proofs($instance, $maintenance),
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
// What the plan costs is true whether or not another invoice is
// coming — a customer who has given notice still pays until the
// term ends and still needs the figure on their master record.
'planPrice' => $contract === null ? null : [
'cents' => (int) $contract->price_cents,
'currency' => (string) ($contract->currency ?: 'EUR'),
'term' => (string) $contract->term,
],
'nextInvoice' => $this->nextInvoice($contract, $instance),
// The initial admin password, if it is still waiting to be
// acknowledged. Read straight off $instance (already scoped to
// THIS customer above) rather than a fresh unscoped lookup, and
// never stored as a public property — see acknowledgeCredentials().
'credentials' => $this->credentials($instance, $domain),
'asOf' => Carbon::now(),
]);
}
/**
* Deletes the instance's stored admin password and stamps the
* acknowledgement — the customer has confirmed they noted it down.
*
* Re-resolves both the customer AND the instance from the authenticated
* session rather than trusting $uuid alone: this method is reachable by
* anyone who can post to /livewire/update, not only through this card's
* button, so ownership is checked here again rather than relying on the
* card simply not being shown to anyone else.
*/
public function acknowledgeCredentials(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->where('uuid', $uuid)->whereNotNull('admin_password')->first();
if ($instance === null) {
return;
}
$instance->update([
'admin_password' => null,
'credentials_acknowledged_at' => now(),
]);
$this->dispatch('notify', message: __('dashboard.credentials.acknowledged'));
}
/**
* @return array{uuid: string, url: string, user: string, password: string}|null
*/
private function credentials(?Instance $instance, ?string $domain): ?array
{
if ($instance === null || $instance->admin_password === null) {
return null;
}
return [
'uuid' => $instance->uuid,
'url' => 'https://'.($domain ?: $instance->subdomain),
'user' => (string) $instance->nc_admin_ref,
'password' => $instance->admin_password,
];
}
/**
* Who holds the seats, so the card can say something true underneath the
* figure rather than repeating it.
*
* @return array<string, int> role => count, roles with nobody omitted
*/
private function seatBreakdown(?Customer $customer): array
{
if ($customer === null) {
return [];
}
return Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->selectRaw('role, COUNT(*) as n')
->groupBy('role')
->pluck('n', 'role')
->filter()
->all();
}
/**
* How full the instance is, as measured — not as sold.
*
* Null until the sampler has managed a reading. The card then states the
* contractual allowance, which is true, rather than a ring at a level
* nobody measured.
*
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
*/
private function disk(Instance $instance): ?array
{
$metric = InstanceMetric::latestDisk($instance);
if ($metric === null || ! $metric->disk_total_bytes) {
return null;
}
// What it grew by this week, which is the line the template carries
// under the ring. Null when there is no reading a week back to compare
// against — an instance three days old has no weekly trend, and
// inventing "+0 GB" would read as "nothing happened".
$weekAgo = InstanceMetric::query()
->where('instance_id', $instance->id)
->whereNotNull('disk_used_bytes')
->where('day', '<=', now()->subDays(7)->toDateString())
->orderByDesc('day')
->first();
return [
'used' => $metric->disk_used_bytes,
'total' => $metric->disk_total_bytes,
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
];
}
/** The address the customer actually reaches their cloud at. */
private function domain(?Instance $instance): ?string
{
if ($instance === null) {
return null;
}
return $instance->custom_domain
?: ($instance->subdomain
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
: null);
}
/**
* Where the data physically sits.
*
* Named from the datacenter register rather than from a translation string,
* because this is the line a customer copies into a processing record — it
* has to be the place their instance really runs on, not the place we
* usually sell.
*
* @return array{name: string, note: null}|null
*/
private function location(?Instance $instance): ?array
{
$code = $instance?->host?->datacenter;
if ($code === null || $code === '') {
return null;
}
$datacenter = Datacenter::query()->where('code', $code)->first();
// The country, not the site. "Falkenstein" is how an operator places an
// instance; a customer's processing record says which jurisdiction the
// data sits in, and naming the building means editing customer-facing
// copy every time the estate grows.
return [
'name' => $datacenter?->location ?: $code,
'note' => null,
];
}
/**
* Seats in use against seats bought.
*
* Invited seats count as used: the licence is committed the moment the
* invitation goes out, and showing them as free is how a customer finds out
* at the worst moment that they cannot add the person in front of them.
*
* @return array{used: int, total: int|null}
*/
private function seats(?Customer $customer, ?Subscription $contract): array
{
return [
'used' => $customer === null ? 0 : Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->count(),
'total' => $contract?->seats,
];
}
/**
* The proof register — what was done for this customer, and when.
*
* Only entries backed by a record. A row here is something the customer can
* be asked to evidence, so "we back up nightly" is not one; "the last backup
* completed at 03:12 and reported ok" is.
*
* @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
*/
private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
{
if ($instance === null) {
return [];
}
$proofs = [];
// A failing job outranks a succeeding one, whatever their dates.
//
// Ordering by last_ok_at alone hides exactly the case this register
// exists for: last night's job failed and so has no success time at
// all, an older row that DID succeed sorts above it, and the sheet
// reports backups as fine. On the page a customer shows their auditor,
// a problem has to beat a date.
$backups = $instance->backups()->get();
$backup = $backups->first(fn ($b) => $b->status !== 'ok')
?? $backups->sortByDesc('last_ok_at')->first();
if ($backup !== null) {
$proofs[] = [
'key' => 'backup',
'at' => $backup->last_ok_at,
// The job's own verdict, not "there is a row, so it worked".
'state' => $backup->status === 'ok' ? 'ok' : 'attention',
'note' => $backup->schedule,
];
}
// A certificate that stopped renewing is the failure a customer meets as
// "my browser says my own cloud is unsafe", so it is stated either way.
$proofs[] = [
'key' => 'certificate',
'at' => null,
'state' => $instance->cert_ok ? 'ok' : 'attention',
'note' => null,
];
if ($maintenance !== null) {
$proofs[] = [
'key' => 'maintenance',
'at' => $maintenance->starts_at,
'state' => 'planned',
'note' => $maintenance->title,
];
}
if ($instance->service_ends_at !== null) {
$proofs[] = [
'key' => 'service_ends',
'at' => $instance->service_ends_at,
'state' => 'attention',
'note' => null,
];
}
// Newest first, but an entry with no time of its own — the certificate —
// stays at the top rather than sinking to the bottom of the register.
usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));
return $proofs;
}
/**
* What is owed next, from the contract rather than from a price list.
*
* The plan and the modules are kept apart. A customer with modules booked
* would otherwise read the plan's price as their bill and be short every
* month — and putting the combined figure next to the word "Paket" on the
* master record would be just as wrong in the other direction.
*
* Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
* plan's price_cents is the price of a whole term. On a yearly contract the
* modules therefore multiply by twelve to land on the same invoice.
*
* Nothing at all once the customer has given notice. Cancelling leaves the
* subscription `active` until the term runs out — that is deliberate, the
* service keeps running — but `current_period_end` is then the day service
* ENDS, not the day the next charge falls. Billing a customer, on the sheet
* they check their invoices against, for a renewal that will never happen is
* the worst single line this page could carry. The end date is still stated;
* it is in the proof register as "contract ends".
*
* @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
*/
private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
{
if ($contract === null || $contract->status === 'cancelled') {
return null;
}
if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
return null;
}
$months = $contract->isYearly() ? 12 : 1;
$addons = $contract->addons
->whereNull('cancelled_at')
->sum(fn ($addon) => $addon->monthlyCents()) * $months;
return [
'plan' => (int) $contract->price_cents,
'addons' => (int) $addons,
'total' => (int) $contract->price_cents + (int) $addons,
'currency' => (string) ($contract->currency ?: 'EUR'),
'due' => $contract->current_period_end,
'term' => (string) $contract->term,
];
}
}