Report the estate from the database, and print the traffic that is sold

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 <noreply@anthropic.com>
feat/portal-design
Claude 2026-07-27 04:48:51 +02:00
parent b844ff377d
commit 30a80b6c15
14 changed files with 913 additions and 240 deletions

View File

@ -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,

View File

@ -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(),
]);
}
}

View File

@ -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<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, string>>
*/
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,
};
}
}

View File

@ -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<string, array{cents:int, customers:array<int,bool>, 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<string, array{cents:int, customers:array<int,bool>, contracts:int}> $totals
* @param callable(array{cents:int, customers:array<int,bool>, 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<int, array{currency:string, config:array<string,mixed>}>
*/
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<int, array<string, string>>
*/
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();
}
}

View File

@ -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. */

View File

@ -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<self> $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);

View File

@ -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',
],
];

View File

@ -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',
],
];

View File

@ -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 @@
<td class="pSpec {{ $plan['recommended'] ? 'colWin' : '' }}">{{ $plan['storage'] }}</td>
@endforeach
</tr>
<tr>
<th class="rh" scope="row">Datenvolumen</th>
@foreach ($plans as $plan)
<td class="pSpec {{ $plan['recommended'] ? 'colWin' : '' }}">{{ $plan['traffic'] }}<span class="perMonth">/Monat</span></td>
@endforeach
</tr>
<tr>
<th class="rh" scope="row">Benutzer</th>
@foreach ($plans as $plan)

View File

