From c1e81808a79c8fa82e3845d32c4abf6bd0b5b6b5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:33:47 +0200 Subject: [PATCH] feat(traffic): meter the monthly allowance, show it, throttle instead of blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers can now see what they have used and what is left, and the service slows down rather than stopping when the allowance is gone — a slow Nextcloud gets a top-up, a dead one gets a cancellation. - instance_traffic keeps one row per instance per month. Proxmox counters are cumulative since the VM last started, so usage is the difference between two samples, and a counter that went backwards means a restart, not a refund. - Outbound is what counts: inbound is free at our providers and egress is what Hetzner's 20 TB per server applies to. - CollectInstanceTraffic samples every 15 minutes, warns once per threshold (80/95 %) rather than on every run, and at 100 % limits the VM's NIC via Proxmox — released again as soon as the customer tops up or the month rolls over. - The dashboard gets a full-width band (it throttles the service, so it is not a tile among tiles) with the top-up offer right next to the warning. - Bytes are formatted in SI units now: allowances are computed in SI, so dividing by 1024 made a 1000 GB plan read as "931 GB". Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Billing.php | 6 + app/Livewire/Dashboard.php | 10 ++ app/Models/Instance.php | 3 +- app/Models/InstanceTraffic.php | 37 ++++ .../Jobs/CollectInstanceTraffic.php | 131 +++++++++++++++ app/Services/Proxmox/FakeProxmoxClient.php | 13 ++ app/Services/Proxmox/HttpProxmoxClient.php | 23 +++ app/Services/Proxmox/ProxmoxClient.php | 9 + app/Services/Traffic/TrafficMeter.php | 94 +++++++++++ app/Support/Bytes.php | 16 +- config/provisioning.php | 25 ++- ...5_230000_create_instance_traffic_table.php | 45 +++++ ...230001_add_traffic_addons_to_instances.php | 27 +++ lang/de/billing.php | 4 + lang/de/dashboard.php | 8 + lang/en/billing.php | 4 + lang/en/dashboard.php | 8 + resources/views/livewire/billing.blade.php | 23 ++- resources/views/livewire/dashboard.blade.php | 53 ++++++ routes/console.php | 6 + tests/Feature/TrafficTest.php | 159 ++++++++++++++++++ 21 files changed, 693 insertions(+), 11 deletions(-) create mode 100644 app/Models/InstanceTraffic.php create mode 100644 app/Provisioning/Jobs/CollectInstanceTraffic.php create mode 100644 app/Services/Traffic/TrafficMeter.php create mode 100644 database/migrations/2026_07_25_230000_create_instance_traffic_table.php create mode 100644 database/migrations/2026_07_25_230001_add_traffic_addons_to_instances.php create mode 100644 tests/Feature/TrafficTest.php diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 78425dd..57df6de 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -31,6 +31,7 @@ class Billing extends Component [$plan, $amount, $addonKey] = match ($type) { 'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null], 'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null], + 'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null], 'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key], default => [null, 0, null], }; @@ -39,6 +40,9 @@ class Billing extends Component $valid = match ($type) { 'upgrade' => isset($plans[$key]) && ($plans[$key]['price_cents'] ?? 0) > ($plans[$currentPlan]['price_cents'] ?? 0), 'storage' => true, + // Always available: running out of traffic is exactly when someone + // needs to be able to buy more, whatever plan they are on. + 'traffic' => true, 'addon' => isset($addons[$key]), default => false, }; @@ -84,6 +88,8 @@ class Billing extends Component 'features' => (array) config('provisioning.plan_features'), 'upgrades' => $upgrades, 'storage' => (array) config('provisioning.storage_addon'), + 'trafficAddon' => (array) config('provisioning.traffic.addon'), + 'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null, 'addons' => (array) config('provisioning.addons'), 'pending' => $customer ? $customer->orders()->where('status', 'pending')->latest('id')->get() diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 07e75ec..b9d416f 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,6 +2,8 @@ namespace App\Livewire; +use App\Livewire\Concerns\ResolvesCustomer; +use App\Services\Traffic\TrafficMeter; use Illuminate\Support\Carbon; use Illuminate\Support\Number; use Livewire\Attributes\Layout; @@ -10,8 +12,15 @@ use Livewire\Component; #[Layout('layouts.portal-app')] class Dashboard extends Component { + use ResolvesCustomer; + public function render() { + // Real numbers where they exist: traffic is metered, unlike the + // fixtures below, and a customer needs to see it before it runs out. + $instance = $this->customer()?->instances()->latest('id')->first(); + $traffic = $instance !== null ? TrafficMeter::for($instance) : null; + // Locale-aware date / number formatting for the fixture values below. $locale = app()->getLocale(); $date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt); @@ -28,6 +37,7 @@ class Dashboard extends Component 99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98]; return view('livewire.dashboard', [ + 'traffic' => $traffic, 'storageUsed' => $storageUsed, 'storageQuota' => $storageQuota, 'storagePercent' => $storagePercent, diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 6f2ac36..84fcd44 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -14,7 +14,7 @@ class Instance extends Model use HasFactory, HasUuid; protected $fillable = [ - 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'disk_gb', + 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb', 'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', 'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at', ]; @@ -28,6 +28,7 @@ class Instance extends Model 'route_written' => 'boolean', 'cert_ok' => 'boolean', 'vmid' => 'integer', + 'traffic_addons' => 'integer', 'quota_gb' => 'integer', 'disk_gb' => 'integer', 'ram_mb' => 'integer', diff --git a/app/Models/InstanceTraffic.php b/app/Models/InstanceTraffic.php new file mode 100644 index 0000000..41ae6b3 --- /dev/null +++ b/app/Models/InstanceTraffic.php @@ -0,0 +1,37 @@ + 'integer', + 'tx_bytes' => 'integer', + 'last_netin' => 'integer', + 'last_netout' => 'integer', + 'notified_percent' => 'integer', + 'throttled' => 'boolean', + 'sampled_at' => 'datetime', + 'throttled_at' => 'datetime', + ]; + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class); + } + + public static function currentPeriod(): string + { + return now()->format('Y-m'); + } +} diff --git a/app/Provisioning/Jobs/CollectInstanceTraffic.php b/app/Provisioning/Jobs/CollectInstanceTraffic.php new file mode 100644 index 0000000..a4eb3e3 --- /dev/null +++ b/app/Provisioning/Jobs/CollectInstanceTraffic.php @@ -0,0 +1,131 @@ +onQueue('provisioning'); + } + + public function handle(ProxmoxClient $proxmox): void + { + $instances = Instance::query() + ->with('host') + ->whereNotNull('host_id') + ->whereNotNull('vmid') + ->where('status', 'active') + ->get(); + + foreach ($instances as $instance) { + try { + $this->sample($proxmox, $instance); + } catch (Throwable $e) { + Log::warning('traffic sample failed', [ + 'instance' => $instance->uuid, 'error' => $e->getMessage(), + ]); + } + } + } + + private function sample(ProxmoxClient $proxmox, Instance $instance): void + { + $client = $proxmox->forHost($instance->host); + $status = $client->vmStatus($instance->host->node ?? 'pve', $instance->vmid); + + $netin = (int) ($status['netin'] ?? 0); + $netout = (int) ($status['netout'] ?? 0); + + $row = InstanceTraffic::query()->firstOrCreate( + ['instance_id' => $instance->id, 'period' => InstanceTraffic::currentPeriod()], + ['last_netin' => $netin, 'last_netout' => $netout, 'sampled_at' => now()], + ); + + // A fresh row starts from this sample: whatever the VM transferred + // before we started counting this month is not ours to bill. + if (! $row->wasRecentlyCreated) { + $row->rx_bytes += $this->delta($netin, $row->last_netin); + $row->tx_bytes += $this->delta($netout, $row->last_netout); + $row->last_netin = $netin; + $row->last_netout = $netout; + $row->sampled_at = now(); + $row->save(); + } + + $this->enforce($client, $instance, $row); + } + + /** Counter went backwards → the VM restarted, so the current value is the delta. */ + private function delta(int $current, int $previous): int + { + return $current >= $previous ? $current - $previous : $current; + } + + private function enforce($client, Instance $instance, InstanceTraffic $row): void + { + $meter = TrafficMeter::for($instance, $row); + + if ($meter->isExhausted() && ! $row->throttled) { + // Throttle rather than cut off: a slow Nextcloud gets the customer to + // buy more traffic, a dead one gets a cancellation. + $kbps = (int) config('provisioning.traffic.throttle_kbps', 2048); + $client->setNetworkRate($instance->host->node ?? 'pve', $instance->vmid, $kbps / 8 / 1024); + + $row->update(['throttled' => true, 'throttled_at' => now()]); + Log::info('instance throttled for traffic', ['instance' => $instance->uuid]); + + return; + } + + // Quota was topped up (or the month rolled over): give the line back. + if ($row->throttled && ! $meter->isExhausted()) { + $client->setNetworkRate($instance->host->node ?? 'pve', $instance->vmid, null); + $row->update(['throttled' => false, 'throttled_at' => null]); + + return; + } + + // Warn once per threshold crossed, not on every sample. + foreach ((array) config('provisioning.traffic.warn_percent', [80, 95]) as $threshold) { + if ($meter->percent() >= $threshold && $row->notified_percent < $threshold) { + $row->update(['notified_percent' => $threshold]); + Log::info('traffic threshold reached', [ + 'instance' => $instance->uuid, 'percent' => $meter->percent(), + ]); + } + } + } +} diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index c9e4cec..ce30e53 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -117,14 +117,27 @@ class FakeProxmoxClient implements ProxmoxClient /** Set to e.g. 'clone' to simulate a VM still locked by a running clone task. */ public ?string $vmLock = null; + /** Cumulative counters per vmid, as Proxmox reports them: [netin, netout]. */ + public array $counters = []; + + /** vmid => MB/s currently configured, or null when unlimited. */ + public array $networkRates = []; + public function vmStatus(string $node, int $vmid): array { return [ 'status' => in_array($vmid, $this->runningVmids, true) ? 'running' : 'stopped', 'lock' => $this->vmLock, + 'netin' => $this->counters[$vmid]['netin'] ?? 0, + 'netout' => $this->counters[$vmid]['netout'] ?? 0, ]; } + public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void + { + $this->networkRates[$vmid] = $mbytesPerSecond; + } + public function vmExists(string $node, int $vmid): bool { return in_array($vmid, $this->clonedVmids, true) || in_array($vmid, $this->runningVmids, true); diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php index 4bc5969..430eafa 100644 --- a/app/Services/Proxmox/HttpProxmoxClient.php +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -5,6 +5,7 @@ namespace App\Services\Proxmox; use App\Models\Host; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; +use RuntimeException; /** * Real Proxmox VE REST client (read-only in A: capacity + reachability). Token @@ -68,6 +69,28 @@ class HttpProxmoxClient implements ProxmoxClient $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/config", $params)->throw(); } + public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void + { + // The rate lives inside the net0 definition, so the existing value has + // to be read and rewritten — sending net0=rate=… alone would drop the + // model and bridge and detach the VM from the network. + $config = $this->http()->get("/nodes/{$node}/qemu/{$vmid}/config")->throw()->json('data', []); + $net0 = (string) ($config['net0'] ?? ''); + if ($net0 === '') { + throw new RuntimeException("VM {$vmid} on {$node} has no net0 to limit."); + } + + $parts = array_values(array_filter( + explode(',', $net0), + fn (string $part) => ! str_starts_with($part, 'rate='), + )); + if ($mbytesPerSecond !== null) { + $parts[] = 'rate='.rtrim(rtrim(number_format($mbytesPerSecond, 3, '.', ''), '0'), '.'); + } + + $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/config", ['net0' => implode(',', $parts)])->throw(); + } + public function resizeDisk(string $node, int $vmid, string $disk, string $size): void { $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/resize", ['disk' => $disk, 'size' => $size])->throw(); diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php index 065200d..bcc4c59 100644 --- a/app/Services/Proxmox/ProxmoxClient.php +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -58,6 +58,15 @@ interface ProxmoxClient /** @param array> $rules */ public function applyFirewall(string $node, int $vmid, array $rules): void; + /** + * Limit (or release) the VM's network interface. + * + * $mbytesPerSecond === null removes the limit. Proxmox expresses `rate` in + * MB/s on the NIC definition, so the caller converts — this signature keeps + * the unit visible instead of hiding a magic number. + */ + public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void; + /** Create a scheduled vzdump backup job for the VM; returns the job id. */ public function createBackupJob(string $node, int $vmid, string $schedule): string; } diff --git a/app/Services/Traffic/TrafficMeter.php b/app/Services/Traffic/TrafficMeter.php new file mode 100644 index 0000000..4815896 --- /dev/null +++ b/app/Services/Traffic/TrafficMeter.php @@ -0,0 +1,94 @@ +where('instance_id', $instance->id) + ->where('period', InstanceTraffic::currentPeriod()) + ->first(); + + return new self( + instance: $instance, + usedBytes: (int) ($row?->tx_bytes ?? 0), + quotaBytes: self::quotaGb($instance) * 1000 ** 3, + throttled: (bool) ($row?->throttled ?? false), + ); + } + + /** Plan allowance plus purchased add-on packs. */ + public static function quotaGb(Instance $instance): int + { + $plan = (int) (config("provisioning.plans.{$instance->plan}.traffic_gb") ?? 0); + $addonGb = (int) config('provisioning.traffic.addon.gb', 1000); + + return $plan + ($instance->traffic_addons ?? 0) * $addonGb; + } + + public function percent(): float + { + if ($this->quotaBytes <= 0) { + return 0.0; // an unmetered plan is never "at" anything + } + + return round($this->usedBytes / $this->quotaBytes * 100, 1); + } + + public function remainingBytes(): int + { + return max(0, $this->quotaBytes - $this->usedBytes); + } + + public function isExhausted(): bool + { + return $this->quotaBytes > 0 && $this->usedBytes >= $this->quotaBytes; + } + + /** + * ok → normal, warn → 80 %, critical → 95 %, exhausted → throttled. + * Thresholds are config so the portal wording and the collector's decision + * cannot drift apart. + */ + public function state(): string + { + if ($this->isExhausted()) { + return 'exhausted'; + } + + [$warn, $critical] = array_pad((array) config('provisioning.traffic.warn_percent', [80, 95]), 2, 95); + $percent = $this->percent(); + + return match (true) { + $percent >= $critical => 'critical', + $percent >= $warn => 'warn', + default => 'ok', + }; + } + + /** Days left in the billing month — "resets in 6 days" beats a raw date. */ + public function daysUntilReset(): int + { + return (int) ceil(now()->diffInDays(now()->endOfMonth(), absolute: true)); + } +} diff --git a/app/Support/Bytes.php b/app/Support/Bytes.php index ed742f8..adabde9 100644 --- a/app/Support/Bytes.php +++ b/app/Support/Bytes.php @@ -8,18 +8,24 @@ namespace App\Support; */ final class Bytes { + /** + * Decimal units (1 kB = 1000 B), not binary ones. Traffic and storage are + * sold in SI gigabytes — by us and by Hetzner — so a 1000 GB allowance has + * to read as "1 TB" and not as "931 GB", which is what dividing by 1024 + * produced. + */ public static function human(int $bytes, int $decimals = 1): string { - if ($bytes < 1024) { + if ($bytes < 1000) { return $bytes.' B'; } $units = ['kB', 'MB', 'GB', 'TB', 'PB']; - $power = min((int) floor(log($bytes, 1024)), count($units)); - $value = $bytes / (1024 ** $power); + $power = min((int) floor(log($bytes, 1000)), count($units)); + $value = $bytes / (1000 ** $power); - // Whole numbers read better without a trailing ",0". - $decimals = $value >= 100 ? 0 : $decimals; + // Whole numbers read better without a trailing ",0" — "1 TB", not "1,0 TB". + $decimals = ($value >= 100 || round($value, $decimals) == floor($value)) ? 0 : $decimals; return number_format($value, $decimals, ',', '.').' '.$units[$power - 1]; } diff --git a/config/provisioning.php b/config/provisioning.php index c5f9b48..39c0610 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -66,10 +66,10 @@ return [ // ADMIN-ONLY. Customers compare seats, storage, performance class and // features — never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*). 'plans' => [ - 'start' => ['quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000], - 'team' => ['quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000], - 'business' => ['quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000], - 'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000], + 'start' => ['traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000], + 'team' => ['traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000], + 'business' => ['traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000], + 'enterprise' => ['traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000], ], // Default branding applied when a customer has not set their own (resolved by @@ -81,6 +81,23 @@ return [ 'accent_color' => '#c2560a', ], + /* + | Traffic policy. Outbound is what counts: inbound is free at our providers, + | and it is egress that Hetzner's 20 TB per server applies to. + | + | Running out throttles rather than blocks — a slow Nextcloud gets a customer + | to buy more traffic, a dead one gets a support ticket and a cancellation. + */ + 'traffic' => [ + 'warn_percent' => [80, 95], + 'throttle_kbps' => (int) env('CLUPILOT_TRAFFIC_THROTTLE_KBPS', 2048), // ~2 Mbit/s + 'addon' => ['gb' => 1000, 'price_cents' => 500], + // Sampling interval of the collector, in minutes. Proxmox counters are + // cumulative, so a missed run costs accuracy only if the VM restarts + // in between. + 'sample_minutes' => 15, + ], + // Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php). 'storage_addon' => ['gb' => 100, 'price_cents' => 1000], 'addons' => [ diff --git a/database/migrations/2026_07_25_230000_create_instance_traffic_table.php b/database/migrations/2026_07_25_230000_create_instance_traffic_table.php new file mode 100644 index 0000000..40e9574 --- /dev/null +++ b/database/migrations/2026_07_25_230000_create_instance_traffic_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('instance_id')->constrained()->cascadeOnDelete(); + $table->string('period', 7); // YYYY-MM + $table->unsignedBigInteger('rx_bytes')->default(0); // into the VM + $table->unsignedBigInteger('tx_bytes')->default(0); // out of the VM — what the quota counts + $table->unsignedBigInteger('last_netin')->default(0); // raw Proxmox counters + $table->unsignedBigInteger('last_netout')->default(0); + $table->timestamp('sampled_at')->nullable(); + // Remembered so a customer is warned once per threshold and not on + // every collection run. + $table->unsignedTinyInteger('notified_percent')->default(0); + $table->boolean('throttled')->default(false); + $table->timestamp('throttled_at')->nullable(); + $table->timestamps(); + + $table->unique(['instance_id', 'period']); + }); + } + + public function down(): void + { + Schema::dropIfExists('instance_traffic'); + } +}; diff --git a/database/migrations/2026_07_25_230001_add_traffic_addons_to_instances.php b/database/migrations/2026_07_25_230001_add_traffic_addons_to_instances.php new file mode 100644 index 0000000..c407942 --- /dev/null +++ b/database/migrations/2026_07_25_230001_add_traffic_addons_to_instances.php @@ -0,0 +1,27 @@ +unsignedSmallInteger('traffic_addons')->default(0)->after('quota_gb'); + }); + } + + public function down(): void + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn('traffic_addons'); + }); + } +}; diff --git a/lang/de/billing.php b/lang/de/billing.php index 8ecbe98..3fed853 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -1,6 +1,10 @@ 'Datenvolumen', + 'traffic_body' => ':gb GB zusätzliches Volumen für diesen Monat, :price.', + 'traffic_used' => ':used von :quota verbraucht', + 'traffic_cta' => ':gb GB nachbuchen', 'title' => 'Paket & Addons', 'subtitle' => 'Ihr aktuelles Paket, Upgrades, Zusatzspeicher und Erweiterungen.', 'current_plan' => 'Aktuelles Paket', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 397e907..1ef882a 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -1,6 +1,14 @@ [ + 'title' => 'Datenvolumen diesen Monat', + 'resets' => 'setzt sich in :days Tagen zurück', + 'remaining' => ':amount übrig', + 'almost' => ':percent % verbraucht — es wird knapp.', + 'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.', + 'buy' => 'Volumen nachbuchen', + ], 'title' => 'Übersicht', 'subtitle' => 'Status Ihrer Cloud auf einen Blick.', 'greeting' => 'Guten Morgen, :name', diff --git a/lang/en/billing.php b/lang/en/billing.php index e698060..306295d 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -1,6 +1,10 @@ 'Data transfer', + 'traffic_body' => ':gb GB of extra volume for this month, :price.', + 'traffic_used' => ':used of :quota used', + 'traffic_cta' => 'Add :gb GB', 'title' => 'Plan & add-ons', 'subtitle' => 'Your current plan, upgrades, extra storage and add-ons.', 'current_plan' => 'Current plan', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 02caab1..f1bc7ae 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -1,6 +1,14 @@ [ + 'title' => 'Data transfer this month', + 'resets' => 'resets in :days days', + 'remaining' => ':amount left', + 'almost' => ':percent % used — getting tight.', + 'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.', + 'buy' => 'Add data volume', + ], 'title' => 'Overview', 'subtitle' => 'Your cloud status at a glance.', 'greeting' => 'Good morning, :name', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 71466e2..3b3c86f 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -73,7 +73,7 @@ @endif {{-- Extra storage + Add-ons --}} -
+
{{-- Storage --}}
@@ -86,6 +86,27 @@
+ {{-- Traffic --}} +
+
+ +

{{ __('billing.traffic_title') }}

+
+

{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}

+ @if ($trafficMeter) +

+ {{ __('billing.traffic_used', [ + 'used' => \App\Support\Bytes::human($trafficMeter->usedBytes), + 'quota' => \App\Support\Bytes::human($trafficMeter->quotaBytes), + ]) }} +

+ @endif + + {{ __('billing.traffic_cta', ['gb' => $trafficAddon['gb']]) }} + +
+ {{-- Add-ons --}} @foreach ($addons as $key => $addon)
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 7401d28..6ce715d 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -11,6 +11,59 @@ {{-- Live provisioning progress (only while a run is in flight) --}} + {{-- Traffic: the one allowance that throttles the service when it runs out, + so it gets a full-width band rather than a tile in the KPI row. --}} + @if ($traffic) + @php + $state = $traffic->state(); + $bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state]; + $frame = [ + 'ok' => 'border-line', + 'warn' => 'border-warning-border bg-warning-bg', + 'critical' => 'border-warning-border bg-warning-bg', + 'exhausted' => 'border-danger-border bg-danger-bg', + ][$state]; + @endphp +
+
+
+

