Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions app/Http/Controllers/ServerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -315,6 +326,11 @@ public function destroy(Server $server): RedirectResponse
return back()->with('error', __('This is not your Server!'));
}

if (!$server->pterodactyl_id) {
Comment thread
MrWeez marked this conversation as resolved.
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);

Expand Down Expand Up @@ -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')
Expand All @@ -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([
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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])
Expand All @@ -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;
Expand Down
41 changes: 13 additions & 28 deletions app/Jobs/ReconcileServerCreationJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
MrWeez marked this conversation as resolved.
$creditService->refund($server->user, $this->chargedPrice);
Log::info('ReconcileServerCreationJob: deleted server and refunded credits on confirmed 404', [
'server_id' => $this->serverId,
'amount' => $this->chargedPrice,
]);

Comment thread
MrWeez marked this conversation as resolved.
return;
}
Expand Down Expand Up @@ -129,22 +123,13 @@ public function failed(\Throwable $exception): void
}

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,
]);
}
$server->delete();
$creditService->refund($server->user, $this->chargedPrice);
Log::critical('ReconcileServerCreationJob failed after retries with remote 404; deleted server and refunded credits', [
'server_id' => $this->serverId,
'amount' => $this->chargedPrice,
'error' => $exception->getMessage(),
]);

return;
}
Expand Down
51 changes: 26 additions & 25 deletions app/Services/ServerCreationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, $user, $product, $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);
}
}
Expand Down Expand Up @@ -232,12 +232,21 @@ private function handleProvisionSuccess(Server $server, $response, int $chargedP

private function handleProvisionFailure(Server $server, User $user, Product $product, $response, int $chargedPrice): Server
Comment thread
MrWeez marked this conversation as resolved.
Outdated
{
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 and refund.
if ($response->status() === 400) {
$server->delete();
$this->refundCredits($user, $chargedPrice);
Comment thread
MrWeez marked this conversation as resolved.
Outdated

throw new \Exception(__('Server could not be created, please try again later or contact administration if the issue persists.'));
}
Comment thread
MrWeez marked this conversation as resolved.

try {
$remoteResponse = $this->pterodactylClient->getServerByExternalId($server->id);

Expand All @@ -258,38 +267,30 @@ 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();
$this->refundCredits($user, $chargedPrice);

return $server;
throw new \Exception(__('Server could not be created, please try again later or contact administration if the issue persists.'));
}
Comment thread
MrWeez marked this conversation as resolved.

$server->update(['status' => Server::STATUS_PENDING_RECONCILIATION]);
dispatch(new ReconcileServerCreationJob($server->id, $chargedPrice));

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(),
]);
Expand Down
31 changes: 18 additions & 13 deletions themes/default/views/servers/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ class="mr-2 fas fa-database"></i><span>{{ __('Database') }}</span>

<div class="flex-row row d-flex justify-content-center justify-content-md-start">
@foreach ($servers as $server)
@if($server->location && $server->node && $server->nest && $server->egg)
<div class="pl-0 pr-0 col-xl-3 col-lg-5 col-md-6 col-sm-6 col-xs-12 card ml-sm-2 mr-sm-3"
style="max-width: 350px">
<div class="card-header">
Expand All @@ -60,7 +59,13 @@ class="mr-2 fas fa-database"></i><span>{{ __('Database') }}</span>
<div class="mb-3 row">
<div class="my-auto col">{{ __('Status') }}:</div>
<div class="my-auto col-7">
@if($server->suspended)
@if($server->status === 'provisioning')
<span class="badge badge-info">{{ __('Provisioning') }}</span>
@elseif($server->status === 'pending_reconciliation')
<span class="badge badge-info">{{ __('Reconciling') }}</span>
@elseif($server->status === 'failed')
<span class="badge badge-danger">{{ __('Failed') }}</span>
@elseif($server->suspended)
<span class="badge badge-danger">{{ __('Suspended') }}</span>
@elseif($server->canceled)
<span class="badge badge-warning">{{ __('Canceled') }}</span>
Expand All @@ -74,9 +79,9 @@ class="mr-2 fas fa-database"></i><span>{{ __('Database') }}</span>
{{ __('Location') }}:
</div>
<div class="col-7 d-flex justify-content-between align-items-center">
<span class="">{{ $server->location }}</span>
<span class="">{{ $server->location ?? __('Unknown') }}</span>
<i data-toggle="popover" data-trigger="hover"
data-content="{{ __('Node') }}: {{ $server->node }}"
data-content="{{ __('Node') }}: {{ $server->node ?? __('Unknown') }}"
class="fas fa-info-circle"></i>
</div>

