From ee5e92c0ed67d3255a208e36e35833d43f5b3d2f Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 16:29:28 +0200 Subject: [PATCH] Measure what the template draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Dashboard.php | 33 +++++++ app/Models/InstanceMetric.php | 65 ++++++++++++++ .../Jobs/CollectInstanceTraffic.php | 83 +++++++++++++++++- ...7_150000_create_instance_metrics_table.php | 48 ++++++++++ lang/de/dashboard.php | 1 + lang/en/dashboard.php | 1 + .../views/components/ui/metric.blade.php | 4 +- resources/views/components/ui/spark.blade.php | 2 +- resources/views/livewire/dashboard.blade.php | 23 ++++- tests/Feature/InstanceMetricsTest.php | 87 +++++++++++++++++++ 10 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 app/Models/InstanceMetric.php create mode 100644 database/migrations/2026_07_27_150000_create_instance_metrics_table.php create mode 100644 tests/Feature/InstanceMetricsTest.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 40b8a2f..acbc235 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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 { diff --git a/app/Models/InstanceMetric.php b/app/Models/InstanceMetric.php new file mode 100644 index 0000000..005bbdf --- /dev/null +++ b/app/Models/InstanceMetric.php @@ -0,0 +1,65 @@ + '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 + */ + 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(); + } +} diff --git a/app/Provisioning/Jobs/CollectInstanceTraffic.php b/app/Provisioning/Jobs/CollectInstanceTraffic.php index 4bef001..f330edf 100644 --- a/app/Provisioning/Jobs/CollectInstanceTraffic.php +++ b/app/Provisioning/Jobs/CollectInstanceTraffic.php @@ -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 diff --git a/database/migrations/2026_07_27_150000_create_instance_metrics_table.php b/database/migrations/2026_07_27_150000_create_instance_metrics_table.php new file mode 100644 index 0000000..56a23da --- /dev/null +++ b/database/migrations/2026_07_27_150000_create_instance_metrics_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index e84590a..06a7795 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -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', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 6026c2d..b0dad67 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -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', diff --git a/resources/views/components/ui/metric.blade.php b/resources/views/components/ui/metric.blade.php index 491dc4f..3fdce7d 100644 --- a/resources/views/components/ui/metric.blade.php +++ b/resources/views/components/ui/metric.blade.php @@ -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. --}} -
merge(['class' => 'rounded-lg border border-line bg-surface p-5 shadow-xs']) }}> +
merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface p-5 shadow-xs']) }}>
{{ $label }} @if ($href) @@ -30,7 +30,7 @@
-

+

{{ $value }} @if ($unit){{ $unit }}@endif

diff --git a/resources/views/components/ui/spark.blade.php b/resources/views/components/ui/spark.blade.php index cb5e788..97246b3 100644 --- a/resources/views/components/ui/spark.blade.php +++ b/resources/views/components/ui/spark.blade.php @@ -22,7 +22,7 @@ @endphp @if ($path !== '')
$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]); +});