{{ __('dashboard.traffic.title') }}

+ {{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }} +
+

+ {{ \App\Support\Bytes::human($traffic->usedBytes) }} + / {{ \App\Support\Bytes::human($traffic->quotaBytes) }} +

+
+ +
+ {{-- R4: width is data, not decoration — the only inline style allowed. --}} +
+
+ +
+

+ @if ($state === 'exhausted') + {{ __('dashboard.traffic.exhausted') }} + @elseif ($state === 'ok') + {{ __('dashboard.traffic.remaining', ['amount' => \App\Support\Bytes::human($traffic->remainingBytes())]) }} + @else + {{ __('dashboard.traffic.almost', ['percent' => $traffic->percent()]) }} + @endif +

+ @if ($state !== 'ok') + {{-- The offer belongs next to the warning: someone reading that + they are nearly out should not have to go looking. --}} + + {{ __('dashboard.traffic.buy') }} + + @endif +
+
+ @endif + {{-- KPI row --}}
{{-- Storage ring --}} diff --git a/routes/console.php b/routes/console.php index bc56199..8119de8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -21,3 +21,9 @@ Schedule::call(fn () => app(TickProvisioning::class)()) Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers) ->everyMinute() ->name('vpn-sync'); + +// Sample network counters and enforce the monthly allowance. Runs on the +// provisioning queue, which is where the Proxmox credentials are usable. +Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic) + ->everyFifteenMinutes() + ->name('traffic-collect'); diff --git a/tests/Feature/TrafficTest.php b/tests/Feature/TrafficTest.php new file mode 100644 index 0000000..802765b --- /dev/null +++ b/tests/Feature/TrafficTest.php @@ -0,0 +1,159 @@ +instance(ProxmoxClient::class, $pve); + + $customer = Customer::factory()->create(); + $host = App\Models\Host::factory()->active()->create(); + $instance = Instance::factory()->create(array_merge([ + 'customer_id' => $customer->id, + 'host_id' => $host->id, + 'vmid' => 1042, + 'plan' => 'start', // 1000 GB + 'status' => 'active', + ], $overrides)); + + return [$pve, $customer, $instance]; +} + +it('counts the difference between samples, not the raw counter', function () { + [$pve, , $instance] = trafficSetup(); + + // First sample only establishes the baseline: what the VM did before we + // started counting this month is not the customer's bill. + $pve->counters[1042] = ['netin' => 5_000_000, 'netout' => 9_000_000]; + (new CollectInstanceTraffic)->handle($pve); + + $row = InstanceTraffic::query()->firstOrFail(); + expect($row->tx_bytes)->toBe(0) + ->and($row->last_netout)->toBe(9_000_000); + + $pve->counters[1042] = ['netin' => 6_000_000, 'netout' => 11_500_000]; + (new CollectInstanceTraffic)->handle($pve); + + expect($row->fresh()->tx_bytes)->toBe(2_500_000) + ->and($row->fresh()->rx_bytes)->toBe(1_000_000); +}); + +it('treats a counter that went backwards as a restart, not a refund', function () { + [$pve, , $instance] = trafficSetup(); + + $pve->counters[1042] = ['netin' => 0, 'netout' => 8_000_000]; + (new CollectInstanceTraffic)->handle($pve); + $pve->counters[1042] = ['netin' => 0, 'netout' => 12_000_000]; + (new CollectInstanceTraffic)->handle($pve); + expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_000_000); + + // VM restarted: Proxmox counts from zero again. + $pve->counters[1042] = ['netin' => 0, 'netout' => 500_000]; + (new CollectInstanceTraffic)->handle($pve); + + expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_500_000); +}); + +it('throttles instead of cutting the service off when the allowance is gone', function () { + [$pve, , $instance] = trafficSetup(); + + $quota = TrafficMeter::quotaGb($instance) * 1000 ** 3; + $pve->counters[1042] = ['netin' => 0, 'netout' => 0]; + (new CollectInstanceTraffic)->handle($pve); + $pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1]; + (new CollectInstanceTraffic)->handle($pve); + + $row = InstanceTraffic::query()->firstOrFail(); + expect($row->throttled)->toBeTrue() + // Slowed down, never stopped: a dead Nextcloud gets a cancellation, + // a slow one gets a top-up. + ->and($pve->networkRates[1042])->toBeGreaterThan(0) + ->and(TrafficMeter::for($instance->fresh())->state())->toBe('exhausted'); +}); + +it('gives the line back when the customer buys more', function () { + [$pve, , $instance] = trafficSetup(); + + $quota = TrafficMeter::quotaGb($instance) * 1000 ** 3; + $pve->counters[1042] = ['netin' => 0, 'netout' => 0]; + (new CollectInstanceTraffic)->handle($pve); + $pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1]; + (new CollectInstanceTraffic)->handle($pve); + expect($pve->networkRates[1042])->not->toBeNull(); + + $instance->update(['traffic_addons' => 1]); // one extra pack + (new CollectInstanceTraffic)->handle($pve); + + expect($pve->networkRates[1042])->toBeNull() + ->and(InstanceTraffic::query()->first()->throttled)->toBeFalse(); +}); + +it('warns once per threshold, not on every sample', function () { + [$pve, , $instance] = trafficSetup(); + $quota = TrafficMeter::quotaGb($instance) * 1000 ** 3; + + $pve->counters[1042] = ['netin' => 0, 'netout' => 0]; + (new CollectInstanceTraffic)->handle($pve); + $pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.82)]; + (new CollectInstanceTraffic)->handle($pve); + + expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80); + + // Still in the same band a sample later — nothing new to say. + $pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.84)]; + (new CollectInstanceTraffic)->handle($pve); + expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80); + + // Crossing the next one does warn again. + $pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.96)]; + (new CollectInstanceTraffic)->handle($pve); + expect(InstanceTraffic::query()->first()->notified_percent)->toBe(95); +}); + +it('counts add-on packs towards the allowance', function () { + [, , $instance] = trafficSetup(); + + expect(TrafficMeter::quotaGb($instance))->toBe(1000); + + $instance->update(['traffic_addons' => 2]); + expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000); +}); + +it('shows the customer what is left, and offers a top-up when it gets tight', function () { + [, $customer, $instance] = trafficSetup(); + $user = User::factory()->create(['email' => $customer->email]); + + InstanceTraffic::create([ + 'instance_id' => $instance->id, + 'period' => InstanceTraffic::currentPeriod(), + 'tx_bytes' => (int) (1000 * 1000 ** 3 * 0.10), + ]); + + // Comfortable: no upsell in the customer's face. + Livewire\Livewire::actingAs($user)->test(Dashboard::class) + ->assertSee(__('dashboard.traffic.title')) + ->assertDontSee(__('dashboard.traffic.buy')); + + InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]); + + Livewire\Livewire::actingAs($user)->test(Dashboard::class) + ->assertSee(__('dashboard.traffic.buy')); +}); + +it('shows allowances in the units they are sold in', function () { + // A 1000 GB plan must read as 1 TB, not as 931 GB: quotas are computed in + // SI units, so displaying binary ones made the two disagree on screen. + expect(App\Support\Bytes::human(1000 * 1000 ** 3))->toBe('1 TB') + ->and(App\Support\Bytes::human(880 * 1000 ** 3))->toBe('880 GB') + ->and(App\Support\Bytes::human(1_500_000))->toBe('1,5 MB') + ->and(App\Support\Bytes::human(512))->toBe('512 B'); +});