Expand All @@ -86,7 +91,7 @@ class="fas fa-info-circle"></i>
{{ __('Software') }}:
</div>
<div class="col-7 text-wrap">
<span>{{ $server->nest }}</span>
<span>{{ $server->nest ?? __('Unknown') }}</span>
</div>

</div>
Expand All @@ -95,7 +100,7 @@ class="fas fa-info-circle"></i>
{{ __('Specification') }}:
</div>
<div class="col-7 text-wrap">
<span>{{ $server->egg }}</span>
<span>{{ $server->egg ?? __('Unknown') }}</span>
</div>
</div>
<div class="mb-2 row">
Expand All @@ -117,7 +122,7 @@ class="fas fa-info-circle"></i>
</div>
<div class="col-7 d-flex text-wrap align-items-center">
<span>
@if ($server->suspended)
@if ($server->suspended || !$server->pterodactyl_id)
-
@else
@switch($server->product->billing_period)
Expand Down Expand Up @@ -187,31 +192,31 @@ class="fas fa-info-circle"></i>
</div>

<div class="text-center card-footer">
<a href="{{ $pterodactyl_url }}/server/{{ $server->identifier }}"
<a href="{{ $server->identifier ? $pterodactyl_url . '/server/' . $server->identifier : '#' }}"
target="__blank"
class="float-left ml-2 text-center btn btn-info"
class="float-left ml-2 text-center btn btn-info {{ !$server->identifier ? 'disabled' : '' }}"
data-toggle="tooltip" data-placement="bottom" title="{{ __('Manage Server') }}">
Comment thread
MrWeez marked this conversation as resolved.
Outdated
<i class="mx-2 fas fa-tools"></i>
</a>
<a href="{{ route('servers.show', ['server' => $server->id])}}"
class="mr-3 text-center btn btn-info"
<a href="{{ $server->pterodactyl_id ? route('servers.show', ['server' => $server->id]) : '#' }}"
class="mr-3 text-center btn btn-info {{ !$server->pterodactyl_id ? 'disabled' : '' }}"
data-toggle="tooltip" data-placement="bottom" title="{{ __('Server Settings') }}">
<i class="mx-2 fas fa-cog"></i>
</a>
<button onclick="handleServerCancel('{{ $server->id }}');" target="__blank"
class="text-center btn btn-warning"
{{ $server->suspended || $server->canceled ? "disabled" : "" }}
{{ $server->suspended || $server->canceled || !$server->pterodactyl_id ? "disabled" : "" }}
data-toggle="tooltip" data-placement="bottom" title="{{ __('Cancel Server') }}">
<i class="mx-2 fas fa-ban"></i>
</button>
<button onclick="handleServerDelete('{{ $server->id }}');" target="__blank"
class="float-right mr-2 text-center btn btn-danger"
{{ !$server->pterodactyl_id && $server->status !== 'failed' ? "disabled" : "" }}
Comment thread
MrWeez marked this conversation as resolved.
Outdated
data-toggle="tooltip" data-placement="bottom" title="{{ __('Delete Server') }}">
<i class="mx-2 fas fa-trash"></i>
</button>
</div>
</div>
@endif
@endforeach
</div>
<!-- END CUSTOM CONTENT -->
Expand Down
Loading