diff --git a/CHANGELOG.md b/CHANGELOG.md index 449c129ef..27eca5765 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 0ebcbe3a0..c533032c0 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 c6f5b7b15..1a26eed7e 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 c949b7609..344fa248c 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 000000000..2327fd88d --- /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 72158193f..d21c8d3c8 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 000000000..c67a3f051 --- /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 26d9eb443..2643d062a 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 3de307d9a..f0e978116 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 74ec809fe..64e7cfb60 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 b4d6ba6b0..4f59fddce 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 90c2601ea..f448f0b78 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 000000000..eae53bfbc --- /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 9e7e6a822..41f163f3b 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 000000000..7433f3221 --- /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 2bb1dd8c7..873fbf636 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 2c0f6c62b..01ac79b1e 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 4f1d611ef..ebdd60d23 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 abaf25e54..b4ea579a1 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')
+