diff --git a/app/Http/Controllers/Admin/ServerController.php b/app/Http/Controllers/Admin/ServerController.php index f254c17b5..cb3b7aaaf 100644 --- a/app/Http/Controllers/Admin/ServerController.php +++ b/app/Http/Controllers/Admin/ServerController.php @@ -9,6 +9,8 @@ use App\Settings\LocaleSettings; use App\Settings\PterodactylSettings; use App\Classes\PterodactylClient; +use App\Facades\Currency; +use App\Services\CreditService; use Exception; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; @@ -154,9 +156,11 @@ public function update(Request $request, Server $server, DiscordSettings $discor * Remove the specified resource from storage. * * @param Server $server + * @param Request $request + * @param DiscordSettings $discord_settings * @return RedirectResponse|Response */ - public function destroy(Server $server, DiscordSettings $discord_settings) + public function destroy(Server $server, Request $request, DiscordSettings $discord_settings) { $this->checkPermission(self::DELETE_PERMISSION); try { @@ -173,6 +177,17 @@ public function destroy(Server $server, DiscordSettings $discord_settings) log::debug('Failed to update discord roles' . $e->getMessage()); } + if ($request->has('refund')) { + $user = User::findOrFail($server->user_id); + $credits = (int) round($server->product->price); + app(CreditService::class)->refund($user, $credits); + + activity() + ->performedOn($server) + ->causedBy(Auth::user()) + ->log("Server credits (" . Currency::formatForDisplay($credits) . ") refunded to user " . $user->name . " during deletion."); + } + // Attempt to remove the server from pterodactyl $server->delete(); @@ -236,42 +251,77 @@ public function toggleSuspended(Request $request, Server $server) public function syncServers() { $this->checkPermission(self::WRITE_PERMISSION); - $CPServers = Server::get(); + $CPServers = Server::all(); $CPIDArray = []; $renameCount = 0; - foreach ($CPServers as $CPServer) { //go thru all CP servers and make array with IDs as keys. All values are false. + $recoveredCount = 0; + $deleteCount = 0; + + // 1. Handle servers with missing pterodactyl_id (Try to recover via external_id) + foreach ($CPServers->whereNull('pterodactyl_id') as $serverWithoutId) { + try { + $response = $this->pterodactyl->getServerByExternalId($serverWithoutId->id); + if ($response->successful()) { + $attributes = $response->json()['attributes'] ?? null; + if ($attributes && isset($attributes['id'])) { + $serverWithoutId->update([ + 'pterodactyl_id' => $attributes['id'], + 'identifier' => $attributes['identifier'] ?? $serverWithoutId->identifier, + 'status' => Server::STATUS_ACTIVE, + ]); + $recoveredCount++; + } + } + } catch (Exception $e) { + Log::error("Failed to sync server without ID {$serverWithoutId->id}: " . $e->getMessage()); + } + } + + // Refresh list after recovery attempts + $CPServers = Server::all(); + + // 2. Map existing pterodactyl_id for presence check + foreach ($CPServers as $CPServer) { if ($CPServer->pterodactyl_id) { $CPIDArray[$CPServer->pterodactyl_id] = false; } } - foreach ($this->pterodactyl->getServers() as $server) { //go thru all ptero servers, if server exists, change value to true in array. - if (isset($CPIDArray[$server['attributes']['id']])) { - $CPIDArray[$server['attributes']['id']] = true; + // 3. Sync names and mark found servers + foreach ($this->pterodactyl->getServers() as $server) { + $pteroId = $server['attributes']['id']; + if (isset($CPIDArray[$pteroId])) { + $CPIDArray[$pteroId] = true; - if (isset($server['attributes']['name'])) { //failsafe - //Check if a server got renamed - $savedServer = Server::query()->where('pterodactyl_id', $server['attributes']['id'])->first(); - if ($savedServer->name != $server['attributes']['name']) { - $savedServer->name = $server['attributes']['name']; - $savedServer->save(); + if (isset($server['attributes']['name'])) { + $savedServer = $CPServers->where('pterodactyl_id', $pteroId)->first(); + if ($savedServer && $savedServer->name != $server['attributes']['name']) { + $savedServer->update(['name' => $server['attributes']['name']]); $renameCount++; } } } } - $filteredArray = array_filter($CPIDArray, function ($v, $k) { - return $v == false; - }, ARRAY_FILTER_USE_BOTH); //Array of servers, that dont exist on ptero (value == false) - $deleteCount = 0; - foreach ($filteredArray as $key => $CPID) { //delete servers that dont exist on ptero anymore - if (!$this->pterodactyl->getServerAttributes($key, true)) { + + // 4. Delete servers that don't exist on Pterodactyl anymore + $orphanedServers = array_filter($CPIDArray, fn($found) => !$found); + foreach ($orphanedServers as $key => $found) { + try { + // getServerAttributes with deleteOn404=true will delete the server if it's missing + $this->pterodactyl->getServerAttributes($key, true); $deleteCount++; + } catch (Exception $e) { + Log::error("Failed to check orphaned server {$key}: " . $e->getMessage()); } } - return redirect()->back()->with('success', __('Servers synced successfully' . (($renameCount) ? (',\n' . __('renamed') . ' ' . $renameCount . ' ' . __('servers')) : '') . ((count($filteredArray)) ? (',\n' . __('deleted') . ' ' . $deleteCount . '/' . count($filteredArray) . ' ' . __('old servers')) : ''))) . '.'; + $message = __('Servers synced successfully.'); + if ($renameCount > 0) $message .= ' ' . __('Renamed') . ': ' . $renameCount . '.'; + if ($recoveredCount > 0) $message .= ' ' . __('Recovered') . ': ' . $recoveredCount . '.'; + if ($deleteCount > 0) $message .= ' ' . __('Deleted') . ': ' . $deleteCount . '.'; + + return redirect()->back()->with('success', $message); } /** @@ -329,11 +379,16 @@ class="btn btn-sm '.$suspendColor.' text-white mr-1 suspend-btn" -
- ' . csrf_field() . ' - ' . method_field('DELETE') . ' - -
+ '; }) diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 27ed7a200..290ff2063 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -157,6 +157,11 @@ public function store(Request $request): RedirectResponse ->with('error', __('Server creation failed')); } + if ($server->status === Server::STATUS_PENDING_RECONCILIATION) { + return redirect()->route('servers.index') + ->with('success', __('Server is being created, please wait.')); + } + return redirect()->route('servers.index') ->with('success', __('Server created')); } catch (Exception $e) { @@ -248,8 +253,14 @@ private function getServersWithInfo(): \Illuminate\Database\Eloquent\Collection $servers = Auth::user()->servers; foreach ($servers as $server) { + if (!$server->pterodactyl_id) { + continue; + } + $serverInfo = $this->pterodactyl->getServerAttributes($server->pterodactyl_id); - if (!$serverInfo) continue; + if (!$serverInfo) { + continue; + } $this->updateServerInfo($server, $serverInfo); } @@ -315,6 +326,11 @@ public function destroy(Server $server): RedirectResponse return back()->with('error', __('This is not your Server!')); } + if (!$server->pterodactyl_id) { + return redirect()->route('servers.index') + ->with('error', __('Server is not ready yet. Please wait until it is created.')); + } + try { $serverInfo = $this->pterodactyl->getServerAttributes($server->pterodactyl_id); @@ -360,6 +376,11 @@ public function cancel(Server $server): RedirectResponse return back()->with('error', __('This is not your Server!')); } + if (!$server->pterodactyl_id) { + return redirect()->route('servers.index') + ->with('error', __('Server is not ready yet. Please wait until it is created.')); + } + try { $server->update(['canceled' => now()]); return redirect()->route('servers.index') @@ -370,12 +391,17 @@ public function cancel(Server $server): RedirectResponse } } - public function show(Server $server): \Illuminate\View\View + public function show(Server $server): \Illuminate\View\View|RedirectResponse { if ($server->user_id !== Auth::id()) { return back()->with('error', __('This is not your Server!')); } + if (!$server->pterodactyl_id) { + return redirect()->route('servers.index') + ->with('error', __('Server is not ready yet. Please wait until it is created.')); + } + $serverAttributes = $this->pterodactyl->getServerAttributes($server->pterodactyl_id); $upgradeOptions = $this->getUpgradeOptions($server, $serverAttributes); return view('servers.settings')->with([ @@ -434,6 +460,11 @@ public function upgrade(Server $server, Request $request): RedirectResponse ->with('error', __('This is not your Server!')); } + if (!$server->pterodactyl_id) { + return redirect()->route('servers.index') + ->with('error', __('Server is not ready yet. Please wait until it is created.')); + } + if (!$request->has('product_upgrade')) { return redirect()->route('servers.show', ['server' => $server->id]) ->with('error', __('No product selected for upgrade')); @@ -481,6 +512,11 @@ public function updateBillingPriority(Server $server, Request $request): Redirec ->with('error', __('This is not your Server!')); } + if (!$server->pterodactyl_id) { + return redirect()->route('servers.index') + ->with('error', __('Server is not ready yet. Please wait until it is created.')); + } + $server->update($data); return redirect()->route('servers.show', ['server' => $server->id]) @@ -494,6 +530,10 @@ private function validateUpgrade(Server $server, Product $oldProduct, Product $n return false; } + if (!$server->pterodactyl_id) { + return false; + } + $serverInfo = $this->pterodactyl->getServerAttributes($server->pterodactyl_id); if (!$serverInfo) { return false; diff --git a/app/Jobs/ReconcileServerCreationJob.php b/app/Jobs/ReconcileServerCreationJob.php index ab383f0f6..b95b519e0 100644 --- a/app/Jobs/ReconcileServerCreationJob.php +++ b/app/Jobs/ReconcileServerCreationJob.php @@ -78,18 +78,12 @@ public function handle(PterodactylClient $pterodactylClient, CreditService $cred } if ($response->status() === 404) { - // Atomic transition to FAILED to avoid double refunds in concurrent workers. - $updated = Server::where('id', $server->id) - ->where('status', '!=', Server::STATUS_FAILED) - ->update(['status' => Server::STATUS_FAILED]); - - if ($updated === 1) { - $creditService->refund($server->user, $this->chargedPrice); - Log::info('ReconcileServerCreationJob: refunded credits on confirmed 404', [ - 'server_id' => $server->id, - 'amount' => $this->chargedPrice, - ]); - } + $server->delete(); + $creditService->refund($server->user, $this->chargedPrice); + Log::info('ReconcileServerCreationJob: deleted server and refunded credits on confirmed 404', [ + 'server_id' => $this->serverId, + 'amount' => $this->chargedPrice, + ]); return; } @@ -120,47 +114,34 @@ public function failed(\Throwable $exception): void ]); dispatch(new PostServerCreationJob($server->id)); - Log::critical('ReconcileServerCreationJob failed after retries but remote server found; marked active', [ + Log::critical('ReconcileServerCreationJob exhausted retries but remote server finally found; marked active', [ 'server_id' => $this->serverId, - 'error' => $exception->getMessage(), ]); return; } } - if ($response->status() === 404) { - $updated = Server::where('id', $server->id) - ->where('status', '!=', Server::STATUS_FAILED) - ->update(['status' => Server::STATUS_FAILED]); - - if ($updated === 1) { - $creditService->refund($server->user, $this->chargedPrice); - Log::critical('ReconcileServerCreationJob failed after retries with remote 404; refunded credits', [ - 'server_id' => $this->serverId, - 'amount' => $this->chargedPrice, - 'error' => $exception->getMessage(), - ]); - } else { - Log::info('ReconcileServerCreationJob failed after retries with remote 404; no refund needed because status already failed', [ - 'server_id' => $this->serverId, - ]); - } - - return; - } - - $server->update(['status' => Server::STATUS_PENDING_RECONCILIATION]); - Log::critical('ReconcileServerCreationJob failed after maximum retries; remote state unknown, keeping pending_reconciliation', [ + // If we are here, we either got a 404 or a persistent API error (500/timeout) + // after many retries. We satisfy the "automatic" requirement by cleaning up. + $server->delete(); + $creditService->refund($server->user, $this->chargedPrice); + + Log::critical('ReconcileServerCreationJob exhausted retries and could not confirm server; auto-deleted and refunded', [ 'server_id' => $this->serverId, + 'amount' => $this->chargedPrice, 'error' => $exception->getMessage(), ]); + } catch (\Exception $e) { - // If remote check also fails, preserve pending state and log. - $server->update(['status' => Server::STATUS_PENDING_RECONCILIATION]); - Log::critical('ReconcileServerCreationJob failed and remote check failed; keeping pending_reconciliation', [ - 'server_id' => $this->serverId, - 'exception' => $e->getMessage(), - ]); + // Even if the final check fails, we delete and refund to avoid "stuck" servers. + if ($server->exists) { + $server->delete(); + $creditService->refund($server->user, $this->chargedPrice); + Log::critical('ReconcileServerCreationJob failed critically (even final check); forced delete and refund', [ + 'server_id' => $this->serverId, + 'exception' => $e->getMessage() + ]); + } } } } diff --git a/app/Services/ServerCreationService.php b/app/Services/ServerCreationService.php index ef8938d53..e065c9e3d 100644 --- a/app/Services/ServerCreationService.php +++ b/app/Services/ServerCreationService.php @@ -98,23 +98,23 @@ public function handle(User $user, Product $product, mixed $data): Server try { $response = $this->pterodactylClient->createServer($server, $egg, $validatedData['allocation_id'], $validatedData['egg_variables']); - - if ($response->successful()) { - return $this->handleProvisionSuccess($server, $response, $credits); - } - - return $this->handleProvisionFailure($server, $user, $product, $response, $credits); } catch (\Throwable $e) { return $this->handleProvisionUncertain($server, $credits, $e); } + + if ($response->successful()) { + return $this->handleProvisionSuccess($server, $response, $credits); + } + + return $this->handleProvisionFailure($server, $response, $credits); } catch (\Throwable $e) { if ($creditsReserved) { - if ($server) { + if ($server && $server->exists) { if ($server->status !== Server::STATUS_ACTIVE && $server->status !== Server::STATUS_FAILED) { $server->update(['status' => Server::STATUS_PENDING_RECONCILIATION]); dispatch(new ReconcileServerCreationJob($server->id, $credits)); } - } else { + } elseif (!$server || !$server->exists) { $this->refundCredits($user, $credits); } } @@ -230,14 +230,22 @@ private function handleProvisionSuccess(Server $server, $response, int $chargedP } } - private function handleProvisionFailure(Server $server, User $user, Product $product, $response, int $chargedPrice): Server + private function handleProvisionFailure(Server $server, $response, int $chargedPrice): Server { - logger()->warning('Provisioning failed on Pterodactyl, re-checking remote state', [ + logger()->error('Server creation failed on Pterodactyl (Permanent Error)', [ 'server_id' => $server->id, 'status' => $response->status(), 'error' => $response->json(), ]); + // If Pterodactyl returned a 400 Bad Request, it means the request was invalid (e.g. missing variables). + // In this case, we know the server wasn't created, so we can immediately delete it. + if ($response->status() === 400) { + $server->delete(); + + throw new \Exception(__('Server could not be created, please try again later or contact administration if the issue persists.')); + } + try { $remoteResponse = $this->pterodactylClient->getServerByExternalId($server->id); @@ -258,16 +266,9 @@ private function handleProvisionFailure(Server $server, User $user, Product $pro } if ($remoteResponse->status() === 404) { - // Atomic status transition to avoid double refund when update fails. - $updated = Server::where('id', $server->id) - ->where('status', '!=', Server::STATUS_FAILED) - ->update(['status' => Server::STATUS_FAILED]); - - if ($updated === 1) { - $this->refundCredits($user, $chargedPrice); - } + $server->delete(); - return $server; + throw new \Exception(__('Server could not be created, please try again later or contact administration if the issue persists.')); } $server->update(['status' => Server::STATUS_PENDING_RECONCILIATION]); @@ -275,21 +276,19 @@ private function handleProvisionFailure(Server $server, User $user, Product $pro return $server; } catch (\Throwable $e) { + if ($e instanceof \Exception) { + throw $e; + } return $this->handleProvisionUncertain($server, $chargedPrice, $e); } } /** - * Handle a provisioning state where the outcome is uncertain. - * - * The passed exception is intentionally only used for logging and is not rethrown - * or further analyzed here. At this point we cannot reliably determine the remote - * Pterodactyl state, so we mark the server as pending reconciliation and delegate - * detailed error handling and state correction to ReconcileServerCreationJob. + * Handle a provisioning state where the outcome is uncertain (e.g. timeout, 500). */ private function handleProvisionUncertain(Server $server, int $chargedPrice, \Throwable $exception): Server { - logger()->warning('Provisioning uncertain, scheduling reconciliation', [ + logger()->warning('Provisioning uncertain (Timeout/Transient error), scheduling reconciliation', [ 'server_id' => $server->id, 'exception' => $exception->getMessage(), ]); @@ -319,36 +318,4 @@ private function findAvailableNode(string $locationId, Product $product): ?Node return $availableNodes->isEmpty() ? null : $availableNodes->first(); } - - /** - * Find a node in the given location for the product that has required resources - * and also a free allocation on Pterodactyl. Returns ['node' => Node, 'allocation_id' => int] - * or null when none available. - */ - private function findAvailableNodeWithAllocation(string $locationId, Product $product): ?array - { - $nodes = Node::where('location_id', $locationId) - ->whereHas('products', fn($q) => $q->where('product_id', $product->id)) - ->get(); - - $availableNodes = $nodes->reject(function ($node) use ($product) { - return !$this->pterodactylClient->checkNodeResources($node, $product->memory, $product->disk); - }); - - // Try each available node and return the first one with a free allocation. - foreach ($availableNodes as $node) { - try { - $allocationId = $this->pterodactylClient->getFreeAllocationId($node); - } catch (\Exception $e) { - logger('Failed to get allocation for node ' . $node->id, ['exception' => $e]); - $allocationId = null; - } - - if ($allocationId) { - return ['node' => $node, 'allocation_id' => $allocationId]; - } - } - - return null; - } } diff --git a/themes/default/views/admin/servers/table.blade.php b/themes/default/views/admin/servers/table.blade.php index 46bb131a7..90582be01 100644 --- a/themes/default/views/admin/servers/table.blade.php +++ b/themes/default/views/admin/servers/table.blade.php @@ -16,12 +16,8 @@ diff --git a/themes/default/views/servers/index.blade.php b/themes/default/views/servers/index.blade.php index 9f388bc62..7179ff258 100644 --- a/themes/default/views/servers/index.blade.php +++ b/themes/default/views/servers/index.blade.php @@ -47,7 +47,6 @@ class="mr-2 fas fa-database">{{ __('Database') }}
@foreach ($servers as $server) - @if($server->location && $server->node && $server->nest && $server->egg)
@@ -60,7 +59,13 @@ class="mr-2 fas fa-database">{{ __('Database') }}
{{ __('Status') }}:
- @if($server->suspended) + @if($server->status === 'provisioning') + {{ __('Provisioning') }} + @elseif($server->status === 'pending_reconciliation') + {{ __('Reconciling') }} + @elseif($server->status === 'failed') + {{ __('Failed') }} + @elseif($server->suspended) {{ __('Suspended') }} @elseif($server->canceled) {{ __('Canceled') }} @@ -74,9 +79,9 @@ class="mr-2 fas fa-database">{{ __('Database') }} {{ __('Location') }}:
- {{ $server->location }} + {{ $server->location ?? __('Unknown') }}
@@ -86,7 +91,7 @@ class="fas fa-info-circle"> {{ __('Software') }}:
- {{ $server->nest }} + {{ $server->nest ?? __('Unknown') }}
@@ -95,7 +100,7 @@ class="fas fa-info-circle"> {{ __('Specification') }}:
- {{ $server->egg }} + {{ $server->egg ?? __('Unknown') }}
@@ -117,7 +122,7 @@ class="fas fa-info-circle">
- @if ($server->suspended) + @if ($server->suspended || !$server->pterodactyl_id) - @else @switch($server->product->billing_period) @@ -187,31 +192,30 @@ class="fas fa-info-circle">
- @endif @endforeach