103 lines
3.2 KiB
PHP
103 lines
3.2 KiB
PHP
<?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.
|
|
*
|
|
* The allowance is the contract's, not the catalogue's: cutting a plan's
|
|
* traffic must not start throttling someone who bought the larger one. An
|
|
* instance with no contract has nothing to meter against and is treated as
|
|
* unmetered rather than as exhausted — a machine we cannot price should not
|
|
* be throttled on a guess.
|
|
*/
|
|
public static function quotaGb(Instance $instance): int
|
|
{
|
|
$plan = (int) ($instance->subscription?->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));
|
|
}
|
|
}
|