diff --git a/CHANGELOG.md b/CHANGELOG.md index 449c129e..27eca576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ This file is a running track of new features and fixes to each version of the pa This project follows [Semantic Versioning](http://semver.org) guidelines. +## v0.7.8 (Derelict Dermodactylus) +### Added +* Nodes can now be put into maintenance mode to deny access to servers temporarily. +* Basic statistics about your panel are now available in the Admin CP. + +### Fixed +* Hitting Ctrl+Z when editing a file on the web now works as expected. +* Logo now links to the correct location on all pages. + +### Changed +* Attempting to upload a folder via the web file manager will now display a warning telling the user to use SFTP. + ## v0.7.7 (Derelict Dermodactylus) ### Fixed * Fixes an issue with the sidebar logo not working correctly in some browsers due to the CSS being assigned. diff --git a/app/Contracts/Repository/NodeRepositoryInterface.php b/app/Contracts/Repository/NodeRepositoryInterface.php index 0ebcbe3a..c533032c 100644 --- a/app/Contracts/Repository/NodeRepositoryInterface.php +++ b/app/Contracts/Repository/NodeRepositoryInterface.php @@ -21,6 +21,14 @@ interface NodeRepositoryInterface extends RepositoryInterface, SearchableInterfa */ public function getUsageStats(Node $node): array; + /** + * Return the usage stats for a single node. + * + * @param \Pterodactyl\Models\Node $node + * @return array + */ + public function getUsageStatsRaw(Node $node): array; + /** * Return all available nodes with a searchable interface. * diff --git a/app/Contracts/Repository/RepositoryInterface.php b/app/Contracts/Repository/RepositoryInterface.php index c6f5b7b1..1a26eed7 100644 --- a/app/Contracts/Repository/RepositoryInterface.php +++ b/app/Contracts/Repository/RepositoryInterface.php @@ -200,4 +200,11 @@ interface RepositoryInterface * @return bool */ public function insertIgnore(array $values): bool; + + /** + * Get the amount of entries in the database + * + * @return int + */ + public function count(): int; } diff --git a/app/Contracts/Repository/ServerRepositoryInterface.php b/app/Contracts/Repository/ServerRepositoryInterface.php index c949b760..344fa248 100644 --- a/app/Contracts/Repository/ServerRepositoryInterface.php +++ b/app/Contracts/Repository/ServerRepositoryInterface.php @@ -145,4 +145,11 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter * @return bool */ public function isUniqueUuidCombo(string $uuid, string $short): bool; + + /** + * Get the amount of servers that are suspended + * + * @return int + */ + public function getSuspendedServersCount(): int; } diff --git a/app/Http/Controllers/Admin/StatisticsController.php b/app/Http/Controllers/Admin/StatisticsController.php new file mode 100644 index 00000000..2327fd88 --- /dev/null +++ b/app/Http/Controllers/Admin/StatisticsController.php @@ -0,0 +1,101 @@ +allocationRepository = $allocationRepository; + $this->databaseRepository = $databaseRepository; + $this->eggRepository = $eggRepository; + $this->nodeRepository = $nodeRepository; + $this->serverRepository = $serverRepository; + $this->userRepository = $userRepository; + } + + public function index() + { + $servers = $this->serverRepository->all(); + $nodes = $this->nodeRepository->all(); + $usersCount = $this->userRepository->count(); + $eggsCount = $this->eggRepository->count(); + $databasesCount = $this->databaseRepository->count(); + $totalAllocations = $this->allocationRepository->count(); + $suspendedServersCount = $this->serverRepository->getSuspendedServersCount(); + + $totalServerRam = 0; + $totalNodeRam = 0; + $totalServerDisk = 0; + $totalNodeDisk = 0; + foreach ($nodes as $node) { + $stats = $this->nodeRepository->getUsageStatsRaw($node); + $totalServerRam += $stats['memory']['value']; + $totalNodeRam += $stats['memory']['max']; + $totalServerDisk += $stats['disk']['value']; + $totalNodeDisk += $stats['disk']['max']; + } + + $tokens = []; + foreach ($nodes as $node) { + $tokens[$node->id] = $node->daemonSecret; + } + + $this->injectJavascript([ + 'servers' => $servers, + 'suspendedServers' => $suspendedServersCount, + 'totalServerRam' => $totalServerRam, + 'totalNodeRam' => $totalNodeRam, + 'totalServerDisk' => $totalServerDisk, + 'totalNodeDisk' => $totalNodeDisk, + 'nodes' => $nodes, + 'tokens' => $tokens, + ]); + + return view('admin.statistics', [ + 'servers' => $servers, + 'nodes' => $nodes, + 'usersCount' => $usersCount, + 'eggsCount' => $eggsCount, + 'totalServerRam' => $totalServerRam, + 'databasesCount' => $databasesCount, + 'totalNodeRam' => $totalNodeRam, + 'totalNodeDisk' => $totalNodeDisk, + 'totalServerDisk' => $totalServerDisk, + 'totalAllocations' => $totalAllocations, + ]); + } + +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 72158193..d21c8d3c 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Http; +use Pterodactyl\Http\Middleware\MaintenanceMiddleware; use Pterodactyl\Models\ApiKey; use Illuminate\Auth\Middleware\Authorize; use Illuminate\Auth\Middleware\Authenticate; @@ -108,6 +109,7 @@ class Kernel extends HttpKernel 'can' => Authorize::class, 'bindings' => SubstituteBindings::class, 'recaptcha' => VerifyReCaptcha::class, + 'node.maintenance' => MaintenanceMiddleware::class, // Server specific middleware (used for authenticating access to resources) // diff --git a/app/Http/Middleware/MaintenanceMiddleware.php b/app/Http/Middleware/MaintenanceMiddleware.php new file mode 100644 index 00000000..c67a3f05 --- /dev/null +++ b/app/Http/Middleware/MaintenanceMiddleware.php @@ -0,0 +1,44 @@ +response = $response; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + /** @var \Pterodactyl\Models\Server $server */ + $server = $request->attributes->get('server'); + $node = $server->getRelation('node'); + + if ($node->maintenance_mode) { + return $this->response->view('errors.maintenance'); + } + + return $next($request); + } +} diff --git a/app/Models/Node.php b/app/Models/Node.php index 26d9eb44..2643d062 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -48,6 +48,7 @@ class Node extends Model implements CleansAttributes, ValidableContract 'daemonSFTP' => 'integer', 'behind_proxy' => 'boolean', 'public' => 'boolean', + 'maintenance_mode' => 'boolean', ]; /** @@ -62,7 +63,7 @@ class Node extends Model implements CleansAttributes, ValidableContract 'disk_overallocate', 'upload_size', 'daemonSecret', 'daemonBase', 'daemonSFTP', 'daemonListen', - 'description', + 'description', 'maintenance_mode', ]; /** @@ -111,6 +112,7 @@ class Node extends Model implements CleansAttributes, ValidableContract 'daemonBase' => 'regex:/^([\/][\d\w.\-\/]+)$/', 'daemonSFTP' => 'numeric|between:1024,65535', 'daemonListen' => 'numeric|between:1024,65535', + 'maintenance_mode' => 'boolean', ]; /** @@ -126,6 +128,7 @@ class Node extends Model implements CleansAttributes, ValidableContract 'daemonBase' => '/srv/daemon-data', 'daemonSFTP' => 2022, 'daemonListen' => 8080, + 'maintenance_mode' => false, ]; /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 3de307d9..f0e97811 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -33,7 +33,7 @@ class RouteServiceProvider extends ServiceProvider ->namespace($this->namespace . '\Auth') ->group(base_path('routes/auth.php')); - Route::middleware(['web', 'csrf', 'auth', 'server', 'subuser.auth'])->prefix('/server/{server}') + Route::middleware(['web', 'csrf', 'auth', 'server', 'subuser.auth', 'node.maintenance'])->prefix('/server/{server}') ->namespace($this->namespace . '\Server') ->group(base_path('routes/server.php')); diff --git a/app/Repositories/Eloquent/EloquentRepository.php b/app/Repositories/Eloquent/EloquentRepository.php index 74ec809f..64e7cfb6 100644 --- a/app/Repositories/Eloquent/EloquentRepository.php +++ b/app/Repositories/Eloquent/EloquentRepository.php @@ -296,4 +296,14 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf return $this->getBuilder()->getConnection()->statement($statement, $bindings); } + + /** + * Get the amount of entries in the database + * + * @return int + */ + public function count(): int + { + return $this->getBuilder()->count(); + } } diff --git a/app/Repositories/Eloquent/NodeRepository.php b/app/Repositories/Eloquent/NodeRepository.php index b4d6ba6b..4f59fddc 100644 --- a/app/Repositories/Eloquent/NodeRepository.php +++ b/app/Repositories/Eloquent/NodeRepository.php @@ -56,6 +56,33 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa })->toArray(); } + /** + * Return the usage stats for a single node. + * + * @param \Pterodactyl\Models\Node $node + * @return array + */ + public function getUsageStatsRaw(Node $node): array + { + $stats = $this->getBuilder()->select( + $this->getBuilder()->raw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk') + )->join('servers', 'servers.node_id', '=', 'nodes.id')->where('node_id', $node->id)->first(); + + return collect(['disk' => $stats->sum_disk, 'memory' => $stats->sum_memory])->mapWithKeys(function ($value, $key) use ($node) { + $maxUsage = $node->{$key}; + if ($node->{$key . '_overallocate'} > 0) { + $maxUsage = $node->{$key} * (1 + ($node->{$key . '_overallocate'} / 100)); + } + + return [ + $key => [ + 'value' => $value, + 'max' => $maxUsage, + ], + ]; + })->toArray(); + } + /** * Return all available nodes with a searchable interface. * diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index 90c2601e..f448f0b7 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -328,4 +328,14 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt $this->app->make(SubuserRepository::class)->getBuilder()->select('server_id')->where('user_id', $user) )->pluck('id')->all(); } + + /** + * Get the amount of servers that are suspended + * + * @return int + */ + public function getSuspendedServersCount(): int + { + return $this->getBuilder()->where('suspended', true)->count(); + } } diff --git a/app/Traits/Controllers/PlainJavascriptInjection.php b/app/Traits/Controllers/PlainJavascriptInjection.php new file mode 100644 index 00000000..eae53bfb --- /dev/null +++ b/app/Traits/Controllers/PlainJavascriptInjection.php @@ -0,0 +1,24 @@ +boolean('maintenance_mode')->after('behind_proxy')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('nodes', function (Blueprint $table) { + $table->dropColumn('maintenance_mode'); + }); + } +} diff --git a/public/themes/pterodactyl/css/pterodactyl.css b/public/themes/pterodactyl/css/pterodactyl.css index 9e7e6a82..41f163f3 100644 --- a/public/themes/pterodactyl/css/pterodactyl.css +++ b/public/themes/pterodactyl/css/pterodactyl.css @@ -473,3 +473,7 @@ label.control-label > span.field-optional:before { height: 42px; width: auto; } + +.number-info-box-content { + padding: 15px 10px 0; +} diff --git a/public/themes/pterodactyl/js/admin/statistics.js b/public/themes/pterodactyl/js/admin/statistics.js new file mode 100644 index 00000000..7433f322 --- /dev/null +++ b/public/themes/pterodactyl/js/admin/statistics.js @@ -0,0 +1,118 @@ +var freeDisk = Pterodactyl.totalNodeDisk - Pterodactyl.totalServerDisk; +let diskChart = new Chart($('#disk_chart'), { + type: 'pie', + data: { + labels: ['Free Disk', 'Used Disk'], + datasets: [ + { + label: 'Disk (in MB)', + backgroundColor: ['#51B060', '#ff0000'], + data: [freeDisk, Pterodactyl.totalServerDisk] + } + ] + } +}); + +var freeRam = Pterodactyl.totalNodeRam - Pterodactyl.totalServerRam; +let ramChart = new Chart($('#ram_chart'), { + type: 'pie', + data: { + labels: ['Free RAM', 'Used RAM'], + datasets: [ + { + label: 'Memory (in MB)', + backgroundColor: ['#51B060', '#ff0000'], + data: [freeRam, Pterodactyl.totalServerRam] + } + ] + } +}); + +var activeServers = Pterodactyl.servers.length - Pterodactyl.suspendedServers; +let serversChart = new Chart($('#servers_chart'), { + type: 'pie', + data: { + labels: ['Active', 'Suspended'], + datasets: [ + { + label: 'Servers', + backgroundColor: ['#51B060', '#E08E0B'], + data: [activeServers, Pterodactyl.suspendedServers] + } + ] + } +}); + +let statusChart = new Chart($('#status_chart'), { + type: 'pie', + data: { + labels: ['Online', 'Offline', 'Installing', 'Error'], + datasets: [ + { + label: '', + backgroundColor: ['#51B060', '#b7b7b7', '#E08E0B', '#ff0000'], + data: [0,0,0,0] + } + ] + } +}); + +var servers = Pterodactyl.servers; +var nodes = Pterodactyl.nodes; + +for (let i = 0; i < servers.length; i++) { + setTimeout(getStatus, 200 * i, servers[i]); +} + +function getStatus(server) { + var uuid = server.uuid; + var node = getNodeByID(server.node_id); + + $.ajax({ + type: 'GET', + url: node.scheme + '://' + node.fqdn + ':'+node.daemonListen+'/v1/server', + timeout: 5000, + headers: { + 'X-Access-Server': uuid, + 'X-Access-Token': Pterodactyl.tokens[node.id], + } + }).done(function (data) { + + if (typeof data.status === 'undefined') { + // Error + statusChart.data.datasets[0].data[3]++; + return; + } + + switch (data.status) { + case 0: + case 3: + case 30: + // Offline + statusChart.data.datasets[0].data[1]++; + break; + case 1: + case 2: + // Online + statusChart.data.datasets[0].data[0]++; + break; + case 20: + // Installing + statusChart.data.datasets[0].data[2]++; + break; + } + statusChart.update(); + }).fail(function (jqXHR) { + // Error + statusChart.data.datasets[0].data[3]++; + statusChart.update(); + }); +} + +function getNodeByID(id) { + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].id === id) { + return nodes[i]; + } + } +} \ No newline at end of file diff --git a/public/themes/pterodactyl/js/frontend/files/upload.js b/public/themes/pterodactyl/js/frontend/files/upload.js index 2bb1dd8c..873fbf63 100644 --- a/public/themes/pterodactyl/js/frontend/files/upload.js +++ b/public/themes/pterodactyl/js/frontend/files/upload.js @@ -61,6 +61,18 @@ event.preventDefault(); }, false); + window.foldersDetectedInDrag = function (event) { + var folderDetected = false; + var files = event.dataTransfer.files; + for (var i = 0, f; f = files[i]; i++) { + if (!f.type && f.size === 0) { + return true; + } + } + + return folderDetected; + }; + var dropCounter = 0; $('#load_files').bind({ dragenter: function (event) { @@ -75,6 +87,15 @@ } }, drop: function (event) { + if (window.foldersDetectedInDrag(event.originalEvent)) { + $.notify({ + message: 'Folder uploads are not supported. Please use SFTP to upload whole directories.', + }, { + type: 'warning', + delay: 0 + }); + } + dropCounter = 0; $(this).removeClass('hasFileHover'); } diff --git a/resources/lang/en/base.php b/resources/lang/en/base.php index 2c0f6c62..01ac79b1 100644 --- a/resources/lang/en/base.php +++ b/resources/lang/en/base.php @@ -21,6 +21,11 @@ return [ 'header' => 'Server Suspended', 'desc' => 'This server has been suspended and cannot be accessed.', ], + 'maintenance' => [ + 'header' => 'Node Under Maintenance', + 'title' => 'Temporarily Unavailable', + 'desc' => 'This node is under maintenance, therefore your server can temporarily not be accessed.', + ], ], 'index' => [ 'header' => 'Your Servers', diff --git a/resources/lang/en/strings.php b/resources/lang/en/strings.php index 4f1d611e..ebdd60d2 100644 --- a/resources/lang/en/strings.php +++ b/resources/lang/en/strings.php @@ -74,6 +74,7 @@ return [ 'tasks' => 'Tasks', 'seconds' => 'Seconds', 'minutes' => 'Minutes', + 'under_maintenance' => 'Under Maintenance', 'days' => [ 'sun' => 'Sunday', 'mon' => 'Monday', diff --git a/resources/themes/pterodactyl/admin/nodes/index.blade.php b/resources/themes/pterodactyl/admin/nodes/index.blade.php index abaf25e5..b4ea579a 100644 --- a/resources/themes/pterodactyl/admin/nodes/index.blade.php +++ b/resources/themes/pterodactyl/admin/nodes/index.blade.php @@ -56,7 +56,7 @@ @foreach ($nodes as $node)
If you are running the daemon behind a proxy such as Cloudflare, select this to have the daemon skip looking for certificates on boot.
If the node is marked as 'Under Maintenance' users won't be able to access servers that are on this node.
+@lang('base.errors.maintenance.desc')
+