From 30a80b6c1544c3df36fb2796c942029da9bbd8e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:48:51 +0200 Subject: [PATCH] Report the estate from the database, and print the traffic that is sold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three console pages were fiction. The front page claimed 42 customers, 39 instances, four hosts named pve-fsn-1..3 and €7,842 a month, over a twelve-month growth curve; the instance list held seven invented machines; the revenue page reported churn and a trend for a business with no recorded history. All of it was hard-coded. It read like a running company and measured nothing. They now read the database. Two figures are gone rather than approximated — the revenue trend, which needs a monthly history nobody records, and churn, which needs a base the data cannot supply. ARR stays, labelled as the projection it is. The green "all systems normal" badge is computed from the notice list instead of asserted, and the notices themselves come from failed runs, hosts reporting errors or gone quiet, and monitoring that is down. Host load is the one number that had to agree with something else: 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 orders were already being refused on it, so it uses the host's own accounting — with the filter moved into a scope both share, and the sum preloaded so listing hosts stays one query. The instance list drops the Nextcloud version column: that version is not recorded anywhere, and a column filled with a plausible number is worse than no column. Statuses the lifecycle writes but nobody had translated no longer render as "admin.status.failed". The price sheet also gains the included traffic, which the catalogue has always carried and the page simply never printed. Co-Authored-By: Claude Opus 5 --- app/Http/Controllers/LandingController.php | 1 + app/Livewire/Admin/Instances.php | 43 ++- app/Livewire/Admin/Overview.php | 333 +++++++++++++++--- app/Livewire/Admin/Revenue.php | 221 +++++++++--- app/Models/Host.php | 16 +- app/Models/Instance.php | 17 + lang/de/admin.php | 70 ++-- lang/en/admin.php | 72 ++-- resources/views/landing.blade.php | 7 + .../views/livewire/admin/instances.blade.php | 26 +- .../views/livewire/admin/overview.blade.php | 120 ++++--- .../views/livewire/admin/revenue.blade.php | 51 +-- .../Admin/ConsoleReportsRealDataTest.php | 170 +++++++++ tests/Feature/LandingPriceSheetTest.php | 6 + 14 files changed, 913 insertions(+), 240 deletions(-) create mode 100644 tests/Feature/Admin/ConsoleReportsRealDataTest.php diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index d9469da..296c4d6 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -108,6 +108,7 @@ class LandingController extends Controller 'note' => self::COPY[$key]['note'] ?? '', 'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']), 'storage' => $this->storage((int) $plan['quota_gb']), + 'traffic' => $this->storage((int) $plan['traffic_gb']), 'seats' => (int) $plan['seats'], 'features' => $this->features($plan['features'] ?? []), 'recommended' => $key === self::RECOMMENDED, diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php index 24cfb5c..79cf01e 100644 --- a/app/Livewire/Admin/Instances.php +++ b/app/Livewire/Admin/Instances.php @@ -2,24 +2,49 @@ namespace App\Livewire\Admin; +use App\Models\Instance; +use Illuminate\Support\Facades\Lang; use Livewire\Attributes\Layout; use Livewire\Component; +use Livewire\WithPagination; +/** + * Every instance on the estate, from the instances table. + * + * This page used to list seven invented instances on four invented hosts, + * complete with a Nextcloud version column. The version is not recorded + * anywhere, so the column is gone rather than filled in with something + * plausible — and the storage column now shows the quota that was actually + * sold, not a made-up "used of total", because used disk is not collected. + */ #[Layout('layouts.admin')] class Instances extends Component { + use WithPagination; + public function render() { + $instances = Instance::query() + ->with(['customer', 'host']) + ->orderByDesc('id') + ->paginate(25); + return view('livewire.admin.instances', [ - 'rows' => [ - ['sub' => 'kanzlei-berger', 'host' => 'pve-fsn-1', 'vmid' => 1042, 'storage' => '235 / 500 GB', 'version' => 'NC 31.0.4', 'status' => 'active'], - ['sub' => 'stb-reiss', 'host' => 'pve-fsn-1', 'vmid' => 1051, 'storage' => '88 / 250 GB', 'version' => 'NC 31.0.4', 'status' => 'active'], - ['sub' => 'notariat-kuelz', 'host' => 'pve-fsn-2', 'vmid' => 1067, 'storage' => '612 / 1000 GB', 'version' => 'NC 31.0.4', 'status' => 'active'], - ['sub' => 'ordination-fux', 'host' => 'pve-fsn-2', 'vmid' => 1072, 'storage' => '2 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'], - ['sub' => 'buero-lang', 'host' => 'pve-fsn-3', 'vmid' => 1074, 'storage' => '—', 'version' => 'NC 31.0.4', 'status' => 'provisioning'], - ['sub' => 'weingut-prantl', 'host' => 'pve-hel-1', 'vmid' => 1075, 'storage' => '4 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'], - ['sub' => 'praxis-sommer', 'host' => 'pve-fsn-3', 'vmid' => 1039, 'storage' => '41 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'suspended'], - ], + 'instances' => $instances, + 'rows' => $instances->getCollection()->map(fn (Instance $i) => [ + 'address' => $i->custom_domain ?: $i->subdomain, + 'customer' => $i->customer?->name ?? '—', + 'host' => $i->host?->name ?? '—', + 'vmid' => $i->vmid ?? '—', + 'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—', + 'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—', + 'status' => $status = $i->status ?? 'provisioning', + // A status the lifecycle adds later must show as itself, never + // as "admin.status.whatever" in front of the owner. + 'status_label' => Lang::has('admin.status.'.$status) + ? __('admin.status.'.$status) + : $status, + ])->all(), ]); } } diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 5dd5124..1b570b6 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -2,81 +2,298 @@ namespace App\Livewire\Admin; +use App\Livewire\Concerns\BuildsRunSteps; +use App\Models\Customer; +use App\Models\Host; +use App\Models\Instance; +use App\Models\MonitoringTarget; +use App\Models\ProvisioningRun; +use App\Models\Subscription; use Illuminate\Support\Carbon; use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; +/** + * The console's front page — every figure on it comes from the database. + * + * It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts + * named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating + * business and reported nothing. Anything without a real source has been + * removed rather than approximated — the revenue trend line especially, because + * there is no revenue history to draw it from, and a plausible line is worse + * than no line. + */ #[Layout('layouts.admin')] class Overview extends Component { + // current_step is an index into the pipeline, not a name. The provisioning + // page already owns the translation from one to the other; sharing it keeps + // the two pages from naming the same step differently. + use BuildsRunSteps; + + /** A host is presumed missing once it has not checked in for this long. */ + private const HOST_SILENT_AFTER_MINUTES = 30; + public function render() { $locale = app()->getLocale(); - $months = collect(range(11, 0)) - ->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM')) - ->all(); + + $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' => '42', 'delta' => '+3', 'tone' => 'success'], - ['label' => __('admin.kpi.instances'), 'value' => '39', 'sub' => __('admin.kpi.instances_sub'), 'tone' => 'success'], - ['label' => __('admin.kpi.hosts'), 'value' => '4', 'sub' => __('admin.kpi.hosts_sub'), 'tone' => 'muted'], - ['label' => __('admin.kpi.mrr'), 'value' => Number::currency(7842, in: 'EUR', locale: $locale), 'delta' => '+6,2 %', 'tone' => 'success'], - ], - 'fleetChart' => [ - 'type' => 'line', - 'data' => [ - 'labels' => $months, - 'datasets' => [[ - 'label' => __('admin.kpi.instances'), - 'data' => [21, 23, 25, 27, 28, 30, 31, 33, 35, 36, 38, 39], - 'borderColor' => 'token:accent', - 'backgroundColor' => 'token:accent/0.12', - 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2, - ]], + [ + 'label' => __('admin.kpi.customers'), + 'value' => (string) $customersOpen, + 'sub' => $customersSuspended > 0 + ? __('admin.kpi.customers_suspended', ['n' => $customersSuspended]) + : null, ], - 'options' => [ - 'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]], - 'plugins' => ['legend' => ['display' => false]], + [ + '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'), ], ], - 'revenueChart' => [ - 'type' => 'line', - 'data' => [ - 'labels' => $months, - 'datasets' => [[ - 'label' => 'MRR', - 'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842], - 'borderColor' => 'token:accent', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - 'borderWidth' => 2, - ]], - ], - 'options' => [ - 'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']]], - 'plugins' => ['legend' => ['display' => false]], - ], - ], - // Per-host storage load as capacity meters (a ranked comparison, not a - // trend — so meters, not a chart). level drives the meter colour. - 'hostLoad' => [ - ['name' => 'pve-fsn-1', 'pct' => 72, 'level' => 'warn'], - ['name' => 'pve-fsn-2', 'pct' => 64, 'level' => 'ok'], - ['name' => 'pve-fsn-3', 'pct' => 81, 'level' => 'high'], - ['name' => 'pve-hel-1', 'pct' => 38, 'level' => 'ok'], - ], - 'runs' => [ - ['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'], - ['customer' => 'Architekturbüro Lang', 'step' => __('admin.run.dns'), 'state' => 'running'], - ['customer' => 'Weingut Prantl', 'step' => __('admin.run.acceptance'), 'state' => 'running'], - ], - 'alerts' => [ - ['level' => 'warning', 'text' => __('admin.alert.host_load', ['host' => 'pve-fsn-3'])], - ['level' => 'info', 'text' => __('admin.alert.cert_soon', ['n' => 3])], - ], + 'newInstances' => $this->newInstancesPerMonth($locale), + 'hostLoad' => $this->hostLoad(), + 'runs' => $this->openRuns(), + '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->format('Y-m')); + + $labels = []; + $data = []; + + for ($i = 0; $i < 12; $i++) { + $month = $start->copy()->addMonths($i); + $labels[] = $month->locale($locale)->isoFormat('MMM'); + $data[] = (int) ($counts[$month->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(); + } + + /** + * 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])]; + } + + foreach (Host::query()->where('status', 'error')->pluck('name') as $name) { + $notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])]; + } + + $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, + ])]; + } + + // Distinct instances, not targets: one instance can be watched by + // several checks, and counting checks would report three outages where + // one machine is down. + $down = MonitoringTarget::query()->where('status', '!=', 'up')->distinct()->count('instance_id'); + if ($down > 0) { + $notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; + } + + 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, + }; + } } diff --git a/app/Livewire/Admin/Revenue.php b/app/Livewire/Admin/Revenue.php index 6bf5ed9..d15bc7a 100644 --- a/app/Livewire/Admin/Revenue.php +++ b/app/Livewire/Admin/Revenue.php @@ -2,63 +2,202 @@ namespace App\Livewire\Admin; -use Illuminate\Support\Carbon; +use App\Models\Subscription; +use App\Models\SubscriptionRecord; use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; +/** + * Revenue, from the contracts and the commercial register. + * + * What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month + * curve, four payments from customers who do not exist. All of it is gone. + * + * Two figures that used to be shown are not shown any more, because there is + * nothing to compute them from and an estimate would be indistinguishable from + * a measurement: + * + * - the MRR trend, which needs a monthly revenue history nobody records; + * - churn, which needs cancellations over a period, and cancelled_at alone + * does not say what the base was. + * + * ARR is shown, but only as what it is: this month's recurring revenue times + * twelve, labelled as a projection rather than as measured turnover. + */ #[Layout('layouts.admin')] class Revenue extends Component { public function render() { $locale = app()->getLocale(); - $eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale); - $months = collect(range(11, 0)) - ->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM')) - ->all(); + $totals = $this->recurringTotals(); return view('livewire.admin.revenue', [ 'kpis' => [ - ['label' => __('admin.rev.mrr'), 'value' => $eur(7842), 'delta' => '+6,2 %'], - ['label' => __('admin.rev.arr'), 'value' => $eur(94104), 'delta' => '+18 %'], - ['label' => __('admin.rev.arpu'), 'value' => $eur(187), 'delta' => '+1,4 %'], - ['label' => __('admin.rev.churn'), 'value' => '1,8 %', 'delta' => '−0,3 %'], - ], - 'mrrChart' => [ - 'type' => 'line', - 'data' => [ - 'labels' => $months, - 'datasets' => [[ - 'label' => 'MRR', - 'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842], - 'borderColor' => 'token:accent', 'backgroundColor' => 'token:accent/0.12', - 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2, - ]], + [ + 'label' => __('admin.rev.mrr'), + 'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale), + 'sub' => __('admin.rev.mrr_sub'), ], - 'options' => [ - 'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]], - 'plugins' => ['legend' => ['display' => false]], + [ + 'label' => __('admin.rev.arr'), + 'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale), + 'sub' => __('admin.rev.arr_sub'), + ], + [ + 'label' => __('admin.rev.arpu'), + 'value' => $this->format( + $totals, + fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0, + $locale, + ), + 'sub' => __('admin.rev.arpu_sub'), + ], + [ + 'label' => __('admin.rev.contracts'), + 'value' => (string) array_sum(array_column($totals, 'contracts')), + 'sub' => __('admin.rev.contracts_sub'), ], ], - 'planChart' => [ - 'type' => 'doughnut', - 'data' => [ - 'labels' => ['Solo', 'Team', 'Enterprise'], - 'datasets' => [[ - 'data' => [1659, 3366, 1796], - 'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'], - 'borderWidth' => 0, - ]], - ], - 'options' => ['cutout' => '62%', 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]]], - ], - 'payments' => [ - ['customer' => 'Notariat Külz', 'amount' => $eur(449), 'when' => '01.07.'], - ['customer' => 'Kanzlei Berger', 'amount' => $eur(198), 'when' => '01.07.'], - ['customer' => 'Steuerberatung Reiss', 'amount' => $eur(198), 'when' => '01.07.'], - ['customer' => 'Ordination Dr. Fux', 'amount' => $eur(79), 'when' => '30.06.'], - ], + 'planCharts' => $this->planCharts(), + 'payments' => $this->recentPayments($locale), ]); } + + /** + * Recurring revenue per currency, off the contracts. + * + * Never summed across currencies. Frozen contract prices, not the + * catalogue, so a price rise does not retroactively inflate what + * grandfathered customers are reported to pay. Add-ons included and yearly + * terms divided — totalMonthlyCents() owns both of those rules. + * + * @return array, contracts:int}> + */ + private function recurringTotals(): array + { + $totals = []; + + Subscription::query() + ->where('status', 'active') + ->with('addons') + ->chunkById(200, function ($subscriptions) use (&$totals) { + foreach ($subscriptions as $subscription) { + $currency = strtoupper((string) $subscription->currency); + $totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0]; + $totals[$currency]['cents'] += $subscription->totalMonthlyCents(); + $totals[$currency]['contracts']++; + // A customer with two contracts is one customer — ARPU + // divided by contracts would understate it. + $totals[$currency]['customers'][(int) $subscription->customer_id] = true; + } + }); + + return $totals; + } + + /** + * @param array, contracts:int}> $totals + * @param callable(array{cents:int, customers:array, contracts:int}): float $amount + */ + private function format(array $totals, callable $amount, string $locale): string + { + if ($totals === []) { + return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale); + } + + return collect($totals) + ->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale)) + ->implode(' · '); + } + + /** + * Recurring revenue split by plan — one chart per currency. + * + * By what customers are ON, not by what is on sale: a withdrawn plan still + * bills, and leaving it out would understate the total the chart sits next + * to. + * + * Per currency, because a doughnut adds its slices together. Two currencies + * in one ring produces a total that is not an amount of anything, and it + * looks exactly as convincing as a correct one. + * + * @return array}> + */ + private function planCharts(): array + { + $byCurrency = []; + + Subscription::query() + ->where('status', 'active') + ->with('addons') + ->chunkById(200, function ($subscriptions) use (&$byCurrency) { + foreach ($subscriptions as $subscription) { + $currency = strtoupper((string) $subscription->currency); + $plan = (string) $subscription->plan; + $byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0) + + $subscription->totalMonthlyCents(); + } + }); + + $charts = []; + + foreach ($byCurrency as $currency => $byPlan) { + arsort($byPlan); + + $charts[] = [ + 'currency' => $currency, + 'config' => [ + 'type' => 'doughnut', + 'data' => [ + 'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)), + 'datasets' => [[ + 'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)), + 'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'], + 'borderWidth' => 0, + ]], + ], + 'options' => [ + 'cutout' => '62%', + 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]], + ], + ], + ]; + } + + return $charts; + } + + /** + * The last payments actually recorded. + * + * Both kinds count: an ordinary billing cycle is written as `renewal`, and + * `invoice_paid` is reserved for everything else Stripe charges for — a + * proration, a manual invoice. Listing only the second would show a + * payments panel with no ordinary payments in it. + * + * Gross, because that is what left the customer's account. The register + * holds net and tax separately for the books. + * + * @return array> + */ + private function recentPayments(string $locale): array + { + return SubscriptionRecord::query() + ->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID]) + ->orderByDesc('occurred_at') + ->limit(8) + ->get() + ->map(fn (SubscriptionRecord $r) => [ + 'customer' => $r->customer_name ?: '—', + 'amount' => Number::currency( + (int) ($r->gross_cents ?: $r->net_cents) / 100, + in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())), + locale: $locale, + ), + 'when' => $r->occurred_at?->translatedFormat('d. MMM') ?? '—', + ]) + ->all(); + } } diff --git a/app/Models/Host.php b/app/Models/Host.php index 5699b2d..381d92f 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -76,9 +76,19 @@ class Host extends Model implements ProvisioningSubject */ public function committedGb(): int { - return (int) $this->instances() - ->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')) - ->sum('disk_gb'); + // Answer a preloaded sum when the caller supplied one (see + // Instance::scopeOccupyingHost) — listing every host otherwise costs a + // query per host, twice over once usedPct() asks again. + // + // Tested for PRESENCE, not for null: SUM over no rows is null, so a + // host with nothing on it arrives preloaded-but-null. Reading that as + // "not preloaded" would re-query for exactly the empty hosts, which is + // the case the preload exists for. + if (array_key_exists('committed_disk_gb', $this->getAttributes())) { + return (int) $this->getAttributes()['committed_disk_gb']; + } + + return (int) $this->instances()->occupyingHost()->sum('disk_gb'); } /** Storage still available for new instances. */ diff --git a/app/Models/Instance.php b/app/Models/Instance.php index b6484fa..91c14fd 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -39,6 +39,23 @@ class Instance extends Model ]; } + /** + * Instances that are actually occupying storage on their host. + * + * A failed instance still holds its disk while its VM exists; a failure + * before any VM was created releases it. Kept here rather than written out + * at each call site so the host's own accounting and anything reporting on + * it cannot drift apart — a dashboard that counted differently from + * placement would call a host comfortable while orders were being refused + * on it. + * + * @param \Illuminate\Database\Eloquent\Builder $query + */ + public function scopeOccupyingHost($query): void + { + $query->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')); + } + public function customer(): BelongsTo { return $this->belongsTo(Customer::class); diff --git a/lang/de/admin.php b/lang/de/admin.php index 50da367..b5f9b7a 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -23,23 +23,33 @@ return [ 'overview_title' => 'Fleet-Übersicht', 'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.', - 'systems_ok' => 'Alle Systeme normal', + 'systems_ok' => 'Nichts gemeldet', + 'systems_notices' => ':n Hinweis(e)', 'kpi' => [ 'customers' => 'Kunden', + 'customers_suspended' => 'davon :n gesperrt', 'instances' => 'Aktive Instanzen', - 'instances_sub' => '3 in Bereitstellung', - 'hosts' => 'Hosts', - 'hosts_sub' => '3 · Falkenstein · 1 · Helsinki', + 'instances_of' => 'von :n insgesamt', + 'hosts' => 'Aktive Hosts', + 'hosts_of' => 'von :n insgesamt', 'mrr' => 'MRR', + 'mrr_sub' => 'Netto je Monat, aus laufenden Verträgen', ], - 'fleet_growth' => 'Flotten-Wachstum', - 'fleet_growth_sub' => 'Aktive Instanzen, 12 Monate.', - 'host_load' => 'Host-Auslastung', - 'host_load_sub' => 'Belegter Speicher je Host.', - 'mrr_trend' => 'MRR-Verlauf', - 'active_runs' => 'Laufende Bereitstellungen', + 'new_instances' => 'Neue Instanzen', + 'new_instances_sub' => 'Je Monat angelegt, letzte 12 Monate.', + 'no_data_yet' => 'Noch keine Daten.', + 'host_load' => 'Belegung je Host', + // "Zugeteilt", not "benutzt": the quota a customer bought is what the host + // must be able to honour. Real disk usage is not collected, so it is not + // claimed here. + 'host_load_sub' => 'Zugeteilter Speicher gegen Kapazität.', + 'host_no_capacity' => 'Kapazität unbekannt', + 'no_hosts_yet' => 'Noch keine Hosts angebunden.', + 'active_runs' => 'Offene Bereitstellungen', + 'no_open_runs' => 'Keine offenen Läufe.', + 'no_notices' => 'Keine offenen Hinweise.', 'view_all' => 'Alle ansehen', 'alerts' => 'Hinweise', 'run_detail' => 'Aktueller Lauf', @@ -48,14 +58,20 @@ return [ 'run_current' => 'Aktueller Schritt', 'run_next' => 'Als Nächstes', - 'run' => [ - 'deploy' => 'DeployApplicationStack', - 'dns' => 'ConfigureDnsAndTls', - 'acceptance' => 'RunAcceptanceChecks', + 'run_status' => [ + 'pending' => 'Wartet', + 'running' => 'Läuft', + 'waiting' => 'Wartet auf Ereignis', + 'paused' => 'Angehalten', + 'failed' => 'Fehlgeschlagen', + 'completed' => 'Abgeschlossen', ], - 'alert' => [ - 'host_load' => 'Host :host über 80 % Speicherauslastung.', - 'cert_soon' => ':n Zertifikate laufen in 14 Tagen ab.', + + 'notice' => [ + 'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).', + 'host_error' => 'Host :host meldet einen Fehler.', + 'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.', + 'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.', ], 'customers_sub' => 'Alle Kunden und ihre Pakete.', @@ -73,23 +89,30 @@ return [ 'provisioning_sub' => 'Laufende und abgeschlossene Bereitstellungen.', 'revenue_sub' => 'Umsatzkennzahlen der Plattform.', 'mrr_by_plan' => 'MRR nach Paket', + 'no_payments_yet' => 'Noch keine Zahlungen verbucht.', 'recent_payments' => 'Letzte Zahlungen', + 'instances_empty' => 'Noch keine Instanzen.', + 'col' => [ 'customer' => 'Kunde', 'plan' => 'Paket', 'mrr' => 'MRR', 'status' => 'Status', + 'address' => 'Adresse', + 'quota' => 'Kontingent', 'subdomain' => 'Subdomain', 'host' => 'Host', 'storage' => 'Speicher', - 'version' => 'Version', 'step' => 'Schritt', 'attempt' => 'Versuch', 'actions' => 'Aktionen', ], 'status' => [ + 'reserving' => 'Reserviert', + 'failed' => 'Fehlgeschlagen', + 'cancellation_scheduled' => 'Kündigung vorgemerkt', 'active' => 'Aktiv', 'provisioning' => 'Bereitstellung', 'suspended' => 'Ausgesetzt', @@ -110,8 +133,13 @@ return [ 'rev' => [ 'mrr' => 'MRR', - 'arr' => 'ARR', - 'arpu' => 'ARPU', - 'churn' => 'Churn (30 T)', + 'mrr_sub' => 'Netto je Monat, aus laufenden Verträgen', + 'arr' => 'ARR (Hochrechnung)', + // Explicitly a projection: MRR x 12, not measured turnover. + 'arr_sub' => 'MRR mal zwölf — keine gemessene Jahressumme', + 'arpu' => 'Je Kunde', + 'arpu_sub' => 'MRR geteilt durch zahlende Kunden', + 'contracts' => 'Laufende Verträge', + 'contracts_sub' => 'Verträge mit Status aktiv', ], ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 40bc643..307c27d 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -23,39 +23,55 @@ return [ 'overview_title' => 'Fleet overview', 'overview_sub' => 'The whole platform at a glance.', - 'systems_ok' => 'All systems normal', + 'systems_ok' => 'Nothing reported', + 'systems_notices' => ':n notice(s)', 'kpi' => [ 'customers' => 'Customers', + 'customers_suspended' => ':n suspended', 'instances' => 'Active instances', - 'instances_sub' => '3 provisioning', - 'hosts' => 'Hosts', - 'hosts_sub' => '3 · Falkenstein · 1 · Helsinki', + 'instances_of' => 'of :n in total', + 'hosts' => 'Active hosts', + 'hosts_of' => 'of :n in total', 'mrr' => 'MRR', + 'mrr_sub' => 'Net per month, from live contracts', ], - 'fleet_growth' => 'Fleet growth', - 'fleet_growth_sub' => 'Active instances, 12 months.', - 'host_load' => 'Host load', - 'host_load_sub' => 'Used storage per host.', - 'mrr_trend' => 'MRR trend', - 'active_runs' => 'Active provisioning', + 'new_instances' => 'New instances', + 'new_instances_sub' => 'Created per month, last 12 months.', + 'no_data_yet' => 'No data yet.', + 'host_load' => 'Committed per host', + // "Committed", not "used": the quota a customer bought is what the host + // must be able to honour. Real disk usage is not collected, so it is not + // claimed here. + 'host_load_sub' => 'Committed storage against capacity.', + 'host_no_capacity' => 'Capacity unknown', + 'no_hosts_yet' => 'No hosts onboarded yet.', + 'active_runs' => 'Open provisioning', + 'no_open_runs' => 'No open runs.', + 'no_notices' => 'No open notices.', 'view_all' => 'View all', - 'alerts' => 'Alerts', + 'alerts' => 'Notices', 'run_detail' => 'Current run', 'run_attempt' => 'Attempt :n', 'run_done' => ':done of :total complete', 'run_current' => 'Current step', 'run_next' => 'Up next', - 'run' => [ - 'deploy' => 'DeployApplicationStack', - 'dns' => 'ConfigureDnsAndTls', - 'acceptance' => 'RunAcceptanceChecks', + 'run_status' => [ + 'pending' => 'Pending', + 'running' => 'Running', + 'waiting' => 'Waiting', + 'paused' => 'Paused', + 'failed' => 'Failed', + 'completed' => 'Completed', ], - 'alert' => [ - 'host_load' => 'Host :host over 80% storage usage.', - 'cert_soon' => ':n certificates expire in 14 days.', + + 'notice' => [ + 'failed_runs' => ':n failed provisioning run(s).', + 'host_error' => 'Host :host reports an error.', + 'host_silent' => 'Host :host has not checked in for over :minutes minutes.', + 'monitoring_down' => ':n monitored instance(s) unreachable.', ], 'customers_sub' => 'All customers and their plans.', @@ -73,23 +89,30 @@ return [ 'provisioning_sub' => 'Running and completed provisioning.', 'revenue_sub' => 'Platform revenue metrics.', 'mrr_by_plan' => 'MRR by plan', + 'no_payments_yet' => 'No payments recorded yet.', 'recent_payments' => 'Recent payments', + 'instances_empty' => 'No instances yet.', + 'col' => [ 'customer' => 'Customer', 'plan' => 'Plan', 'mrr' => 'MRR', 'status' => 'Status', + 'address' => 'Address', + 'quota' => 'Quota', 'subdomain' => 'Subdomain', 'host' => 'Host', 'storage' => 'Storage', - 'version' => 'Version', 'step' => 'Step', 'attempt' => 'Attempt', 'actions' => 'Actions', ], 'status' => [ + 'reserving' => 'Reserving', + 'failed' => 'Failed', + 'cancellation_scheduled' => 'Cancellation scheduled', 'active' => 'Active', 'provisioning' => 'Provisioning', 'suspended' => 'Suspended', @@ -110,8 +133,13 @@ return [ 'rev' => [ 'mrr' => 'MRR', - 'arr' => 'ARR', - 'arpu' => 'ARPU', - 'churn' => 'Churn (30d)', + 'mrr_sub' => 'Net per month, from live contracts', + 'arr' => 'ARR (projected)', + // Explicitly a projection: MRR x 12, not measured turnover. + 'arr_sub' => 'MRR times twelve — not measured annual turnover', + 'arpu' => 'Per customer', + 'arpu_sub' => 'MRR divided by paying customers', + 'contracts' => 'Live contracts', + 'contracts_sub' => 'Contracts with status active', ], ]; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index f0b0492..fb51b03 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -340,6 +340,7 @@ .pPrice small{font-family:var(--sans);font-size:.78rem;color:var(--muted);font-weight:400;letter-spacing:0;margin-left:5px} .pSet{font-size:.76rem;color:var(--muted);margin-top:5px;line-height:1.45} .pSpec{font-size:.95rem;color:var(--ink);font-variant-numeric:tabular-nums} + .perMonth{font-size:.76rem;color:var(--muted);margin-left:4px} table.ps td.hasF,table.ps td.noF{text-align:center;padding-top:15px;padding-bottom:15px} table.ps td.hasF svg{width:15px;height:15px;color:var(--ok)} table.ps td.noF{color:var(--faint);font-family:var(--mono);font-size:.85rem} @@ -740,6 +741,12 @@ {{ $plan['storage'] }} @endforeach + + Datenvolumen + @foreach ($plans as $plan) + {{ $plan['traffic'] }}/Monat + @endforeach + Benutzer @foreach ($plans as $plan) diff --git a/resources/views/livewire/admin/instances.blade.php b/resources/views/livewire/admin/instances.blade.php index ab9755b..68a1d41 100644 --- a/resources/views/livewire/admin/instances.blade.php +++ b/resources/views/livewire/admin/instances.blade.php @@ -9,27 +9,35 @@ - + + - - + + - @foreach ($rows as $r) + @forelse ($rows as $r) - + + - - - + + + - @endforeach + @empty + + @endforelse
{{ __('admin.col.subdomain') }}{{ __('admin.col.address') }}{{ __('admin.col.customer') }} {{ __('admin.col.host') }} VMID{{ __('admin.col.storage') }}{{ __('admin.col.version') }}{{ __('admin.col.plan') }}{{ __('admin.col.quota') }} {{ __('admin.col.status') }}
{{ $r['sub'] }}{{ $r['address'] }}{{ $r['customer'] }} {{ $r['host'] }} {{ $r['vmid'] }}{{ $r['storage'] }}{{ $r['version'] }}{{ __('admin.status.'.$r['status']) }}{{ $r['plan'] }}{{ $r['quota'] }}{{ $r['status_label'] }}
{{ __('admin.instances_empty') }}
+ + @if ($instances->hasPages()) +
{{ $instances->links() }}
+ @endif diff --git a/resources/views/livewire/admin/overview.blade.php b/resources/views/livewire/admin/overview.blade.php index 388ee5e..86f5d44 100644 --- a/resources/views/livewire/admin/overview.blade.php +++ b/resources/views/livewire/admin/overview.blade.php @@ -1,9 +1,17 @@

{{ __('admin.overview_title') }}

- - {{ __('admin.systems_ok') }} - + {{-- Derived from the notice list below, never asserted: a green badge + that is not computed is the most expensive kind of decoration. --}} + @if ($noticeCount === 0) + + {{ __('admin.systems_ok') }} + + @else + + {{ __('admin.systems_notices', ['n' => $noticeCount]) }} + + @endif

{{ __('admin.overview_sub') }}

@@ -13,81 +21,85 @@ @foreach ($kpis as $i => $kpi)

{{ $kpi['label'] }}

-

- {{ $kpi['value'] }} - @isset($kpi['delta']){{ $kpi['delta'] }}@endisset -

- @isset($kpi['sub'])

{{ $kpi['sub'] }}

@endisset +

{{ $kpi['value'] }}

+ @if (($kpi['sub'] ?? null) !== null) +

{{ $kpi['sub'] }}

+ @endif
@endforeach
- {{-- Charts --}}
+ {{-- New instances per month --}}
-

{{ __('admin.fleet_growth') }}

-

{{ __('admin.fleet_growth_sub') }}

-
+

{{ __('admin.new_instances') }}

+

{{ __('admin.new_instances_sub') }}

+ @if ($newInstances['empty']) +

{{ __('admin.no_data_yet') }}

+ @else +
+ @endif
+ + {{-- Committed storage per host --}}

{{ __('admin.host_load') }}

{{ __('admin.host_load_sub') }}

    - @foreach ($hostLoad as $h) - @php $bar = ['ok' => 'bg-success-bright', 'warn' => 'bg-accent-active', 'high' => 'bg-danger'][$h['level']] ?? 'bg-accent-active'; @endphp + @forelse ($hostLoad as $h) + @php $bar = ['ok' => 'bg-success-bright', 'warn' => 'bg-accent-active', 'high' => 'bg-danger', 'unknown' => 'bg-line-strong'][$h['level']] ?? 'bg-accent-active'; @endphp
  • -
    +
    {{ $h['name'] }} - {{ $h['pct'] }} % + {{ $h['detail'] }}
  • - @endforeach + @empty +
  • {{ __('admin.no_hosts_yet') }}
  • + @endforelse
-
-
-

{{ __('admin.mrr_trend') }}

-
+
+ {{-- Open provisioning runs --}} +
+
+

{{ __('admin.active_runs') }}

+ {{ __('admin.view_all') }} › +
+
    + @forelse ($runs as $r) +
  • + +
    +

    {{ $r['subject'] }}

    +

    {{ $r['step'] }}

    +
    + {{ __('admin.run_status.'.$r['status']) }} +
  • + @empty +
  • {{ __('admin.no_open_runs') }}
  • + @endforelse +
-
- {{-- Active runs --}} -
-
-

{{ __('admin.active_runs') }}

- {{ __('admin.view_all') }} › -
-
    - @foreach ($runs as $r) -
  • - -
    -

    {{ $r['customer'] }}

    -

    {{ $r['step'] }}

    -
    - {{ __('admin.state_running') }} -
  • - @endforeach -
-
- - {{-- Alerts --}} -
-

{{ __('admin.alerts') }}

-
    - @foreach ($alerts as $a) -
  • - - {{ $a['text'] }} -
  • - @endforeach -
-
+ {{-- Notices --}} +
+

{{ __('admin.alerts') }}

+
    + @forelse ($notices as $a) +
  • + + {{ $a['text'] }} +
  • + @empty +
  • {{ __('admin.no_notices') }}
  • + @endforelse +
diff --git a/resources/views/livewire/admin/revenue.blade.php b/resources/views/livewire/admin/revenue.blade.php index eae9701..e19ef69 100644 --- a/resources/views/livewire/admin/revenue.blade.php +++ b/resources/views/livewire/admin/revenue.blade.php @@ -9,36 +9,41 @@ @foreach ($kpis as $i => $kpi)

{{ $kpi['label'] }}

-

- {{ $kpi['value'] }} - {{ $kpi['delta'] }} -

+

{{ $kpi['value'] }}

+

{{ $kpi['sub'] }}

@endforeach
-
-

{{ __('admin.mrr_trend') }}

-
-
-
+

{{ __('admin.mrr_by_plan') }}

-
+ @forelse ($planCharts as $chart) + {{-- One ring per currency: a doughnut sums its slices, and a + total mixing euros with francs is not an amount. --}} + @if (count($planCharts) > 1) +

{{ $chart['currency'] }}

+ @endif +
+ @empty +

{{ __('admin.no_data_yet') }}

+ @endforelse
-
-
-

{{ __('admin.recent_payments') }}

-
    - @foreach ($payments as $p) -
  • - - {{ $p['customer'] }} - {{ $p['when'] }} - {{ $p['amount'] }} -
  • - @endforeach -
+
+

{{ __('admin.recent_payments') }}

+
    + @forelse ($payments as $p) +
  • + + {{ $p['customer'] }} + {{ $p['when'] }} + {{ $p['amount'] }} +
  • + @empty +
  • {{ __('admin.no_payments_yet') }}
  • + @endforelse +
+
diff --git a/tests/Feature/Admin/ConsoleReportsRealDataTest.php b/tests/Feature/Admin/ConsoleReportsRealDataTest.php new file mode 100644 index 0000000..83da243 --- /dev/null +++ b/tests/Feature/Admin/ConsoleReportsRealDataTest.php @@ -0,0 +1,170 @@ +test(Overview::class) + ->assertViewHas('kpis', function (array $kpis) { + [$customers, $instances, $hosts, $mrr] = $kpis; + + return $customers['value'] === '0' + && $instances['value'] === '0' + && $hosts['value'] === '0' + && str_contains($mrr['value'], '0,00'); + }) + ->assertViewHas('hostLoad', []) + ->assertViewHas('runs', []) + ->assertViewHas('notices', []) + ->assertSee(__('admin.no_open_runs')) + ->assertSee(__('admin.no_notices')); +}); + +it('counts what is actually there', function () { + $host = Host::factory()->create(['status' => 'active', 'total_gb' => 1000, 'last_seen_at' => now()]); + $customer = Customer::factory()->create(['status' => 'active']); + Instance::factory()->create([ + 'customer_id' => $customer->id, 'host_id' => $host->id, + 'status' => 'active', 'quota_gb' => 250, + ]); + + Livewire::actingAs(operator('Owner')) + ->test(Overview::class) + ->assertSee('1') + ->assertSee($host->name); +}); + +it('reports host capacity the way placement counts it', function () { + // The figure on the dashboard has to be the figure placement acts on. + // Placement counts the VM disk allocation — not the smaller Nextcloud + // quota — against capacity AFTER the host's reserve. A dashboard doing its + // own arithmetic would call a host comfortable while orders were already + // being refused on it. + $host = Host::factory()->create([ + 'status' => 'active', 'total_gb' => 1000, 'reserve_pct' => 20, 'last_seen_at' => now(), + ]); + $customer = Customer::factory()->create(); + Instance::factory()->create([ + 'customer_id' => $customer->id, 'host_id' => $host->id, + 'status' => 'active', 'quota_gb' => 250, 'disk_gb' => 600, + ]); + + expect($host->committedGb())->toBe(600) + ->and($host->freeGb())->toBe(800); + + Livewire::actingAs(operator('Owner')) + ->test(Overview::class) + // 600 of the 800 GB placement may actually use, not 250 of 1000. + ->assertSee('600 / 800 GB') + ->assertDontSee('250 / 1000 GB'); +}); + +it('states monthly revenue off the contracts, with a yearly term divided', function () { + $customer = Customer::factory()->create(); + + Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'active', 'term' => 'monthly', 'price_cents' => 17900, 'currency' => 'EUR', + ]); + // A year paid up front is not twelve times the monthly revenue. + Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'active', 'term' => 'yearly', 'price_cents' => 120000, 'currency' => 'EUR', + ]); + // A cancelled contract bills nothing. + Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'cancelled', 'term' => 'monthly', 'price_cents' => 99900, 'currency' => 'EUR', + ]); + + // 179,00 monthly + 1.200,00 a year ÷ 12 = 179,00 + 100,00 = 279,00 + Livewire::actingAs(operator('Owner')) + ->test(Revenue::class) + ->assertSee('279,00') + ->assertDontSee('999,00'); +}); + +it('raises a notice only when something is actually wrong', function () { + $clean = Livewire::actingAs(operator('Owner'))->test(Overview::class); + $clean->assertSee(__('admin.systems_ok')); + + ProvisioningRun::factory()->create(['status' => ProvisioningRun::STATUS_FAILED]); + + Livewire::actingAs(operator('Owner')) + ->test(Overview::class) + ->assertSee(__('admin.notice.failed_runs', ['n' => 1])) + ->assertDontSee(__('admin.systems_ok')); +}); + +it('gets the same committed storage whether or not the sum was preloaded', function () { + // Two code paths now answer one question. If they ever disagree, the + // dashboard and placement disagree — which is the bug this replaced. + $host = Host::factory()->create(['total_gb' => 1000, 'reserve_pct' => 10]); + $customer = Customer::factory()->create(); + + Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'active', 'disk_gb' => 120]); + Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'failed', 'vmid' => 900, 'disk_gb' => 80]); + // Failed before a VM existed — it holds nothing. + Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'failed', 'vmid' => null, 'disk_gb' => 500]); + + $onDemand = Host::query()->find($host->id)->committedGb(); + $preloaded = Host::query() + ->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb') + ->find($host->id) + ->committedGb(); + + expect($onDemand)->toBe(200)->and($preloaded)->toBe($onDemand); + + // A host with nothing on it arrives preloaded-but-null (SUM over no rows). + // It must read as zero without going back to the database — that is the + // case the preload exists for. + $bare = Host::factory()->create(['total_gb' => 500]); + $preloadedBare = Host::query() + ->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb') + ->find($bare->id); + + DB::enableQueryLog(); + expect($preloadedBare->committedGb())->toBe(0); + expect(DB::getQueryLog())->toBeEmpty(); + DB::disableQueryLog(); +}); + +it('lists real instances and claims no version it does not record', function () { + $host = Host::factory()->create(['name' => 'pve-test-9']); + $customer = Customer::factory()->create(['name' => 'Beispielkunde GmbH']); + Instance::factory()->create([ + 'customer_id' => $customer->id, 'host_id' => $host->id, + 'subdomain' => 'beispielkunde', 'vmid' => 4711, 'quota_gb' => 500, 'status' => 'active', + ]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminInstances::class) + ->assertSee('beispielkunde') + ->assertSee('Beispielkunde GmbH') + ->assertSee('pve-test-9') + ->assertSee('4711') + // The Nextcloud version is not recorded anywhere, so it is not shown. + ->assertDontSee('NC 31'); +}); diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 3d613cf..5da9cb0 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -26,6 +26,12 @@ it('quotes the price the catalogue would actually charge', function () { // sheet renders, so a formatting change cannot slip past this test. $page->assertSee(number_format($monthly->amount_cents / 100, 0, ',', '.')."\u{00A0}€", false); $page->assertSee('bis '.$version->seats); + + // The included traffic is sold and metered but was simply never printed. + $page->assertSee('Datenvolumen'); + $page->assertSee($version->traffic_gb >= 1000 && $version->traffic_gb % 1000 === 0 + ? ($version->traffic_gb / 1000).' TB' + : $version->traffic_gb.' GB'); }); it('follows the catalogue when the owner changes a price', function () {