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), // Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch // steht, nicht wann irgendetwas geloescht wird. Siehe ending(). 'ending' => $this->ending($instance), // Wann die Laufzeit endete — nur, wenn gar keine Instanz mehr in // Betrieb ist. Siehe endedAt(). 'endedAt' => $this->endedAt($customer, $instance), 'asOf' => Carbon::now(), ]); } /** * Wann die Laufzeit dieses Kunden endete — oder null. * * Die Abfrage oben holt nur, was in Betrieb ist; eine `ended`-Instanz * fällt heraus, und der Kunde landete dadurch in demselben Zweig wie * jemand, der noch nie etwas bestellt hat. Er las „Ihre Cloud wird * eingerichtet." und bekam einen Knopf „Paket buchen" — am Tag, an dem * ihm EndInstanceService die Adresse eingezogen hat. * * Nur gefragt, wenn nichts mehr läuft: wer neben der abgelaufenen * Instanz schon wieder eine aktive hat, ist kein beendeter Kunde, und * seine Seite soll von der alten nichts erzählen. * * Die JÜNGSTE Instanz muss die beendete sein, nicht irgendeine beendete. * „Nichts in Betrieb" ist nämlich nicht dasselbe wie „mit uns fertig": * eine frisch bestellte Instanz entsteht als `reserving` (ReserveResources) * und bleibt das den ganzen Bereitstellungslauf lang, und `failed` * (Order::markFailed) steht, bis ein Betreiber wiederholt. Beide fehlen in * der Liste oben — der wiederkehrende Kunde, der eben bezahlt hat, las * damit „Ihre Cloud ist beendet." samt „Neues Paket buchen", direkt über * dem Streifen, der seinen laufenden Aufbau zeigt. Jeder Zustand außer * `ended` ganz oben heißt: es geschieht etwas Neueres, und die alte * Laufzeit ist nicht mehr die Nachricht dieser Seite. * * `service_ends_at` ist dabei nie null — `ended` wird ausschließlich * über EndInstanceService::hasEnded() erreicht, und die verlangt das * Datum. Trotzdem hier verlangt, statt darauf zu vertrauen: ein Satz mit * „endete am —" wäre schlimmer als der Zweig darunter. */ private function endedAt(?Customer $customer, ?Instance $instance): ?Carbon { if ($customer === null || $instance !== null) { return null; } $latest = $customer->instances()->latest('id')->first(); if ($latest === null || $latest->status !== 'ended' || $latest->service_ends_at === null) { return null; } if ($this->buildInFlight($customer)) { return null; } return $latest->service_ends_at; } /** * Läuft für diesen Kunden gerade ein Aufbau? * * Die Instanzprüfung oben trägt den Fall nicht allein: ReserveResources * parkt eine bezahlte Bestellung, für die kein Host Platz hat, und legt * dabei GAR KEINE Instanz an — bis zu vierzehn Tage lang. Die jüngste * Instanz des Kunden ist in diesem Fenster weiterhin die alte, beendete, * und ohne diese zweite Frage stünde der Beendet-Kasten tagelang über * einem Streifen, der auf eine Maschine wartet. * * Bewusst dieselbe Bedingung, unter der CustomerProvisioning den Streifen * überhaupt zeigt (jüngster Kundenlauf, noch nicht abgeschlossen) — auch * ein fehlgeschlagener Lauf gehört dazu: er endet mit einem Betreiber, * der wiederholt, nicht mit einem Kunden, dessen Paket vorbei ist. Zwei * Bauteile auf einer Seite dürfen einander nicht widersprechen. */ private function buildInFlight(Customer $customer): bool { $run = ProvisioningRun::query() ->where('subject_type', Order::class) ->where('pipeline', 'customer') ->whereIn('subject_id', $customer->orders()->select('id')) ->latest('id') ->first(); return $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED; } /** * Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis * zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode * Kunde UND Instanz selbst neu auf, statt einer vom Browser hydrierten * Eigenschaft zu vertrauen — sie ist ueber /livewire/update erreichbar, * nicht nur ueber den Schalter im Streifen, und traegt deshalb dieselbe * Bedingung wie ending() unten: verschwindet der Streifen, weil die * Kuendigung nicht mehr aktiv ist oder das Laufzeitende vorbei ist, darf * dieser Weg auch am Formular vorbei nichts mehr aendern. */ public function setExportWish(bool $wish): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $instance = $customer->instances() ->where('status', 'cancellation_scheduled') ->where('service_ends_at', '>', now()) ->latest('id') ->first(); if ($instance === null) { return; } $instance->update(['export_wish' => $wish]); } /** * Was der Streifen ueber die Restfrist braucht — oder null, wenn er gar * nicht erscheinen soll. * * Zeigt sich nur, wenn eine Kuendigung vorgemerkt ist UND das * Laufzeitende noch in der Zukunft liegt. Der zweite Teil ist kein * Sonderfall am Rand: der stuendliche Lauf, der eine abgelaufene Instanz * auf `ended` stellt, laesst einen DNS-Fehler bewusst durch * (EndInstanceService) und kann deshalb real tagelang hinterherhinken. * Ohne diese Pruefung wuerde der Streifen in genau diesem Fenster eine * abgelaufene Frist als Restzeit ausgeben — der Fehler, den niemand * meldet, weil er wie eine Kleinigkeit aussieht, und den jeder sieht. * * @return array{ends_at: Carbon, days: int, hours: int, show_days: bool}|null */ private function ending(?Instance $instance): ?array { if ($instance === null || $instance->status !== 'cancellation_scheduled' || $instance->service_ends_at === null) { return null; } if (! $instance->service_ends_at->isFuture()) { return null; } // Nie negativ, auch nicht auf dem Papier: geklemmt, obwohl die // Pruefung oben das im Normalfall schon ausschliesst — die Zeit // zwischen der Berechnung hier und der Anzeige im Browser soll sich // nie als "-1 Sekunde" durchschlagen koennen. $seconds = max(0, $instance->service_ends_at->getTimestamp() - Carbon::now()->getTimestamp()); return [ 'ends_at' => $instance->service_ends_at, // Abgerundet auf ganze Tage, solange mehr als ein Tag bleibt — // "noch 12 Tage" liest sich, "noch 11 Tage 7 Stunden" nicht. Am // letzten Tag zaehlt sie Stunden; das ist der Moment, in dem die // Zahl wirklich zaehlt. 'days' => intdiv($seconds, 86400), 'hours' => intdiv($seconds, 3600), 'show_days' => $seconds > 86400, ]; } /** * 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 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; } // See Instance::address(): the customer's own domain counts only once // its DNS proof has been read. return $instance->subdomain ? $instance->address(\App\Support\ProvisioningSettings::dnsZone()) : 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, ]; } }