Measure what the template draws

The panel could not show the storage ring or the transfer trend because nothing
measured them. instance_traffic keeps one row per billing period — right for an
allowance, useless as a series — and storage consumption was never sampled at
all, so the card could only ever state what was sold rather than what is used.

instance_metrics holds one row per instance per day, written by the traffic
collector on the visit it already makes. A second scheduler entry against the
same Proxmox API would have doubled the load and the failure modes for no gain.

Disk usage comes from `df` inside the guest rather than from Proxmox's own disk
figure, which reports the allocated image: that would show every customer a full
500 GB from their first day.

The rule the whole thing is built on: a reading that could not be taken is not a
reading of zero. The disk columns are nullable and left untouched when the guest
agent does not answer, so yesterday's figure stands instead of the ring dropping
to empty — which would tell a customer their data had vanished. Missing days
stay missing in the series rather than being filled with zeroes, which would
draw an outage that never happened. Both are covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-27 16:29:28 +02:00
parent c2a56382e1
commit ee5e92c0ed
10 changed files with 341 additions and 6 deletions

View File

@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
@ -50,6 +51,14 @@ class Dashboard extends Component
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
// The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
// fortnight, and drawing one at zero would tell its owner their
// data had vanished.
'disk' => $instance !== null ? $this->disk($instance) : null,
'trend' => $instance !== null
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
: [],
'proofs' => $this->proofs($instance, $maintenance),
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
// What the plan costs is true whether or not another invoice is
@ -65,6 +74,30 @@ class Dashboard extends Component
]);
}
/**
* How full the instance is, as measured not as sold.
*
* Null until the sampler has managed a reading. The card then states the
* contractual allowance, which is true, rather than a ring at a level
* nobody measured.
*
* @return array{used: int, total: int, percent: float}|null
*/
private function disk(Instance $instance): ?array
{
$metric = InstanceMetric::latestDisk($instance);
if ($metric === null || ! $metric->disk_total_bytes) {
return null;
}
return [
'used' => $metric->disk_used_bytes,
'total' => $metric->disk_total_bytes,
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
];
}
/** The address the customer actually reaches their cloud at. */
private function domain(?Instance $instance): ?string
{

View File

@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* A day in the life of one instance: how full it was, and what it moved.
*
* Read by the customer panel to draw the storage ring and the transfer trend.
* Written by CollectInstanceTraffic, which already visits every active
* instance a second scheduler entry hitting the same Proxmox API would only
* double the load and the failure modes.
*/
class InstanceMetric extends Model
{
protected $fillable = ['instance_id', 'day', 'disk_used_bytes', 'disk_total_bytes', 'rx_bytes', 'tx_bytes'];
protected function casts(): array
{
return [
'day' => 'date',
'disk_used_bytes' => 'integer',
'disk_total_bytes' => 'integer',
'rx_bytes' => 'integer',
'tx_bytes' => 'integer',
];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
/**
* The last $days days, oldest first, with gaps left as gaps.
*
* A missing day is not a zero. Filling it would draw a cliff into the chart
* on every day the sampler could not reach the host, and the customer would
* read an outage that never happened.
*
* @return Collection<int, self>
*/
public static function series(Instance $instance, int $days = 14): Collection
{
return self::query()
->where('instance_id', $instance->id)
->where('day', '>=', Carbon::today()->subDays($days - 1))
->orderBy('day')
->get();
}
/** The most recent day that carries a disk reading, or null. */
public static function latestDisk(Instance $instance): ?self
{
return self::query()
->where('instance_id', $instance->id)
->whereNotNull('disk_used_bytes')
->orderByDesc('day')
->first();
}
}

View File

@ -3,6 +3,7 @@
namespace App\Provisioning\Jobs;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\InstanceTraffic;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;
@ -101,9 +102,14 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
// A fresh row starts from this sample: whatever the VM transferred
// before we started counting this month is not ours to bill.
$rxDelta = 0;
$txDelta = 0;
if (! $row->wasRecentlyCreated) {
$row->rx_bytes += $this->delta($netin, $row->last_netin);
$row->tx_bytes += $this->delta($netout, $row->last_netout);
$rxDelta = $this->delta($netin, $row->last_netin);
$txDelta = $this->delta($netout, $row->last_netout);
$row->rx_bytes += $rxDelta;
$row->tx_bytes += $txDelta;
$row->last_netin = $netin;
$row->last_netout = $netout;
$row->sampled_at = now();
@ -111,6 +117,79 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
}
$this->enforce($client, $instance, $row);
$this->recordDay($client, $instance, $rxDelta ?? 0, $txDelta ?? 0);
}
/**
* Today's row for the customer panel's chart.
*
* Kept apart from the billing row on purpose: instance_traffic answers "how
* much of the allowance is gone this period", which must not be disturbed
* by anything drawn on a screen. This answers "what did the last fortnight
* look like", and a failure here must never cost someone their allowance —
* hence the try/catch around a job that has already done its real work.
*/
private function recordDay($client, Instance $instance, int $rxDelta, int $txDelta): void
{
try {
$disk = $this->diskUsage($client, $instance);
$metric = InstanceMetric::query()->firstOrCreate(
['instance_id' => $instance->id, 'day' => now()->toDateString()],
);
// Accumulated, because the sampler runs several times a day.
$metric->rx_bytes += max(0, $rxDelta);
$metric->tx_bytes += max(0, $txDelta);
// Overwritten rather than accumulated: a fill level is a state, not
// a flow. And left untouched when the reading failed, so yesterday's
// figure stands instead of the chart dropping to zero.
if ($disk !== null) {
$metric->disk_used_bytes = $disk['used'];
$metric->disk_total_bytes = $disk['total'];
}
$metric->save();
} catch (Throwable $e) {
Log::warning('daily metric failed', [
'instance' => $instance->uuid, 'error' => $e->getMessage(),
]);
}
}
/**
* How full the instance's data disk is, via the guest agent.
*
* Proxmox's own `disk` figure for a VM is the allocated image, not what is
* used inside it it would show a customer 500 GB from the first day.
* `df` in the guest is the number they recognise.
*
* @return array{used: int, total: int}|null
*/
private function diskUsage($client, Instance $instance): ?array
{
$node = $instance->host->node ?? 'pve';
if (! $client->guestAgentPing($node, $instance->vmid)) {
return null;
}
// -B1 so the numbers are bytes and no locale can reinterpret them.
$result = $client->guestExec($node, $instance->vmid, 'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /');
$out = trim((string) ($result['out-data'] ?? $result['output'] ?? ''));
// Header line, then one line of two integers. Anything else is a shell
// that answered something we did not ask for, and is discarded rather
// than parsed hopefully.
$lines = preg_split('/\r?\n/', $out) ?: [];
$last = trim((string) end($lines));
if (! preg_match('/^(\d+)\s+(\d+)$/', $last, $m)) {
return null;
}
return ['total' => (int) $m[1], 'used' => (int) $m[2]];
}
private function releasePreviousPeriodThrottle($client, Instance $instance): void

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* One row per instance per day.
*
* instance_traffic keeps a single row per BILLING period, which is what the
* allowance needs but it means there is no series to draw, and the customer
* panel could only ever show a number. Storage consumption was not sampled at
* all, so the panel stated the contractual allowance and nothing about what is
* actually in use.
*
* A day is the right grain: fine enough for a fortnight of trend, coarse enough
* that a year of an instance is 365 rows.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('instance_metrics', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained()->cascadeOnDelete();
$table->date('day');
// Nullable, and deliberately: a sample that could not be taken must
// be distinguishable from a sample that read zero. Charting a failed
// read as 0 GB is how a panel tells a customer their data is gone.
$table->unsignedBigInteger('disk_used_bytes')->nullable();
$table->unsignedBigInteger('disk_total_bytes')->nullable();
$table->unsignedBigInteger('rx_bytes')->default(0);
$table->unsignedBigInteger('tx_bytes')->default(0);
$table->timestamps();
// The sampler runs more often than daily and accumulates into the
// current day's row; the constraint is what makes that safe under
// two workers.
$table->unique(['instance_id', 'day']);
});
}
public function down(): void
{
Schema::dropIfExists('instance_metrics');
}
};

