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(), 'delivery' => $this->delivery(), '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 */ 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->local()->format('Y-m')); $labels = []; $data = []; for ($i = 0; $i < 12; $i++) { $month = $start->copy()->addMonths($i); $labels[] = $month->local()->locale($locale)->isoFormat('MMM'); $data[] = (int) ($counts[$month->local()->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> */ 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(); } /** * What each package can be promised today, and who is waiting. * * The same question the price sheet and the customer's own overview ask, * answered from the same place — an operator must be able to see on the * front page what a visitor is being told, without opening the shop in * another tab. A package with no host big enough is not unsellable: the * order is taken, parked, and rolled out once a machine arrives, which is * exactly what "bereitgestellt in 2-3 Werktagen" means. * * @return array{plans: array, queued: int} */ private function delivery(): array { $capacity = app(HostCapacity::class); try { $sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable(); } catch (\Throwable) { // A catalogue that cannot be read has its own alarm. It does not // get to take the console's front page down with it. $sellable = []; } $plans = []; foreach ($sellable as $key => $plan) { $needs = (int) ($plan['disk_gb'] ?? 0); $plans[] = [ 'name' => (string) ($plan['name'] ?? $key), 'needs' => $needs, 'state' => $capacity->deliveryFor($needs), ]; } return ['plans' => $plans, 'queued' => $capacity->queueDemand()['count']]; } /** * Provisioning runs that have not finished. * * @return array> */ 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> */ 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]), // Every notice carries the page that shows the thing it is // about. A warning an operator cannot act on from where they // read it is a warning they go looking for by hand, and the // count in the header was not even a link to the list. 'route' => 'admin.provisioning', ]; } foreach (Host::query()->where('status', 'error')->pluck('name') as $name) { $notices[] = [ 'level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name]), 'route' => 'admin.hosts', ]; } $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, ]), 'route' => 'admin.hosts', ]; } // Distinct instances, not targets: one instance can be watched by // several checks, and counting checks would report three outages where // one machine is down. And only verdicts the sync job has refreshed — // an unchecked row is not a healthy one, nor a failing one. $down = MonitoringTarget::query() ->where('status', 'down') ->where('checked_at', '>=', Carbon::now()->subMinutes(20)) ->distinct() ->count('instance_id'); if ($down > 0) { $notices[] = [ 'level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down]), 'route' => 'admin.instances', ]; } // Capacity, before an order finds out. // // The platform books thick: a package reserves its whole `disk_gb` on // one host at placement. So the catalogue can go on offering a package // no host has room for, and the first anyone hears of it is a PAID // order failing at the reservation step — after the money. // // Listed per package with the shortfall, because "buy a server" is not // the decision; "buy a server big enough for the package I am selling" // is. foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) { $notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [ 'plan' => $plan['name'], 'needs' => $plan['needs'], 'largest' => $plan['largest'], ])]; } // The site being switched off. It is a deliberate state, not a fault — // but it answers 503 to every visitor who is not on the VPN, and the // only place that says so is the switch itself, three clicks away on // another page. Asked as "ich komme nicht auf www.dev", which is exactly // what it looks like from outside. if (! Settings::bool('site.public', true)) { $notices[] = [ 'level' => 'warning', 'text' => __('admin.notice.site_hidden'), 'route' => 'admin.settings', ]; } // MAIL_MAILER=log. Every purpose mailer short-circuits to the log // transport then — see MailboxTransport::NON_DELIVERING — however well // the mailboxes are configured, and nothing is delivered to anybody. // // Worth its own notice because the failure is SILENT in every direction: // the queue reports success, no job fails, the console's own mailbox test // reports success (MailboxTester builds its own transport on purpose), and // the mail sits in storage/logs. Somebody registering and waiting for a // verification mail has no way to find that out. Asked exactly that way. if (in_array(config('mail.default'), ['log', 'array', null], true)) { $notices[] = [ 'level' => 'warning', 'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]), 'route' => 'admin.mail', ]; } // No SITE_HOST. The marketing site is then registered without a // hostname, so it answers on the PORTAL's host as well — and every // route() it generates points at APP_URL, which is why a link on the // site lands on the portal's domain. // // Only where the installation is host-separated at all. On a fresh // checkout nothing is bound to a hostname by design (see // RestrictAdminHost: "upgrading must not lock anyone out of a system // that was working"), and a warning there would be furniture — the same // mistake the capacity notice made before it learned to ask whether // there is a host at all. if (AdminArea::isHostBound() && config('admin_access.site_hosts') === []) { $notices[] = [ 'level' => 'warning', 'text' => __('admin.notice.no_site_host', [ 'app' => (string) parse_url((string) config('app.url'), PHP_URL_HOST), ]), 'route' => 'admin.integrations', ]; } 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, }; } }