*/ use HasFactory, HasUuid; protected $fillable = [ 'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey', 'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores', 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', ]; protected $hidden = ['api_token_ref']; protected function casts(): array { return [ 'api_token_ref' => 'encrypted', 'last_seen_at' => 'datetime', 'total_gb' => 'integer', 'total_ram_mb' => 'integer', 'cpu_cores' => 'integer', 'cpu_weight' => 'integer', 'reserve_pct' => 'integer', ]; } /** Placement runs (polymorphic subject). */ public function runs(): MorphMany { return $this->morphMany(ProvisioningRun::class, 'subject'); } /** Runner hook: a failed onboarding run moves the host to the error status. */ public function onProvisioningFailed(): void { $this->update(['status' => 'error']); } public function instances(): HasMany { return $this->hasMany(Instance::class); } /** Free committable storage: total minus reserve. */ public function freeGb(): int { if ($this->total_gb === null) { return 0; } return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100); } /** * Host storage already committed, counted as the VM disk allocation * (disk_gb) since that is what is actually placed on the host — not the * (smaller) Nextcloud user quota. A failed instance still counts while its VM * exists (has a vmid); a failure before any VM was created releases it. */ public function committedGb(): int { return (int) $this->instances() ->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')) ->sum('disk_gb'); } /** Storage still available for new instances. */ public function availableGb(): int { return max(0, $this->freeGb() - $this->committedGb()); } /** Used-storage percentage (committed vs committable free), 0–100. */ public function usedPct(): int { $free = $this->freeGb(); return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0; } /** * Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min), * offline (older or never seen). Drives the health dot in the console. */ public function healthState(): string { if ($this->last_seen_at === null) { return 'offline'; } $minutes = $this->last_seen_at->diffInMinutes(now()); return match (true) { $minutes <= 5 => 'online', $minutes <= 30 => 'stale', default => 'offline', }; } /** * Placement (spec §1): first active host in the datacenter with enough free * committable storage. Cluster is ignored in v1.0. */ public static function placeableIn(string $datacenter, int $quotaGb): ?self { return self::query() ->where('datacenter', $datacenter) ->where('status', 'active') ->orderBy('name') ->get() ->first(fn (self $host) => $host->availableGb() >= $quotaGb); } }