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]); +});