@ -9,27 +9,35 @@
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.subdomain') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.host') }}</th>
<th class="px-4 py-3 font-semibold">VMID</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.storage') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.version') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.quota') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
@forelse ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-mono text-body">{{ $r['sub'] }}</td>
<td class="px-4 py-3 font-mono text-body">{{ $r['address'] }}</td>
<td class="px-4 py-3 text-body">{{ $r['customer'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['host'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['vmid'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['storage'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['version'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
<td class="px-4 py-3 text-xs text-muted">{{ $r['plan'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['quota'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ $r['status_label'] }}</x-ui.badge></td>
</tr>
@endforeach
@empty
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-faint">{{ __('admin.instances_empty') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if ($instances->hasPages())
<div class="animate-rise [animation-delay:120ms]">{{ $instances->links() }}</div>
@endif
</div>

View File

@ -1,9 +1,17 @@
<div class="space-y-5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.overview_title') }}</h1>
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('admin.systems_ok') }}
</span>
{{-- 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)
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('admin.systems_ok') }}
</span>
@else
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-warning-bg px-3.5 py-1.5 text-xs font-semibold text-warning">
<x-ui.icon name="alert-triangle" class="size-3.5" />{{ __('admin.systems_notices', ['n' => $noticeCount]) }}
</span>
@endif
<p class="w-full text-sm text-muted">{{ __('admin.overview_sub') }}</p>
</div>
@ -13,81 +21,85 @@
@foreach ($kpis as $i => $kpi)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p>
<p class="mt-2 flex items-baseline gap-2">
<span class="font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</span>
@isset($kpi['delta'])<span class="font-mono text-xs font-semibold text-success">{{ $kpi['delta'] }}</span>@endisset
</p>
@isset($kpi['sub'])<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>@endisset
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p>
@if (($kpi['sub'] ?? null) !== null)
<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
@endif
</div>
@endforeach
</div>
{{-- Charts --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
{{-- New instances per month --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.fleet_growth') }}</h2>
<p class="text-sm text-muted">{{ __('admin.fleet_growth_sub') }}</p>
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$fleetChart" class="h-56" :label="__('admin.fleet_growth')" /></div>
<h2 class="font-semibold text-ink">{{ __('admin.new_instances') }}</h2>
<p class="text-sm text-muted">{{ __('admin.new_instances_sub') }}</p>
@if ($newInstances['empty'])
<p class="mt-8 text-center text-sm text-faint">{{ __('admin.no_data_yet') }}</p>
@else
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$newInstances['config']" class="h-56" :label="__('admin.new_instances')" /></div>
@endif
</div>
{{-- Committed storage per host --}}
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2>
<p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p>
<ul class="mt-4 flex-1 space-y-3.5">
@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
<li>
<div class="flex items-baseline justify-between text-sm">
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="font-mono text-ink">{{ $h['name'] }}</span>
<span class="font-mono text-muted">{{ $h['pct'] }} %</span>
<span class="font-mono text-xs text-muted">{{ $h['detail'] }}</span>
</div>
<div class="mt-1.5 h-2 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $bar }}" style="width: {{ $h['pct'] }}%"></div>
</div>
</li>
@endforeach
@empty
<li class="text-sm text-faint">{{ __('admin.no_hosts_yet') }}</li>
@endforelse
</ul>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_trend') }}</h2>
<div class="mt-3 min-h-52 flex-1" wire:ignore><x-ui.chart :config="$revenueChart" class="h-full" :label="__('admin.mrr_trend')" /></div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{{-- Open provisioning runs --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('admin.active_runs') }}</h2>
<a href="{{ route('admin.provisioning') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.view_all') }} </a>
</div>
<ul class="mt-2 divide-y divide-line">
@forelse ($runs as $r)
<li class="flex items-center gap-3 py-2.5 text-sm">
<span class="size-2 shrink-0 rounded-pill bg-info" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-ink">{{ $r['subject'] }}</p>
<p class="font-mono text-xs text-muted">{{ $r['step'] }}</p>
</div>
<x-ui.badge :status="$r['status'] === 'failed' ? 'error' : 'provisioning'">{{ __('admin.run_status.'.$r['status']) }}</x-ui.badge>
</li>
@empty
<li class="py-3 text-sm text-faint">{{ __('admin.no_open_runs') }}</li>
@endforelse
</ul>
</div>
<div class="flex flex-col gap-4">
{{-- Active runs --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('admin.active_runs') }}</h2>
<a href="{{ route('admin.provisioning') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.view_all') }} </a>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($runs as $r)
<li class="flex items-center gap-3 py-2.5 text-sm">
<span class="size-2 shrink-0 rounded-pill bg-info animate-pulse" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-ink">{{ $r['customer'] }}</p>
<p class="font-mono text-xs text-muted">{{ $r['step'] }}</p>
</div>
<x-ui.badge status="provisioning">{{ __('admin.state_running') }}</x-ui.badge>
</li>
@endforeach
</ul>
</div>
{{-- Alerts --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2>
<ul class="mt-2 space-y-2">
@foreach ($alerts as $a)
<li class="flex items-start gap-2.5 text-sm">
<x-ui.icon name="{{ $a['level'] === 'warning' ? 'alert-triangle' : 'bell' }}" class="mt-0.5 size-4 shrink-0 {{ $a['level'] === 'warning' ? 'text-warning' : 'text-info' }}" />
<span class="text-body">{{ $a['text'] }}</span>
</li>
@endforeach
</ul>
</div>
{{-- Notices --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2>
<ul class="mt-2 space-y-2">
@forelse ($notices as $a)
<li class="flex items-start gap-2.5 text-sm">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />
<span class="text-body">{{ $a['text'] }}</span>
</li>
@empty
<li class="py-1 text-sm text-faint">{{ __('admin.no_notices') }}</li>
@endforelse
</ul>
</div>
</div>
</div>

View File

@ -9,36 +9,41 @@
@foreach ($kpis as $i => $kpi)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p>
<p class="mt-2 flex items-baseline gap-2">
<span class="font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</span>
<span class="font-mono text-xs font-semibold text-success">{{ $kpi['delta'] }}</span>
</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p>
<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
</div>
@endforeach
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_trend') }}</h2>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$mrrChart" class="h-56" :label="__('admin.mrr_trend')" /></div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_by_plan') }}</h2>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$planChart" class="h-56" :label="__('admin.mrr_by_plan')" /></div>
@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)
<p class="mt-3 font-mono text-xs uppercase tracking-wide text-faint">{{ $chart['currency'] }}</p>
@endif
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$chart['config']" class="h-56" :label="__('admin.mrr_by_plan').' — '.$chart['currency']" /></div>
@empty
<p class="mt-8 text-center text-sm text-faint">{{ __('admin.no_data_yet') }}</p>
@endforelse
</div>
</div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:180ms]">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('admin.recent_payments') }}</h2></div>
<ul class="divide-y divide-line">
@foreach ($payments as $p)
<li class="flex items-center gap-3 px-5 py-3 text-sm">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-success"><x-ui.icon name="check" class="size-4" /></span>
<span class="min-w-0 flex-1 truncate font-medium text-ink">{{ $p['customer'] }}</span>
<span class="font-mono text-xs text-faint">{{ $p['when'] }}</span>
<span class="font-mono text-body">{{ $p['amount'] }}</span>
</li>
@endforeach
</ul>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms] lg:col-span-2">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('admin.recent_payments') }}</h2></div>
<ul class="divide-y divide-line">
@forelse ($payments as $p)
<li class="flex items-center gap-3 px-5 py-3 text-sm">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-success"><x-ui.icon name="check" class="size-4" /></span>
<span class="min-w-0 flex-1 truncate font-medium text-ink">{{ $p['customer'] }}</span>
<span class="font-mono text-xs text-faint">{{ $p['when'] }}</span>
<span class="font-mono text-body">{{ $p['amount'] }}</span>
</li>
@empty
<li class="px-5 py-8 text-center text-sm text-faint">{{ __('admin.no_payments_yet') }}</li>
@endforelse
</ul>
</div>
</div>
</div>

View File

@ -0,0 +1,170 @@
<?php
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Livewire\Admin\Instances as AdminInstances;
use App\Livewire\Admin\Overview;
use App\Livewire\Admin\Revenue;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
/**
* The console reports what is in the database, or nothing.
*
* Its front page, its instance list and its revenue page were hard-coded
* fiction: forty-two customers, €7,842 a month, hosts named pve-fsn-1..3, a
* twelve-month growth curve. It read like a running business and measured
* nothing. These tests exist so that cannot come back quietly.
*/
it('reports an empty estate as empty, not as a going concern', function () {
// The strongest statement of the old bug: with nothing in the database the
// console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a
// month. The figures are asserted directly rather than searched for in the
// markup — "42" also occurs inside Livewire's own component ids.
Livewire::actingAs(operator('Owner'))
->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');
});

View File

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