feat(traffic): meter the monthly allowance, show it, throttle instead of blocking

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:33:47 +02:00
parent 4681b135db
commit c1e81808a7
21 changed files with 693 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InstanceTraffic extends Model
{
protected $table = 'instance_traffic';
protected $guarded = [];
protected function casts(): array
{
return [
'rx_bytes' => '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');
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Provisioning\Jobs;
use App\Models\Instance;
use App\Models\InstanceTraffic;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Samples every running instance's network counters and accumulates the month's
* usage, then acts on the result: warn at the configured thresholds, throttle
* once the allowance is gone.
*
* Proxmox reports netin/netout cumulatively since the VM last started, so the
* usage is the difference between two samples. A counter that went backwards
* means the VM restarted the new value is then the usage since the restart,
* not a refund.
*
* One instance failing must not cost the others their sample, so each is
* wrapped individually.
*/
class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $uniqueFor = 900;
public int $tries = 1;
public function __construct()
{
$this->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(),
]);
}
}
}
}

View File

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

View File

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

View File

@ -58,6 +58,15 @@ interface ProxmoxClient
/** @param array<int, array<string, mixed>> $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;
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services\Traffic;
use App\Models\Instance;
use App\Models\InstanceTraffic;
/**
* What a customer has used this month, and what that means.
*
* The quota comes from the plan plus whatever traffic add-ons were bought; the
* usage is the outbound total the collector accumulates. Everything the portal,
* the console and the throttling decision need is derived here, so those three
* can never disagree about whether someone is over their limit.
*/
final readonly class TrafficMeter
{
private function __construct(
public Instance $instance,
public int $usedBytes,
public int $quotaBytes,
public bool $throttled,
) {}
public static function for(Instance $instance, ?InstanceTraffic $row = null): self
{
$row ??= InstanceTraffic::query()
->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));
}
}

View File

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

View File

@ -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' => [

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Monthly network usage per instance.
*
* One row per instance per calendar month, because that is the unit the quota
* is sold in and the unit Hetzner bills us in. Proxmox reports counters that
* are cumulative since the VM last started, so the raw values are kept
* alongside the total: the difference between two samples is the usage, and a
* counter that went backwards means the VM restarted rather than that traffic
* was refunded.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('instance_traffic', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* How many extra traffic packs a customer has bought for this instance. Kept on
* the instance rather than derived from orders so the meter read on every
* dashboard load never has to walk the order history.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->unsignedSmallInteger('traffic_addons')->default(0)->after('quota_gb');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('traffic_addons');
});
}
};

View File

@ -1,6 +1,10 @@
<?php
return [
'traffic_title' => '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',

View File

@ -1,6 +1,14 @@
<?php
return [
'traffic' => [
'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',

View File

@ -1,6 +1,10 @@
<?php
return [
'traffic_title' => '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',

View File

@ -1,6 +1,14 @@
<?php
return [
'traffic' => [
'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',

View File

@ -73,7 +73,7 @@
@endif
{{-- Extra storage + Add-ons --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise [animation-delay:180ms]">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]">
{{-- Storage --}}
<div class="flex flex-col rounded-xl border border-accent-border bg-accent-subtle p-5">
<div class="flex items-center gap-2">
@ -86,6 +86,27 @@
</x-ui.button>
</div>
{{-- Traffic --}}
<div class="flex flex-col rounded-xl border {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'border-warning-border bg-warning-bg' : 'border-line bg-surface shadow-xs' }} p-5">
<div class="flex items-center gap-2">
<x-ui.icon name="activity" class="size-5 {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'text-warning' : 'text-muted' }}" />
<p class="font-semibold text-ink">{{ __('billing.traffic_title') }}</p>
</div>
<p class="mt-2 text-sm text-body">{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}</p>
@if ($trafficMeter)
<p class="mt-2 font-mono text-xs text-muted">
{{ __('billing.traffic_used', [
'used' => \App\Support\Bytes::human($trafficMeter->usedBytes),
'quota' => \App\Support\Bytes::human($trafficMeter->quotaBytes),
]) }}
</p>
@endif
<x-ui.button variant="{{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'primary' : 'secondary' }}" size="sm" class="mt-4 w-full"
wire:click="purchase('traffic')" wire:target="purchase('traffic')" wire:loading.attr="disabled">
{{ __('billing.traffic_cta', ['gb' => $trafficAddon['gb']]) }}
</x-ui.button>
</div>
{{-- Add-ons --}}
@foreach ($addons as $key => $addon)
<div wire:key="addon-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs">

View File

@ -11,6 +11,59 @@
{{-- Live provisioning progress (only while a run is in flight) --}}
<livewire:customer-provisioning />
{{-- 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
<div class="rounded-xl border {{ $frame }} bg-surface p-5 shadow-xs animate-rise">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<div class="flex items-baseline gap-2">
<h2 class="text-sm font-semibold text-ink">{{ __('dashboard.traffic.title') }}</h2>
<span class="text-xs text-muted">{{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }}</span>
</div>
<p class="font-mono text-sm text-body">
{{ \App\Support\Bytes::human($traffic->usedBytes) }}
<span class="text-faint">/ {{ \App\Support\Bytes::human($traffic->quotaBytes) }}</span>
</p>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-pill bg-surface-2">
{{-- R4: width is data, not decoration the only inline style allowed. --}}
<div class="h-full rounded-pill {{ $bar }} transition-all duration-700"
style="width: {{ min(100, $traffic->percent()) }}%"></div>
</div>
<div class="mt-2.5 flex flex-wrap items-center justify-between gap-2">
<p class="text-xs {{ $state === 'ok' ? 'text-muted' : 'text-body' }}">
@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
</p>
@if ($state !== 'ok')
{{-- The offer belongs next to the warning: someone reading that
they are nearly out should not have to go looking. --}}
<a href="{{ route('billing') }}" wire:navigate
class="inline-flex items-center gap-1.5 rounded-md border border-accent-border bg-accent-bg px-2.5 py-1 text-xs font-semibold text-accent-text transition hover:border-accent">
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
</a>
@endif
</div>
</div>
@endif
{{-- KPI row --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{{-- Storage ring --}}

View File

@ -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');

View File

@ -0,0 +1,159 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceTraffic;
use App\Models\User;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;
function trafficSetup(array $overrides = []): array
{
$pve = new FakeProxmoxClient;
app()->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');
});