Update logic for tracking a server's transfer state

This commit is contained in:
Matthew Penner 2020-12-16 09:34:47 -07:00
parent 5d03c0d2e5
commit e6c4a68e4a
20 changed files with 206 additions and 74 deletions

View file

@ -117,7 +117,7 @@ class ServerTransferController extends Controller
$this->daemonConfigurationRepository->setNode($node)->getSystemInformation(); $this->daemonConfigurationRepository->setNode($node)->getSystemInformation();
// Suspend the server and request an archive to be created. // Suspend the server and request an archive to be created.
$this->suspensionService->toggle($server, 'suspend'); //$this->suspensionService->toggle($server, 'suspend');
// Create a new ServerTransfer entry. // Create a new ServerTransfer entry.
$transfer = new ServerTransfer; $transfer = new ServerTransfer;

View file

@ -120,9 +120,12 @@ class ServerTransferController extends Controller
// Unsuspend the server and don't continue the transfer. // Unsuspend the server and don't continue the transfer.
if (! $request->input('successful')) { if (! $request->input('successful')) {
$this->suspensionService->toggle($server, 'unsuspend'); //$this->suspensionService->toggle($server, 'unsuspend');
$server->transfer->forceFill([
'successful' => false,
])->saveOrFail();
return JsonResponse::create([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
$server->node_id = $server->transfer->new_node; $server->node_id = $server->transfer->new_node;
@ -151,21 +154,23 @@ class ServerTransferController extends Controller
// because setServer() tells the repository to use the server's node and not the one // because setServer() tells the repository to use the server's node and not the one
// we want to specify. // we want to specify.
try { try {
/** @var \Pterodactyl\Models\Node $newNode */
$newNode = $this->nodeRepository->find($server->transfer->new_node);
$this->daemonTransferRepository $this->daemonTransferRepository
->setServer($server) ->setServer($server)
->setNode($this->nodeRepository->find($server->transfer->new_node)) ->setNode($newNode)
->notify($server, $data, $server->node, $token->__toString()); ->notify($server, $data, $server->node, $token->__toString());
} catch (DaemonConnectionException $exception) { } catch (DaemonConnectionException $exception) {
throw $exception; throw $exception;
} }
return JsonResponse::create([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
/** /**
* The daemon notifies us about a transfer failure. * The daemon notifies us about a transfer failure.
* *
* @param \Illuminate\Http\Request $request
* @param string $uuid * @param string $uuid
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
@ -183,9 +188,9 @@ class ServerTransferController extends Controller
$this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]); $this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]);
// Unsuspend the server. // Unsuspend the server.
$this->suspensionService->toggle($server, 'unsuspend'); //$this->suspensionService->toggle($server, 'unsuspend');
return JsonResponse::create([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
/** /**
@ -213,11 +218,11 @@ class ServerTransferController extends Controller
// Update the server's allocation_id and node_id. // Update the server's allocation_id and node_id.
$server->allocation_id = $transfer->new_allocation; $server->allocation_id = $transfer->new_allocation;
$server->node_id = $transfer->new_node; $server->node_id = $transfer->new_node;
$server->save(); $server->saveOrFail();
// Mark the transfer as successful. // Mark the transfer as successful.
$transfer->successful = true; $transfer->successful = true;
$transfer->save(); $transfer->saveOrFail();
// Commit the transaction. // Commit the transaction.
$this->connection->commit(); $this->connection->commit();
@ -231,8 +236,8 @@ class ServerTransferController extends Controller
// Unsuspend the server // Unsuspend the server
$server->load('node'); $server->load('node');
$this->suspensionService->toggle($server, $this->suspensionService::ACTION_UNSUSPEND); //$this->suspensionService->toggle($server, $this->suspensionService::ACTION_UNSUSPEND);
return JsonResponse::create([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
} }

View file

@ -112,13 +112,13 @@ class SftpAuthenticationController extends Controller
// Remeber, for security purposes, only reveal the existence of the server to people that // Remeber, for security purposes, only reveal the existence of the server to people that
// have provided valid credentials, and have permissions to know about it. // have provided valid credentials, and have permissions to know about it.
if ($server->installed !== 1 || $server->suspended) { if ($server->installed !== 1 || $server->suspended || $server->transfer !== null) {
throw new BadRequestHttpException( throw new BadRequestHttpException(
'Server is not installed or is currently suspended.' 'Server is not installed or is currently suspended.'
); );
} }
return JsonResponse::create([ return new JsonResponse([
'server' => $server->uuid, 'server' => $server->uuid,
// Deprecated, but still needed at the moment for Wings. // Deprecated, but still needed at the moment for Wings.
'token' => '', 'token' => '',

View file

@ -9,7 +9,6 @@ use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AuthenticateServerAccess class AuthenticateServerAccess
{ {
@ -24,7 +23,6 @@ class AuthenticateServerAccess
* @var string[] * @var string[]
*/ */
protected $except = [ protected $except = [
'api:client:server.view',
'api:client:server.ws', 'api:client:server.ws',
]; ];
@ -65,17 +63,26 @@ class AuthenticateServerAccess
} }
} }
if ($server->suspended && !$request->routeIs('api:client:server.resources')) { if ($server->suspended && ! $request->routeIs('api:client:server.resources')) {
throw new BadRequestHttpException( throw new BadRequestHttpException(
'This server is currently suspended and the functionality requested is unavailable.' 'This server is currently suspended and the functionality requested is unavailable.'
); );
} }
if (! $server->isInstalled()) { // Still allow users to get information about there server if it is installing or being transferred.
// Throw an exception for all server routes; however if the user is an admin and requesting the if (! $request->routeIs('api:client:server.view')) {
// server details, don't throw the exception for them. if (! $server->isInstalled()) {
if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) { // Throw an exception for all server routes; however if the user is an admin and requesting the
throw new ConflictHttpException('Server has not completed the installation process.'); // server details, don't throw the exception for them.
if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) {
throw new ConflictHttpException('Server has not completed the installation process.');
}
}
if ($server->transfer !== null) {
if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) {
throw new ConflictHttpException('Server is currently being transferred.');
}
} }
} }

