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 () {