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(); return view('livewire.dashboard', [ 'customer' => $customer, 'instance' => $instance, 'contract' => $contract, 'domain' => $this->domain($instance), '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), 'asOf' => Carbon::now(), ]); } /** * Who holds the seats, so the card can say something true underneath the * figure rather than repeating it. * * @return array 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 */ 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, ]; } }