View file

@ -80,6 +80,14 @@ class AccessingValidServer
return $this->response->view('errors.installing', [], 409); return $this->response->view('errors.installing', [], 409);
} }
if ($server->transfer !== null) {
if ($isApiRequest) {
throw new ConflictHttpException('Server is currently being transferred.');
}
return $this->response->view('errors.transferring', [], 409);
}
// Add server to the request attributes. This will replace sessions // Add server to the request attributes. This will replace sessions
// as files are updated. // as files are updated.
$request->attributes->set('server', $server); $request->attributes->set('server', $server);

View file

@ -306,7 +306,7 @@ class Server extends Model
*/ */
public function transfer() public function transfer()
{ {
return $this->hasOne(ServerTransfer::class)->orderByDesc('id'); return $this->hasOne(ServerTransfer::class)->whereNull('successful')->orderByDesc('id');
} }
/** /**

View file

@ -11,7 +11,7 @@ namespace Pterodactyl\Models;
* @property int $new_allocation * @property int $new_allocation
* @property string $old_additional_allocations * @property string $old_additional_allocations
* @property string $new_additional_allocations * @property string $new_additional_allocations
* @property bool $successful * @property bool|null $successful
* @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at * @property \Carbon\Carbon $updated_at
* *
@ -66,7 +66,7 @@ class ServerTransfer extends Model
'new_allocation' => 'required|numeric', 'new_allocation' => 'required|numeric',
'old_additional_allocations' => 'nullable', 'old_additional_allocations' => 'nullable',
'new_additional_allocations' => 'nullable', 'new_additional_allocations' => 'nullable',
'successful' => 'sometimes|boolean', 'successful' => 'sometimes|nullable|boolean',
]; ];
/** /**

View file

@ -24,6 +24,7 @@ class GetUserPermissionsService
if ($user->root_admin) { if ($user->root_admin) {
$permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.install';
$permissions[] = 'admin.websocket.transfer';
} }
return $permissions; return $permissions;

View file

@ -61,7 +61,10 @@ class SuspensionService
'suspended' => $action === self::ACTION_SUSPEND, 'suspended' => $action === self::ACTION_SUSPEND,
]); ]);
$this->daemonServerRepository->setServer($server)->suspend($action === self::ACTION_UNSUSPEND); // Only send the suspension request to wings if the server is not currently being transferred.
if ($server->transfer === null) {
$this->daemonServerRepository->setServer($server)->suspend($action === self::ACTION_UNSUSPEND);
}
}); });
} }
} }

View file

@ -28,6 +28,7 @@ class ServerTransformer extends BaseTransformer
'location', 'location',
'node', 'node',
'databases', 'databases',
'transfer',
]; ];
/** /**
@ -55,8 +56,6 @@ class ServerTransformer extends BaseTransformer
* *
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return array * @return array
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function transform(Server $server): array public function transform(Server $server): array
{ {

View file

@ -72,6 +72,7 @@ class ServerTransformer extends BaseClientTransformer
], ],
'is_suspended' => $server->suspended, 'is_suspended' => $server->suspended,
'is_installing' => $server->installed !== 1, 'is_installing' => $server->installed !== 1,
'is_transferring' => $server->transfer !== null,
]; ];
} }

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class MakeSuccessfulNullableInServerTransfers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->boolean('successful')->nullable()->default(null)->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->boolean('successful')->default(0)->change();
});
}
}

View file

@ -40,6 +40,7 @@ export interface Server {
}; };
isSuspended: boolean; isSuspended: boolean;
isInstalling: boolean; isInstalling: boolean;
isTransferring: boolean;
variables: ServerEggVariable[]; variables: ServerEggVariable[];
allocations: Allocation[]; allocations: Allocation[];
} }
@ -62,6 +63,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended, isSuspended: data.is_suspended,
isInstalling: data.is_installing, isInstalling: data.is_installing,
isTransferring: data.is_transferring,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
}); });

View file

@ -26,14 +26,14 @@ const IconDescription = styled.p<{ $alarm: boolean }>`
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>` const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
${tw`grid grid-cols-12 gap-4 relative`}; ${tw`grid grid-cols-12 gap-4 relative`};
& .status-bar { & .status-bar {
${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`}; ${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`};
height: calc(100% - 0.5rem); height: calc(100% - 0.5rem);
${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)}; ${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)};
} }
&:hover .status-bar { &:hover .status-bar {
${tw`opacity-75`}; ${tw`opacity-75`};
} }
@ -74,8 +74,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk); alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
} }
const disklimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited'; const diskLimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited';
const memorylimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited'; const memoryLimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
return ( return (
<StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}> <StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}>
@ -118,7 +118,14 @@ export default ({ server, className }: { server: Server; className?: string }) =
</span> </span>
</div> </div>
: :
<Spinner size={'small'}/> server.isTransferring ?
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Transferring
</span>
</div>
:
<Spinner size={'small'}/>
: :
<React.Fragment> <React.Fragment>
<div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}> <div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}>
@ -134,7 +141,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
{bytesToHuman(stats.memoryUsageInBytes)} {bytesToHuman(stats.memoryUsageInBytes)}
</IconDescription> </IconDescription>
</div> </div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memorylimit}</p> <p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memoryLimit}</p>
</div> </div>
<div css={tw`flex-1 ml-4 sm:block hidden`}> <div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}> <div css={tw`flex justify-center`}>
@ -143,7 +150,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
{bytesToHuman(stats.diskUsageInBytes)} {bytesToHuman(stats.diskUsageInBytes)}
</IconDescription> </IconDescription>
</div> </div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {disklimit}</p> <p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
</div> </div>
</React.Fragment> </React.Fragment>
} }

View file

@ -59,7 +59,7 @@ export default () => {
}, [ progress, continuous ]); }, [ progress, continuous ]);
return ( return (
<div className={'w-full fixed'} style={{ height: '2px' }}> <div css={tw`w-full fixed`} style={{ height: '2px' }}>
<CSSTransition <CSSTransition
timeout={150} timeout={150}
appear appear

View file

@ -74,6 +74,21 @@ export default () => {
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m', (prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
); );
const handleTransferStatus = (status: string) => {
switch (status) {
// Sent by either the source or target node if a failure occurs.
case 'failure':
terminal.writeln(TERMINAL_PRELUDE + 'Transfer has failed.\u001b[0m');
return;
// Sent by the source node whenever the server was archived successfully.
case 'archive':
terminal.writeln(TERMINAL_PRELUDE + 'Server has been archived successfully, attempting connection to target node..\u001b[0m');
// TODO: Get WebSocket information for the target node.
return;
}
};
const handleDaemonErrorOutput = (line: string) => terminal.writeln( const handleDaemonErrorOutput = (line: string) => terminal.writeln(
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m', TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
); );
@ -122,20 +137,23 @@ export default () => {
// Add support for capturing keys // Add support for capturing keys
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Ctrl + C ( Copy ) // Ctrl + C (Copy)
if (e.ctrlKey && e.key === 'c') { if (e.ctrlKey && e.key === 'c') {
document.execCommand('copy'); document.execCommand('copy');
return false; return false;
} }
// Ctrl + F (Find)
if (e.ctrlKey && e.key === 'f') { if (e.ctrlKey && e.key === 'f') {
searchBar.show(); searchBar.show();
return false; return false;
} }
// Escape
if (e.key === 'Escape') { if (e.key === 'Escape') {
searchBar.hidden(); searchBar.hidden();
} }
return true; return true;
}); });
} }
@ -154,17 +172,21 @@ export default () => {
instance.addListener('status', handlePowerChangeEvent); instance.addListener('status', handlePowerChangeEvent);
instance.addListener('console output', handleConsoleOutput); instance.addListener('console output', handleConsoleOutput);
instance.addListener('install output', handleConsoleOutput); instance.addListener('install output', handleConsoleOutput);
instance.addListener('transfer logs', handleConsoleOutput);
instance.addListener('transfer status', handleTransferStatus);
instance.addListener('daemon message', line => handleConsoleOutput(line, true)); instance.addListener('daemon message', line => handleConsoleOutput(line, true));
instance.addListener('daemon error', handleDaemonErrorOutput); instance.addListener('daemon error', handleDaemonErrorOutput);
instance.send('send logs'); instance.send('send logs');
} }
return () => { return () => {
instance && instance.removeListener('console output', handleConsoleOutput) instance && instance.removeListener('status', handlePowerChangeEvent)
.removeListener('console output', handleConsoleOutput)
.removeListener('install output', handleConsoleOutput) .removeListener('install output', handleConsoleOutput)
.removeListener('transfer logs', handleConsoleOutput)
.removeListener('transfer status', handleTransferStatus)
.removeListener('daemon message', line => handleConsoleOutput(line, true)) .removeListener('daemon message', line => handleConsoleOutput(line, true))
.removeListener('daemon error', handleDaemonErrorOutput) .removeListener('daemon error', handleDaemonErrorOutput);
.removeListener('status', handlePowerChangeEvent);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ connected, instance ]); }, [ connected, instance ]);

View file

@ -17,6 +17,7 @@ const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/c
const ServerConsole = () => { const ServerConsole = () => {
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling); const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
// @ts-ignore // @ts-ignore
const eggFeatures: string[] = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual); const eggFeatures: string[] = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual);
@ -24,11 +25,7 @@ const ServerConsole = () => {
<ServerContentBlock title={'Console'} css={tw`flex flex-wrap`}> <ServerContentBlock title={'Console'} css={tw`flex flex-wrap`}>
<div css={tw`w-full lg:w-1/4`}> <div css={tw`w-full lg:w-1/4`}>
<ServerDetailsBlock/> <ServerDetailsBlock/>
{!isInstalling ? {isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<PowerControls/>
</Can>
:
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}> <div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer> <ContentContainer>
<p css={tw`text-sm text-yellow-900`}> <p css={tw`text-sm text-yellow-900`}>
@ -37,6 +34,20 @@ const ServerConsole = () => {
</p> </p>
</ContentContainer> </ContentContainer>
</div> </div>
:
isTransferring ?
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer>
<p css={tw`text-sm text-yellow-900`}>
This server is currently being transferred to another node and all actions
are unavailable.
</p>
</ContentContainer>
</div>
:
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<PowerControls/>
</Can>
} }
</div> </div>
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}> <div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>

View file

@ -65,13 +65,14 @@ const ServerDetailsBlock = () => {
const name = ServerContext.useStoreState(state => state.server.data!.name); const name = ServerContext.useStoreState(state => state.server.data!.name);
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling); const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
const limits = ServerContext.useStoreState(state => state.server.data!.limits); const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const primaryAllocation = ServerContext.useStoreState(state => state.server.data!.allocations.filter(alloc => alloc.isDefault).map( const primaryAllocation = ServerContext.useStoreState(state => state.server.data!.allocations.filter(alloc => alloc.isDefault).map(
allocation => (allocation.alias || allocation.ip) + ':' + allocation.port allocation => (allocation.alias || allocation.ip) + ':' + allocation.port
)).toString(); )).toString();
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited'; const diskLimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited'; const memoryLimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return ( return (
<TitledGreyBox css={tw`break-words`} title={name} icon={faServer}> <TitledGreyBox css={tw`break-words`} title={name} icon={faServer}>
@ -81,10 +82,10 @@ const ServerDetailsBlock = () => {
fixedWidth fixedWidth
css={[ css={[
tw`mr-1`, tw`mr-1`,
statusToColor(status, isInstalling), statusToColor(status, isInstalling || isTransferring),
]} ]}
/> />
&nbsp;{!status ? 'Connecting...' : (isInstalling ? 'Installing' : status)} &nbsp;{!status ? 'Connecting...' : (isInstalling ? 'Installing' : (isTransferring) ? 'Transferring' : status)}
</p> </p>
<CopyOnClick text={primaryAllocation}> <CopyOnClick text={primaryAllocation}>
<p css={tw`text-xs mt-2`}> <p css={tw`text-xs mt-2`}>
@ -97,11 +98,11 @@ const ServerDetailsBlock = () => {
</p> </p>
<p css={tw`text-xs mt-2`}> <p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(stats.memory)} <FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(stats.memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span> <span css={tw`text-neutral-500`}> / {memoryLimit}</span>
</p> </p>
<p css={tw`text-xs mt-2`}> <p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(stats.disk)} <FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(stats.disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span> <span css={tw`text-neutral-500`}> / {diskLimit}</span>
</p> </p>
</TitledGreyBox> </TitledGreyBox>
); );

View file

@ -35,10 +35,12 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin); const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const [ error, setError ] = useState(''); const [ error, setError ] = useState('');
const [ installing, setInstalling ] = useState(false); const [ installing, setInstalling ] = useState(false);
const [ transferring, setTransferring ] = useState(false);
const id = ServerContext.useStoreState(state => state.server.data?.id); const id = ServerContext.useStoreState(state => state.server.data?.id);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid); const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling); const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring);
const serverId = ServerContext.useStoreState(state => state.server.data?.internalId); const serverId = ServerContext.useStoreState(state => state.server.data?.internalId);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState); const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
@ -51,13 +53,23 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
setInstalling(!!isInstalling); setInstalling(!!isInstalling);
}, [ isInstalling ]); }, [ isInstalling ]);
useEffect(() => {
setTransferring(!!isTransferring);
}, [ isTransferring ]);
useEffect(() => { useEffect(() => {
setError(''); setError('');
setInstalling(false); setInstalling(false);
setTransferring(false);
getServer(match.params.id) getServer(match.params.id)
.catch(error => { .catch(error => {
if (error.response?.status === 409) { if (error.response?.status === 409) {
setInstalling(true); if (error.response.data?.errors[0]?.detail?.includes('transfer')) {
setTransferring(true);
} else {
setInstalling(true);
}
} else { } else {
console.error(error); console.error(error);
setError(httpErrorToHuman(error)); setError(httpErrorToHuman(error));
@ -117,9 +129,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</CSSTransition> </CSSTransition>
<InstallListener/> <InstallListener/>
<WebsocketHandler/> <WebsocketHandler/>
{(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ? {((installing || transferring) && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
<ScreenBlock <ScreenBlock
title={'Your server is installing.'} title={installing ? 'Your server is installing.' : 'Your server is currently being transferred.'}
image={'/assets/svgs/server_installing.svg'} image={'/assets/svgs/server_installing.svg'}
message={'Please check back in a few minutes.'} message={'Please check back in a few minutes.'}
/> />

View file

@ -58,6 +58,7 @@
</div> </div>
</div> </div>
</div> </div>
@if(! $server->suspended) @if(! $server->suspended)
<div class="col-sm-4"> <div class="col-sm-4">
<div class="box box-warning"> <div class="box box-warning">
@ -96,28 +97,48 @@
</div> </div>
@endif @endif
<div class="col-sm-4"> @if($server->transfer === null)
<div class="box box-success"> <div class="col-sm-4">
<div class="box-header with-border"> <div class="box box-success">
<h3 class="box-title">Transfer Server</h3> <div class="box-header with-border">
</div> <h3 class="box-title">Transfer Server</h3>
<div class="box-body"> </div>
<p> <div class="box-body">
Transfer this server to another node connected to this panel. <p>
<strong>Warning!</strong> This feature has not been fully tested and may have bugs. Transfer this server to another node connected to this panel.
</p> <strong>Warning!</strong> This feature has not been fully tested and may have bugs.
</div> </p>
</div>
<div class="box-footer"> <div class="box-footer">
@if($canTransfer) @if($canTransfer)
<button class="btn btn-success" data-toggle="modal" data-target="#transferServerModal">Transfer Server</button> <button class="btn btn-success" data-toggle="modal" data-target="#transferServerModal">Transfer Server</button>
@else @else
<button class="btn btn-success disabled">Transfer Server</button> <button class="btn btn-success disabled">Transfer Server</button>
<p style="padding-top: 1rem;">Transferring a server requires more than one node to be configured on your panel.</p> <p style="padding-top: 1rem;">Transferring a server requires more than one node to be configured on your panel.</p>
@endif @endif
</div>
</div> </div>
</div> </div>
</div> @else
<div class="col-sm-4">
<div class="box box-success">
<div class="box-header with-border">
<h3 class="box-title">Transfer Server</h3>
</div>
<div class="box-body">
<p>
This server is currently being transferred to another node.
Transfer was initiated at <strong>{{ $server->transfer->created_at }}</strong>
</p>
</div>
<div class="box-footer">
<button class="btn btn-success disabled">Transfer Server</button>
</div>
</div>
</div>
@endif
</div> </div>
<div class="modal fade" id="transferServerModal" tabindex="-1" role="dialog"> <div class="modal fade" id="transferServerModal" tabindex="-1" role="dialog">