View File

@ -36,6 +36,7 @@ return [
'users' => 'Benutzer',
'address' => 'Adresse',
'storage' => 'Speicher',
'storage_used' => ':percent % belegt',
'storage_note' => 'vertraglich zugesichert',
'location_note' => 'kein Drittlandtransfer',
'gb' => ':n GB',

View File

@ -36,6 +36,7 @@ return [
'users' => 'Users',
'address' => 'Address',
'storage' => 'Storage',
'storage_used' => ':percent % used',
'storage_note' => 'contractually guaranteed',
'location_note' => 'no third-country transfer',
'gb' => ':n GB',

View File

@ -18,7 +18,7 @@
ring, a bar or a sparkline and so a metric with nothing to draw simply
passes none instead of getting a decorative one.
--}}
<article {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface p-5 shadow-xs']) }}>
<article {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface p-5 shadow-xs']) }}>
<div class="flex items-center justify-between gap-3">
<span class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $label }}</span>
@if ($href)
@ -30,7 +30,7 @@
<div class="mt-3 flex items-end justify-between gap-4">
<div class="min-w-0">
<p class="flex items-baseline gap-1.5 font-mono text-2xl font-semibold tracking-tight text-ink">
<p class="flex flex-wrap items-baseline gap-x-1.5 font-mono text-2xl font-semibold leading-tight tracking-tight text-ink">
{{ $value }}
@if ($unit)<span class="text-sm font-normal text-muted">{{ $unit }}</span>@endif
</p>

