374 lines
14 KiB
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,
|
|
];
|
|
}
|
|
}
|