View File

@ -22,7 +22,7 @@
@endphp
@if ($path !== '')
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
style="width:96px;height:34px" aria-hidden="true">
style="width:80px;height:32px" aria-hidden="true">
@if ($area)
<path class="area" d="{{ $path }} L96 34 L0 34 Z" />
@endif

View File

@ -65,7 +65,21 @@
what is true instead of drawing a chart at an invented level. This
is the sheet a customer forwards to their auditor. --}}
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@if ($instance->quota_gb)
{{-- Storage. The ring appears once there is a reading; until then the
card states the allowance, which is true, rather than drawing a
level nobody measured. --}}
@if ($disk)
<x-ui.metric
:label="__('dashboard.master.storage')"
:value="\Illuminate\Support\Number::format($disk['used'] / 1024 ** 3, precision: 0, locale: $locale)"
:unit="'/ '.\Illuminate\Support\Number::format($disk['total'] / 1024 ** 3, precision: 0, locale: $locale).' GB'"
:foot="__('dashboard.master.storage_used', ['percent' => \Illuminate\Support\Number::format($disk['percent'], precision: 0, locale: $locale)])"
:href="route('cloud')"
class="animate-rise [animation-delay:60ms]"
>
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="58" /></x-slot:visual>
</x-ui.metric>
@elseif ($instance->quota_gb)
<x-ui.metric
:label="__('dashboard.master.storage')"
:value="$instance->quota_gb"
@ -102,6 +116,13 @@
:href="route('billing')"
class="animate-rise [animation-delay:140ms]"
>
@if (count($trend) > 2)
{{-- Orange because this is the metric with an action
attached: when it runs out, something can be done
about it. Observed figures stay grey. --}}
<x-slot:visual><x-ui.spark :points="$trend" area /></x-slot:visual>
@endif
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
<div @class([
'h-full rounded-pill',

View File

@ -0,0 +1,87 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\User;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* The measurements behind the panel's ring and trend.
*
* The rule they all serve: a reading that could not be taken is not a reading
* of zero. Charting a failed sample as 0 GB tells a customer their data is
* gone, and charting a missing day as no traffic draws an outage that never
* happened.
*/
function metricFor(Instance $instance, string $day, ?int $used, ?int $total, int $rx = 0, int $tx = 0): InstanceMetric
{
return InstanceMetric::create([
'instance_id' => $instance->id,
'day' => $day,
'disk_used_bytes' => $used,
'disk_total_bytes' => $total,
'rx_bytes' => $rx,
'tx_bytes' => $tx,
]);
}
function customerWithInstance(): array
{
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id]);
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
return [$user, $instance];
}
it('shows the allowance, not a ring, until something has been measured', function () {
[$user, $instance] = customerWithInstance();
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('disk', null)
// The contractual figure is true and says so; a ring at 0 % would read
// as "your cloud is empty".
->assertSee(__('dashboard.master.storage_note'));
});
it('draws the ring once a reading exists', function () {
[$user, $instance] = customerWithInstance();
metricFor($instance, Carbon::today()->toDateString(), used: 235_000_000_000, total: 500_000_000_000);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('disk', fn (?array $d) => $d !== null && (int) round($d['percent']) === 47);
});
it('keeps the last good reading when a sample could not be taken', function () {
[$user, $instance] = customerWithInstance();
metricFor($instance, Carbon::yesterday()->toDateString(), used: 200_000_000_000, total: 500_000_000_000);
// Today's row exists — the traffic half of the sampler wrote it — but the
// guest agent did not answer, so the disk columns stayed null.
metricFor($instance, Carbon::today()->toDateString(), used: null, total: null, rx: 5_000);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('disk', fn (?array $d) => $d !== null && $d['used'] === 200_000_000_000);
});
it('passes the measured days to the trend, and only those', function () {
[$user, $instance] = customerWithInstance();
metricFor($instance, Carbon::today()->subDays(2)->toDateString(), null, null, rx: 1_000, tx: 500);
metricFor($instance, Carbon::today()->subDay()->toDateString(), null, null, rx: 2_000, tx: 500);
metricFor($instance, Carbon::today()->toDateString(), null, null, rx: 4_000, tx: 1_000);
// Outside the window the panel draws.
metricFor($instance, Carbon::today()->subDays(40)->toDateString(), null, null, rx: 9_999_999);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('trend', [1_500, 2_500, 5_000]);
});