forked from Alex/Pterodactyl-Panel
Merge branch 'develop' into feature/react-admin
This commit is contained in:
commit
a3b59f24af
@ -25,6 +25,11 @@ MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM=no-reply@example.com
|
||||
# You should set this to your domain to prevent it defaulting to 'localhost', causing
|
||||
# mail servers such as Gmail to reject your mail.
|
||||
#
|
||||
# @see: https://github.com/pterodactyl/panel/pull/3110
|
||||
# SERVER_NAME=panel.yourdomain.com
|
||||
|
||||
QUEUE_HIGH=high
|
||||
QUEUE_STANDARD=standard
|
||||
|
@ -1,2 +1,4 @@
|
||||
public
|
||||
node_modules
|
||||
resources/views
|
||||
webpack.config.js
|
||||
|
@ -76,9 +76,13 @@ rules:
|
||||
- 1
|
||||
- "line-aligned"
|
||||
"react/jsx-closing-tag-location": 1
|
||||
"no-use-before-define": 0
|
||||
"@typescript-eslint/no-use-before-define": 1
|
||||
"multiline-ternary": 0
|
||||
# This setup is required to avoid a spam of errors when running eslint about React being
|
||||
# used before it is defined.
|
||||
#
|
||||
# see https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
|
||||
no-use-before-define: 0
|
||||
"@typescript-eslint/no-use-before-define":
|
||||
- warn
|
||||
overrides:
|
||||
- files:
|
||||
- "**/*.tsx"
|
||||
|
23
.github/ISSUE_TEMPLATE/---bug-report.md
vendored
23
.github/ISSUE_TEMPLATE/---bug-report.md
vendored
@ -1,31 +1,38 @@
|
||||
---
|
||||
name: "\U0001F41B Bug Report"
|
||||
about: For reporting code or design bugs with the software. DO NOT REPORT APACHE/NGINX/PHP CONFIGURATION ISSUES.
|
||||
|
||||
---
|
||||
|
||||
DO NOT REPORT ISSUES CONFIGURING: SSL, PHP, APACHE, NGINX, YOUR MACHINE, SSH, SFTP, ETC. ON THIS GITHUB TRACKER.
|
||||
|
||||
For assistance installing this software, as well as debugging issues with dependencies, please use our discord server: https://discord.gg/pterodactyl
|
||||
|
||||
You MUST complete all of the below information when reporting a bug, failure to do so will result in closure of your issue. PLEASE stop spamming our tracker with "bugs" that are not related to this project.
|
||||
You MUST complete all of the below information when reporting a bug, failure to do so will result in the closure of your issue. PLEASE stop spamming our tracker with "bugs" that are not related to this project.
|
||||
|
||||
To obtain logs for the panel and wings the below commands should help with the retrieval of them.
|
||||
Panel: tail -n 100 /var/www/pterodactyl/storage/logs/laravel-$(date +%F).log | nc bin.ptdl.co 99
|
||||
Wings: sudo wings diagnostics
|
||||
|
||||
**STOP: READ FIRST, AND THEN DELETE THE ABOVE LINES**
|
||||
|
||||
**Background (please complete the following information):**
|
||||
* Panel or Daemon:
|
||||
* Version of Panel/Daemon:
|
||||
* Server's OS:
|
||||
* Your Computer's OS & Browser:
|
||||
|
||||
* Panel or Wings:
|
||||
* Version of Panel/Wings:
|
||||
* Panel Logs:
|
||||
* Wings Logs:
|
||||
* Server's OS:
|
||||
* Your Computer's OS & Browser:
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
Please provide additional information too, depending on what you have issues with:
|
||||
Panel: `php -v` (the php version in use).
|
||||
Daemon: `uname -a` and `docker info` (your kernel version and information regarding docker)
|
||||
Wings: `uname -a` and `docker info` (your kernel version and information regarding docker)
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
Steps to reproduce this behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
|
4
.github/docker/README.md
vendored
4
.github/docker/README.md
vendored
@ -2,7 +2,7 @@
|
||||
This is a ready to use docker image for the panel.
|
||||
|
||||
## Requirements
|
||||
This docker image requires some additional software to function. The software can either be provided in other containers (see the [docker-compose.yml](docker-compose.yml) as an example) or as existing instances.
|
||||
This docker image requires some additional software to function. The software can either be provided in other containers (see the [docker-compose.yml](https://github.com/pterodactyl/panel/blob/develop/docker-compose.example.yml) as an example) or as existing instances.
|
||||
|
||||
A mysql database is required. We recommend the stock [MariaDB Image](https://hub.docker.com/_/mariadb/) image if you prefer to run it in a docker container. As a non-containerized option we recommend mariadb.
|
||||
|
||||
@ -73,4 +73,4 @@ Every driver requires `MAIL_FROM` to be set.
|
||||
| mandrill | [Mandrill](http://www.mandrill.com/) | `MAIL_USERNAME` |
|
||||
| postmark | [Postmark](https://postmarkapp.com/) | `MAIL_USERNAME` |
|
||||
| mailgun | [Mailgun](https://www.mailgun.com/) | `MAIL_USERNAME`, `MAIL_HOST` |
|
||||
| smtp | Any SMTP server can be configured | `MAIL_USERNAME`, `MAIL_HOST`, `MAIL_PASSWORD`, `MAIL_PORT` |
|
||||
| smtp | Any SMTP server can be configured | `MAIL_USERNAME`, `MAIL_HOST`, `MAIL_PASSWORD`, `MAIL_PORT` |
|
||||
|
2
.github/docker/entrypoint.sh
vendored
2
.github/docker/entrypoint.sh
vendored
@ -1,4 +1,4 @@
|
||||
#!/bin/ash
|
||||
#!/bin/ash -e
|
||||
cd /app
|
||||
|
||||
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php7/ \
|
||||
|
35
.github/workflows/tests.yml
vendored
35
.github/workflows/tests.yml
vendored
@ -1,17 +1,25 @@
|
||||
name: Run Test Suite
|
||||
name: run tests
|
||||
on:
|
||||
push:
|
||||
branch-ignore:
|
||||
- 'master'
|
||||
- 'release/**'
|
||||
branches-ignore:
|
||||
- master
|
||||
- "release/**"
|
||||
pull_request:
|
||||
jobs:
|
||||
integration_tests:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, 'skip ci') && !contains(github.event.head_commit.message, 'ci skip')"
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.2
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
||||
MYSQL_DATABASE: panel_test
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
||||
MYSQL_DATABASE: panel_test
|
||||
@ -21,8 +29,9 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: [7.4, 8.0]
|
||||
name: PHP ${{ matrix.php }}
|
||||
php: [ 7.4, 8.0 ]
|
||||
database: [ mysql, mariadb ]
|
||||
name: "php-${{ matrix.php }} (engine: ${{ matrix.database }})"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
@ -59,9 +68,15 @@ jobs:
|
||||
env:
|
||||
DB_CONNECTION: testing
|
||||
TESTING_DB_HOST: UNIT_NO_DB
|
||||
- name: execute integration tests
|
||||
- name: execute integration tests (mysql)
|
||||
run: vendor/bin/phpunit tests/Integration
|
||||
if: ${{ always() }}
|
||||
if: "${{ matrix.database }} == 'mysql'"
|
||||
env:
|
||||
TESTING_DB_PORT: ${{ job.services.mysql.ports[3306] }}
|
||||
TESTING_DB_USERNAME: root
|
||||
- name: execute integration tests (mariadb)
|
||||
run: vendor/bin/phpunit tests/Integration
|
||||
if: "${{ matrix.database }} == 'mariadb'"
|
||||
env:
|
||||
TESTING_DB_PORT: ${{ job.services.mariadb.ports[3306] }}
|
||||
TESTING_DB_USERNAME: root
|
||||
|
16
.sami.php
16
.sami.php
@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Sami\Sami;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
$iterator = Finder::create()
|
||||
->files()
|
||||
->name('*.php')
|
||||
->in($dir = __DIR__ . '/app');
|
||||
|
||||
return new Sami($iterator, array(
|
||||
'title' => 'Pterodactyl',
|
||||
'build_dir' => __DIR__ . '/.sami/build',
|
||||
'cache_dir' => __DIR__ . '/.sami/cache',
|
||||
'default_opened_level' => 2,
|
||||
));
|
@ -1,7 +0,0 @@
|
||||
preset: laravel
|
||||
risky: false
|
||||
disabled:
|
||||
- concat_without_spaces
|
||||
enabled:
|
||||
- concat_with_spaces
|
||||
- no_unused_imports
|
36
.travis.yml
36
.travis.yml
@ -1,36 +0,0 @@
|
||||
language: php
|
||||
dist: trusty
|
||||
git:
|
||||
depth: 3
|
||||
quiet: true
|
||||
matrix:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- env: TEST_SUITE=Coverage
|
||||
env:
|
||||
matrix:
|
||||
- TEST_SUITE=Unit
|
||||
- TEST_SUITE=Coverage
|
||||
- TEST_SUITE=Integration
|
||||
php:
|
||||
- 7.4
|
||||
sudo: false
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.composer/cache
|
||||
services:
|
||||
- mysql
|
||||
before_install:
|
||||
- mysql -e 'CREATE DATABASE IF NOT EXISTS travis;'
|
||||
before_script:
|
||||
- echo 'opcache.enable_cli=1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini
|
||||
- cp .env.travis .env
|
||||
- travis_retry composer install --no-interaction --prefer-dist --no-suggest
|
||||
script:
|
||||
- if [ "$TEST_SUITE" = "Unit" ]; then vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Unit; fi;
|
||||
- if [ "$TEST_SUITE" = "Coverage" ]; then vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-clover coverage.xml tests/Unit; fi;
|
||||
- if [ "$TEST_SUITE" = "Integration" ]; then vendor/bin/phpunit tests/Integration; fi;
|
||||
notifications:
|
||||
email: false
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
46
BUILDING.md
46
BUILDING.md
@ -1,10 +1,13 @@
|
||||
# Local Development
|
||||
Pterodactyl is now powered by Vuejs and Tailwindcss and uses webpack at its core to generate compiled assets. Release
|
||||
versions of Pterodactyl will include pre-compiled, minified, and hashed assets ready-to-go.
|
||||
Pterodactyl is now powered by React, Typescript, and Tailwindcss using webpack at its core to generate compiled assets.
|
||||
Release versions of Pterodactyl will include pre-compiled, minified, and hashed assets ready-to-go.
|
||||
|
||||
However, if you are interested in running custom themes or making modifications to the Vue files you'll need a build
|
||||
system in place to generate these compiled assets. To get your environment setup, you'll first need to install at least Nodejs
|
||||
`8`, and it is _highly_ recommended that you also install [Yarn](https://yarnpkg.com) to manage your `node_modules`.
|
||||
However, if you are interested in running custom themes or making modifications to the React files you'll need a build
|
||||
system in place to generate these compiled assets. To get your environment setup you'll need at minimum:
|
||||
|
||||
* Node.js 12
|
||||
* [Yarn](https://classic.yarnpkg.com/lang/en/) v1
|
||||
* [Go](https://golang.org/) 1.15.
|
||||
|
||||
### Install Dependencies
|
||||
```bash
|
||||
@ -12,17 +15,19 @@ yarn install
|
||||
```
|
||||
|
||||
The command above will download all of the dependencies necessary to get Pterodactyl assets building. After that, its as
|
||||
simple as running the command below to generate assets while you're developing.
|
||||
simple as running the command below to generate assets while you're developing. Until you've run this command at least
|
||||
once you'll likely see a 500 error on your Panel about a missing `manifest.json` file. This is generated by the commands
|
||||
below.
|
||||
|
||||
```bash
|
||||
# build the compiled assets for development
|
||||
# Build the compiled set of assets for development.
|
||||
yarn run build
|
||||
|
||||
# build the assets automatically when files are modified
|
||||
# Build the assets automatically as they are changed. This allows you to refresh
|
||||
# the page and see the changes immediately.
|
||||
yarn run watch
|
||||
```
|
||||
|
||||
|
||||
### Hot Module Reloading
|
||||
For more advanced users, we also support 'Hot Module Reloading', allowing you to quickly see changes you're making
|
||||
to the Vue template files without having to reload the page you're on. To Get started with this, you just need
|
||||
@ -37,21 +42,22 @@ is the `--host` flag, which is required and should point to the machine where th
|
||||
The second is the `PUBLIC_PATH` environment variable which is the URL pointing to the HMR server and is appended to all of
|
||||
the asset URLs used in Pterodactyl.
|
||||
|
||||
#### Vagrant
|
||||
If you want to use HMR with our Vagrant image, you can use `yarn run v:serve` as a shortcut for the correct parameters.
|
||||
In order to have proper file change detection you can use the [`vagrant-notify-forwarder`](https://github.com/mhallin/vagrant-notify-forwarder) to notify file events from the host to the VM.
|
||||
```sh
|
||||
vagrant plugin install vagrant-notify-forwarder
|
||||
vagrant reload
|
||||
```
|
||||
#### Development Environment
|
||||
If you're using the [`pterodactyl/development`](https://github.com/pterodactyl/development) environments, which are
|
||||
highly recommended, you can just run `yarn run serve` to run the HMR server, no additional configuration is necessary.
|
||||
|
||||
### Building for Production
|
||||
Once you have your files squared away and ready for the live server, you'll be needing to generate compiled, minified, and
|
||||
hashed assets to push live. To do so, run the command below:
|
||||
Once you have your files squared away and ready for the live server, you'll be needing to generate compiled, minified,
|
||||
and hashed assets to push live. To do so, run the command below:
|
||||
|
||||
```bash
|
||||
yarn run build:production
|
||||
```
|
||||
|
||||
This will generate a production ready `bundle.js` and `bundle.css` as well as a `manifest.json` and store them in
|
||||
the `/public/assets` directory where they can then be access by clients, and read by the Panel.
|
||||
This will generate a production JS bundle and associated assets, all located in `public/assets/` which will need to
|
||||
be uploaded to your server or CDN for clients to use.
|
||||
|
||||
### Running Wings
|
||||
To run `wings` in development all you need to do is set up the configuration file as normal when adding a new node, and
|
||||
then you can build and run a local version of Wings by executing `make debug` in the Wings code directory. This must
|
||||
be run on a Linux VM of some sort, you cannot run this locally on macOS or Windows.
|
||||
|
34
CHANGELOG.md
34
CHANGELOG.md
@ -3,6 +3,40 @@ 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.
|
||||
|
||||
## v1.4.1
|
||||
### Added
|
||||
* Adds support for only running a schedule if the server is currently in an online state.
|
||||
* Adds support for ignoring errors during task execution and continuing on to the next item in the sequence. For example, continuing to a server restart even if sending a command beforehand failed.
|
||||
* Adds the ability to specify the group to use for file permissions when using the `p:upgrade` command.
|
||||
* Adds the ability to manually run a schedule even if it is currently disabled.
|
||||
|
||||
## v1.4.0
|
||||
### Fixed
|
||||
* Removes the use of tagging when storing server resource usage in the cache. This addresses errors encountered when using the `file` driver.
|
||||
* Fixes Wings response handling if Wings returns an error response with a 200-level status code that would improperly be passed back to the client as a successful request.
|
||||
* Fixes use of JSON specific functions in SQL queries to better support MariaDB users.
|
||||
* Fixes a migration that could fail on some MySQL/MariaDB setups when trying to encrypt node token values.
|
||||
|
||||
### Changed
|
||||
* Increases the maximum length allowed for a server name using the Rust egg.
|
||||
* Updated server resource utilization API call to Wings to use new API response format used by `Wings@1.4.0`.
|
||||
|
||||
## v1.3.2
|
||||
### Fixed
|
||||
* Fixes self-upgrade incorrectly executing the command to un-tar downloaded archives.
|
||||
* Fixes the checkbox to delete all files when restoring a backup not actually passing that along in the API call. Files will now properly be deleted when restoring if selected.
|
||||
* Fixes some keybindings not working correctly in the server console on Windows machines.
|
||||
* Fixes mobile UI incorrectly squishing the Docker image selector on the server settings page.
|
||||
* Fixes recovery tokens not having a `created_at` value set on them properly when they are created.
|
||||
* Fixes flawed migration that would not correctly set the month value into schedule crons.
|
||||
* Fixes incorrect mounting for Docker compose file that would cause error logs to be missing.
|
||||
|
||||
### Changed
|
||||
* Server resource lookups are now cached on the Panel for 20 seconds at a time to reduce the load from multiple clients requesting the same server's stats.
|
||||
* Bungeecord egg no longer force-enables the query listener.
|
||||
* Adds page to the dashboard URL to allow easy loading of a specific pagination page rather than resetting back to the first page when refreshing.
|
||||
* All application API endpoints now correctly support the `?per_page=N` query parameter to specify how many resources to return at once.
|
||||
|
||||
## v1.3.1
|
||||
### Fixed
|
||||
* Fixes the Rust egg not properly seeding during the upgrade & installation process.
|
||||
|
@ -1,44 +1,55 @@
|
||||
# Contributing
|
||||
We're glad you want to help us out and make this panel the best that it can be! We have a few simple things to follow when making changes to files and adding new features.
|
||||
We're glad you want to help us out and make this panel the best that it can be! We have a few simple things to follow
|
||||
when making changes to files and adding new features.
|
||||
|
||||
### Development Environment
|
||||
Please check the [`pterodactyl/development`](https://github.com/pterodactyl/development) repository for a Vagrant &
|
||||
Docker setup that should run on most macOS and Linux distributions. In the event that your platform is not supported
|
||||
you're welcome to open a PR, or just take a look at our setup scripts to see what you'll need to successfully develop
|
||||
with Pterodactyl.
|
||||
|
||||
#### Building Assets
|
||||
Please see [`BUILDING.md`](https://github.com/pterodactyl/panel/blob/develop/BUILDING.md) for details on how to actually
|
||||
build and run the development server.
|
||||
|
||||
### Project Branches
|
||||
This section mainly applies to those with read/write access to our repositories, but can be helpful for others.
|
||||
|
||||
The `develop` branch should always be in a runnable state, and not contain any major breaking features. For the most part, this means you will need to create `feature/` branches in order to add new functionality or change how things work. When making a feature branch, if it is referencing something in the issue tracker, please title the branch `feature/PTDL-###` where `###` is the issue number.
|
||||
The `develop` branch should always be in a runnable state, and not contain any major breaking features. For the most
|
||||
part, this means you will need to create `feature/` branches in order to add new functionality or change how things
|
||||
work. When making a feature branch, if it is referencing something in the issue tracker, please title the branch
|
||||
`feature/PTDL-###` where `###` is the issue number.
|
||||
|
||||
Moving forward all commits from contributors should be in the form of a PR, unless it is something we have previously discussed as being able to be pushed right into `develop`.
|
||||
All new code should contain tests to ensure their functionality is not unintentionally changed down the road. This
|
||||
is especially important for any API actions or authentication based controls.
|
||||
|
||||
All new code should contain unit tests at a minimum (where applicable). There is a lot of uncovered code currently, so as you are doing things please be looking for places that you can write tests.
|
||||
|
||||
### Update the CHANGELOG
|
||||
When adding something that is new, fixed, changed, or security-related for the next release you should be adding a note to the CHANGELOG. If something is changing within the same version (i.e. fixing a bug introduced but not released) it should _not_ go into the CHANGELOG.
|
||||
### The CHANGELOG
|
||||
You should not make any changes to the `CHANGELOG.md` file during your code updates. This is updated by the maintainers
|
||||
at the time of deployment to include the relevant changes that were made.
|
||||
|
||||
### Code Guidelines
|
||||
We are a `PSR-4` and `PSR-0` compliant project, so please follow those guidelines at a minimum. In addition, StyleCI runs on all of our code to ensure the formatting is standardized across everything. When a PR is made StyleCI will analyze your code and make a pull to that branch if necessary to fix any formatting issues. This project also ships with a PHP-CS configuration file and you are welcome to configure your local environment to make use of that.
|
||||
We are a `PSR-4` and `PSR-0` compliant project, so please follow those guidelines at a minimum. In addition we run
|
||||
`php-cs-fixer` on all PRs and releases to enforce a consistent code style. The following command executed on your machine
|
||||
should show any areas where the code style does not line up correctly.
|
||||
|
||||
All class variable declarations should be in alphabetical order, and constructor arguments should be in alphabetical order based on the classname. See the example below for how this should look, or check out any of the `app/Service` files for examples.
|
||||
|
||||
```php
|
||||
class ProcessScheduleService
|
||||
{
|
||||
protected $repository;
|
||||
protected $runnerService;
|
||||
|
||||
public function __construct(RunTaskService $runnerService, ScheduleRepositoryInterface $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->runnerService = $runnerService;
|
||||
}
|
||||
```
|
||||
vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --config .php_cs.dist
|
||||
```
|
||||
|
||||
### Responsible Disclosure
|
||||
This is a fairly in-depth project and makes use of a lot of parts. We strive to keep everything as secure as possible and welcome you to take a look at the code provided in this project yourself. We do ask that you be considerate of others who are using the software and not publicly disclose security issues without contacting us first by email.
|
||||
This is a fairly in-depth project and makes use of a lot of parts. We strive to keep everything as secure as possible
|
||||
and welcome you to take a look at the code provided in this project yourself. We do ask that you be considerate of
|
||||
others who are using the software and not publicly disclose security issues without contacting us first by email.
|
||||
|
||||
We'll make a deal with you: if you contact us by email and we fail to respond to you within a week you are welcome to publicly disclose whatever issue you have found. We understand how frustrating it is when you find something big and no one will respond to you. This holds us to a standard of providing prompt attention to any issues that arise and keeping this community safe.
|
||||
We'll make a deal with you: if you contact us by email and we fail to respond to you within a week you are welcome to
|
||||
publicly disclose whatever issue you have found. We understand how frustrating it is when you find something big and
|
||||
no one will respond to you. This holds us to a standard of providing prompt attention to any issues that arise and
|
||||
keeping this community safe.
|
||||
|
||||
If you've found what you believe is a security issue please email us at `support@pterodactyl.io`.
|
||||
If you've found what you believe is a security issue please email `dane åt pterodactyl døt io`.
|
||||
|
||||
### Where to find Us
|
||||
You can find us in a couple places online. First and foremost, we're active right here on Github. If you encounter a bug or other problems, open an issue on here for us to take a look at it. We also accept feature requests here as well.
|
||||
### Contact Us
|
||||
You can find us in a couple places online. First and foremost, we're active right here on Github. If you encounter a
|
||||
bug or other problems, open an issue on here for us to take a look at it. We also accept feature requests here as well.
|
||||
|
||||
You can also find us on [Discord](https://pterodactyl.io/discord). In the event that you need to get in contact with us privately feel free to contact us at `support@pterodactyl.io`. Try not to email us with requests for support regarding the panel, we'll probably just direct you to our Discord.
|
||||
You can also find us on [Discord](https://discord.gg/pterodactyl).
|
||||
|
@ -1,14 +0,0 @@
|
||||
# Pterodactyl Panel Contributors
|
||||
This panel would not be possible without the support of our wonderful community of
|
||||
developers who provide code enhancements, new features, and bug fixes to make this panel
|
||||
the best that is can be. You can view a full listing of contributors [here](https://github.com/Pterodactyl/Panel/graphs/contributors).
|
||||
|
||||
Dane Everitt [@DaneEveritt](https://github.com/Pterodactyl/Panel/commits?author=DaneEveritt)
|
||||
|
||||
Dylan Seidt [@DDynamic](https://github.com/Pterodactyl/Panel/commits?author=DDynamic)
|
||||
|
||||
[@nikkiii](https://github.com/Pterodactyl/Panel/commits?author=nikkiii)
|
||||
|
||||
# Get Involved
|
||||
See our `CONTRIBUTING.md` document for information on how to get started. Once you've submitted some code feel free to
|
||||
modify this file and add your name to the list. Please follow the format above for your name and linking to your contributions.
|
@ -1,7 +1,7 @@
|
||||
# The MIT License (MIT)
|
||||
|
||||
```
|
||||
Copyright (c) 2015 - 2020 Dane Everitt <dane@daneeveritt.com>
|
||||
Copyright (c) 2015 - 2021 Dane Everitt <dane@daneeveritt.com> and Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -21,6 +21,8 @@ I would like to extend my sincere thanks to the following sponsors for helping f
|
||||
| Company | About |
|
||||
| ------- | ----- |
|
||||
| [**WISP**](https://wisp.gg) | Extra features. |
|
||||
| [**MixmlHosting**](https://mixmlhosting.com) | MixmlHosting provides high quality Virtual Private Servers along with game servers, all at a affordable price. |
|
||||
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
|
||||
| [**Bloom.host**](https://bloom.host) | Bloom.host offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly unbeatable prices on high-performance hosting. |
|
||||
| [**MineStrator**](https://minestrator.com/) | Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord, trust us. |
|
||||
| [**DedicatedMC**](https://dedicatedmc.io/) | DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance and giving you the best performance money can buy. |
|
||||
@ -32,6 +34,7 @@ I would like to extend my sincere thanks to the following sponsors for helping f
|
||||
| [**HostBend**](https://hostbend.com/) | HostBend offers a variety of solutions for developers, students, and others who have a tight budget but don't want to compromise quality and support. |
|
||||
| [**Capitol Hosting Solutions**](https://capitolsolutions.cloud/) | CHS is *the* budget friendly hosting company for Australian and American gamers, offering a variety of plans from Web Hosting to Game Servers; Custom Solutions too! |
|
||||
| [**ByteAnia**](https://byteania.com/?utm_source=pterodactyl) | ByteAnia offers the best performing and most affordable **Ryzen 5000 Series hosting** on the market for *unbeatable prices*! |
|
||||
| [**Aussie Server Hosts**](https://aussieserverhosts.com/) | No frills Australian Owned and operated High Performance Server hosting for some of the most demanding games serving Australia and New Zealand. |
|
||||
|
||||
## Documentation
|
||||
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)
|
||||
@ -70,7 +73,7 @@ and there are plenty more games available provided by the community. Some of the
|
||||
|
||||
## License
|
||||
```
|
||||
Copyright (c) 2015 - 2020 Dane Everitt <dane@daneeveritt.com> & Contributors
|
||||
Copyright (c) 2015 - 2021 Dane Everitt <dane@daneeveritt.com> and Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -15,6 +15,7 @@ class UpgradeCommand extends Command
|
||||
/** @var string */
|
||||
protected $signature = 'p:upgrade
|
||||
{--user= : The user that PHP runs under. All files will be owned by this user.}
|
||||
{--group= : The group that PHP runs under. All files will be owned by this group.}
|
||||
{--url= : The specific archive to download.}
|
||||
{--release= : A specific Pterodactyl version to download from GitHub. Leave blank to use latest.}
|
||||
{--skip-download : If set no archive will be downloaded.}';
|
||||
@ -46,22 +47,39 @@ class UpgradeCommand extends Command
|
||||
}
|
||||
|
||||
$user = 'www-data';
|
||||
$group = 'www-data';
|
||||
if ($this->input->isInteractive()) {
|
||||
if (!$skipDownload) {
|
||||
$skipDownload = !$this->confirm('Would you like to download and unpack the archive files for the latest version?', true);
|
||||
}
|
||||
|
||||
if (is_null($this->option('user'))) {
|
||||
$details = posix_getpwuid(fileowner('public'));
|
||||
$user = $details['name'] ?? 'www-data';
|
||||
$userDetails = posix_getpwuid(fileowner('public'));
|
||||
$user = $userDetails['name'] ?? 'www-data';
|
||||
|
||||
if (!$this->confirm("Your webserver user has been detected as [{$user}]: is this correct?", true)) {
|
||||
$user = $this->anticipate(
|
||||
'Please enter the name of the user running your webserver process. This varies from system to system, but is generally "www-data", "nginx", or "apache".',
|
||||
[
|
||||
'www-data',
|
||||
'apache',
|
||||
'nginx',
|
||||
'apache',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_null($this->option('group'))) {
|
||||
$groupDetails = posix_getgrgid(filegroup('public'));
|
||||
$group = $groupDetails['name'] ?? 'www-data';
|
||||
|
||||
if (!$this->confirm("Your webserver group has been detected as [{$group}]: is this correct?", true)) {
|
||||
$group = $this->anticipate(
|
||||
'Please enter the name of the group running your webserver process. Normally this is the same as your user.',
|
||||
[
|
||||
'www-data',
|
||||
'nginx',
|
||||
'apache',
|
||||
]
|
||||
);
|
||||
}
|
||||
@ -136,9 +154,9 @@ class UpgradeCommand extends Command
|
||||
$this->call('migrate', ['--seed' => '', '--force' => '']);
|
||||
});
|
||||
|
||||
$this->withProgress($bar, function () use ($user) {
|
||||
$this->line("\$upgrader> chown -R {$user}:{$user} *");
|
||||
$process = Process::fromShellCommandline("chown -R {$user}:{$user} *", $this->getLaravel()->basePath());
|
||||
$this->withProgress($bar, function () use ($user, $group) {
|
||||
$this->line("\$upgrader> chown -R {$user}:{$group} *");
|
||||
$process = Process::fromShellCommandline("chown -R {$user}:{$group} *", $this->getLaravel()->basePath());
|
||||
$process->setTimeout(10 * 60);
|
||||
$process->run(function ($type, $buffer) {
|
||||
$this->{$type === Process::ERR ? 'error' : 'line'}($buffer);
|
||||
|
@ -38,6 +38,15 @@ class DaemonConnectionException extends DisplayException
|
||||
|
||||
if ($useStatusCode) {
|
||||
$this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
|
||||
// There are rare conditions where wings encounters a panic condition and crashes the
|
||||
// request being made after content has already been sent over the wire. In these cases
|
||||
// you can end up with a "successful" response code that is actual an error.
|
||||
//
|
||||
// Handle those better here since we shouldn't ever end up in this exception state and
|
||||
// be returning a 2XX level response.
|
||||
if ($this->statusCode < 400) {
|
||||
$this->statusCode = Response::HTTP_BAD_GATEWAY;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_null($response)) {
|
||||
|
16
app/Exceptions/Service/Backup/BackupLockedException.php
Normal file
16
app/Exceptions/Service/Backup/BackupLockedException.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Exceptions\Service\Backup;
|
||||
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
|
||||
class BackupLockedException extends DisplayException
|
||||
{
|
||||
/**
|
||||
* TooManyBackupsException constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Cannot delete a backup that is marked as locked.');
|
||||
}
|
||||
}
|
@ -108,7 +108,7 @@ class NodeController extends ApplicationApiController
|
||||
$node = $this->updateService->handle(
|
||||
$node,
|
||||
$request->validated(),
|
||||
$request->input('reset_secret')
|
||||
$request->input('reset_secret'),
|
||||
);
|
||||
|
||||
return $this->fractal->item($node)
|
||||
|
@ -35,7 +35,7 @@ class NodeDeploymentController extends ApplicationApiController
|
||||
$nodes = $this->viableNodesService->setLocations($data['location_ids'] ?? [])
|
||||
->setMemory($data['memory'])
|
||||
->setDisk($data['disk'])
|
||||
->handle($request->input('page') ?? 0);
|
||||
->handle($request->query('per_page'), $request->query('page'));
|
||||
|
||||
return $this->fractal->collection($nodes)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
|
@ -72,11 +72,18 @@ class BackupController extends ClientApiController
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Backup $backup */
|
||||
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
|
||||
$backup = $this->initiateBackupService
|
||||
->setIgnoredFiles(
|
||||
explode(PHP_EOL, $request->input('ignored') ?? '')
|
||||
)
|
||||
->handle($server, $request->input('name'));
|
||||
$action = $this->initiateBackupService
|
||||
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
|
||||
|
||||
// Only set the lock status if the user even has permission to delete backups,
|
||||
// otherwise ignore this status. This gets a little funky since it isn't clear
|
||||
// how best to allow a user to create a backup that is locked without also preventing
|
||||
// them from just filling up a server with backups that can never be deleted?
|
||||
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
$action->setIsLocked((bool) $request->input('is_locked'));
|
||||
}
|
||||
|
||||
$backup = $action->handle($server, $request->input('name'));
|
||||
|
||||
$model->metadata = ['backup_uuid' => $backup->uuid];
|
||||
|
||||
@ -88,6 +95,32 @@ class BackupController extends ClientApiController
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the lock status of a given backup for a server.
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||
*/
|
||||
public function toggleLock(Request $request, Server $server, Backup $backup): array
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$action = $backup->is_locked ? AuditLog::SERVER__BACKUP_UNLOCKED : AuditLog::SERVER__BACKUP_LOCKED;
|
||||
$server->audit($action, function (AuditLog $audit) use ($backup) {
|
||||
$audit->metadata = ['backup_uuid' => $backup->uuid];
|
||||
|
||||
$backup->update(['is_locked' => !$backup->is_locked]);
|
||||
});
|
||||
|
||||
$backup->refresh();
|
||||
|
||||
return $this->fractal->item($backup)
|
||||
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about a single backup.
|
||||
*
|
||||
|
@ -36,11 +36,10 @@ class ResourceUtilizationController extends ClientApiController
|
||||
*/
|
||||
public function __invoke(GetServerRequest $request, Server $server): array
|
||||
{
|
||||
$stats = $this->cache
|
||||
->tags(['resources'])
|
||||
->remember($server->uuid, Carbon::now()->addSeconds(20), function () use ($server) {
|
||||
return $this->repository->setServer($server)->getDetails();
|
||||
});
|
||||
$key = "resources:{$server->uuid}";
|
||||
$stats = $this->cache->remember($key, Carbon::now()->addSeconds(20), function () use ($server) {
|
||||
return $this->repository->setServer($server)->getDetails();
|
||||
});
|
||||
|
||||
return $this->fractal->item($stats)
|
||||
->transformWith($this->getTransformer(StatsTransformer::class))
|
||||
|
@ -15,7 +15,6 @@ use Pterodactyl\Services\Schedules\ProcessScheduleService;
|
||||
use Pterodactyl\Transformers\Api\Client\ScheduleTransformer;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest;
|
||||
@ -72,6 +71,7 @@ class ScheduleController extends ClientApiController
|
||||
'cron_hour' => $request->input('hour'),
|
||||
'cron_minute' => $request->input('minute'),
|
||||
'is_active' => (bool) $request->input('is_active'),
|
||||
'only_when_online' => (bool) $request->input('only_when_online'),
|
||||
'next_run_at' => $this->getNextRunAt($request),
|
||||
]);
|
||||
|
||||
@ -118,6 +118,7 @@ class ScheduleController extends ClientApiController
|
||||
'cron_hour' => $request->input('hour'),
|
||||
'cron_minute' => $request->input('minute'),
|
||||
'is_active' => $active,
|
||||
'only_when_online' => (bool) $request->input('only_when_online'),
|
||||
'next_run_at' => $this->getNextRunAt($request),
|
||||
];
|
||||
|
||||
|
@ -33,6 +33,7 @@ class ScheduleTaskController extends ClientApiController
|
||||
/**
|
||||
* Create a new task for a given schedule and store it in the database.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Http\HttpForbiddenException
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Service\ServiceLimitExceededException
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
@ -44,6 +45,10 @@ class ScheduleTaskController extends ClientApiController
|
||||
throw new ServiceLimitExceededException("Schedules may not have more than {$limit} tasks associated with them. Creating this task would put this schedule over the limit.");
|
||||
}
|
||||
|
||||
if ($server->backup_limit === 0 && $request->action === 'backup') {
|
||||
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
|
||||
}
|
||||
|
||||
/** @var \Pterodactyl\Models\Task|null $lastTask */
|
||||
$lastTask = $schedule->tasks()->orderByDesc('sequence_id')->first();
|
||||
|
||||
@ -54,6 +59,7 @@ class ScheduleTaskController extends ClientApiController
|
||||
'action' => $request->input('action'),
|
||||
'payload' => $request->input('payload') ?? '',
|
||||
'time_offset' => $request->input('time_offset'),
|
||||
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
|
||||
]);
|
||||
|
||||
return $this->fractal->item($task)
|
||||
@ -64,6 +70,7 @@ class ScheduleTaskController extends ClientApiController
|
||||
/**
|
||||
* Updates a given task for a server.
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Http\HttpForbiddenException
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
@ -74,10 +81,15 @@ class ScheduleTaskController extends ClientApiController
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
if ($server->backup_limit === 0 && $request->action === 'backup') {
|
||||
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
|
||||
}
|
||||
|
||||
$this->repository->update($task->id, [
|
||||
'action' => $request->input('action'),
|
||||
'payload' => $request->input('payload') ?? '',
|
||||
'time_offset' => $request->input('time_offset'),
|
||||
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
|
||||
]);
|
||||
|
||||
return $this->fractal->item($task->refresh())
|
||||
|
@ -90,7 +90,7 @@ class ServerDetailsController extends Controller
|
||||
/** @var \Pterodactyl\Models\Server[] $servers */
|
||||
$servers = Server::query()
|
||||
->select('servers.*')
|
||||
->selectRaw('started.metadata->>"$.backup_uuid" as backup_uuid')
|
||||
->selectRaw('JSON_UNQUOTE(JSON_EXTRACT(started.metadata, "$.backup_uuid")) as backup_uuid')
|
||||
->leftJoinSub(function (Builder $builder) {
|
||||
$builder->select('*')->from('audit_logs')
|
||||
->where('action', AuditLog::SERVER__BACKUP_RESTORE_STARTED)
|
||||
|
@ -19,6 +19,7 @@ class StoreBackupRequest extends ClientApiRequest
|
||||
{
|
||||
return [
|
||||
'name' => 'nullable|string|max:191',
|
||||
'is_locked' => 'nullable|boolean',
|
||||
'ignored' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ use Pterodactyl\Jobs\Job;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Pterodactyl\Models\Task;
|
||||
use InvalidArgumentException;
|
||||
use Pterodactyl\Models\Schedule;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -15,6 +14,7 @@ use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
|
||||
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
|
||||
class RunTaskJob extends Job implements ShouldQueue
|
||||
{
|
||||
@ -27,13 +27,19 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
*/
|
||||
public $task;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
public $manualRun;
|
||||
|
||||
/**
|
||||
* RunTaskJob constructor.
|
||||
*/
|
||||
public function __construct(Task $task)
|
||||
public function __construct(Task $task, $manualRun = false)
|
||||
{
|
||||
$this->queue = config('pterodactyl.queues.standard');
|
||||
$this->task = $task;
|
||||
$this->manualRun = $manualRun;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,8 +52,8 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
InitiateBackupService $backupService,
|
||||
DaemonPowerRepository $powerRepository
|
||||
) {
|
||||
// Do not process a task that is not set to active.
|
||||
if (!$this->task->schedule->is_active) {
|
||||
// Do not process a task that is not set to active, unless it's been manually triggered.
|
||||
if (!$this->task->schedule->is_active && !$this->manualRun) {
|
||||
$this->markTaskNotQueued();
|
||||
$this->markScheduleComplete();
|
||||
|
||||
@ -56,18 +62,26 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
|
||||
$server = $this->task->server;
|
||||
// Perform the provided task against the daemon.
|
||||
switch ($this->task->action) {
|
||||
case 'power':
|
||||
$powerRepository->setServer($server)->send($this->task->payload);
|
||||
break;
|
||||
case 'command':
|
||||
$commandRepository->setServer($server)->send($this->task->payload);
|
||||
break;
|
||||
case 'backup':
|
||||
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidArgumentException('Cannot run a task that points to a non-existent action.');
|
||||
try {
|
||||
switch ($this->task->action) {
|
||||
case Task::ACTION_POWER:
|
||||
$powerRepository->setServer($server)->send($this->task->payload);
|
||||
break;
|
||||
case Task::ACTION_COMMAND:
|
||||
$commandRepository->setServer($server)->send($this->task->payload);
|
||||
break;
|
||||
case Task::ACTION_BACKUP:
|
||||
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidArgumentException('Invalid task action provided: ' . $this->task->action);
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
// If this isn't a DaemonConnectionException on a task that allows for failures
|
||||
// throw the exception back up the chain so that the task is stopped.
|
||||
if (!($this->task->continue_on_failure && $exception instanceof DaemonConnectionException)) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
$this->markTaskNotQueued();
|
||||
@ -101,7 +115,7 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
|
||||
$nextTask->update(['is_queued' => true]);
|
||||
|
||||
$this->dispatch((new self($nextTask))->delay($nextTask->time_offset));
|
||||
$this->dispatch((new self($nextTask, $this->manualRun))->delay($nextTask->time_offset));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7,17 +7,17 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Container\Container;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property bool $is_system
|
||||
* @property int|null $user_id
|
||||
* @property int|null $server_id
|
||||
* @property string $action
|
||||
* @property string|null $subaction
|
||||
* @property array $device
|
||||
* @property array $metadata
|
||||
* @property \Carbon\CarbonImmutable $created_at
|
||||
* @property \Pterodactyl\Models\User|null $user
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property bool $is_system
|
||||
* @property int|null $user_id
|
||||
* @property int|null $server_id
|
||||
* @property string $action
|
||||
* @property string|null $subaction
|
||||
* @property array $device
|
||||
* @property array $metadata
|
||||
* @property \Carbon\CarbonImmutable $created_at
|
||||
* @property \Pterodactyl\Models\User|null $user
|
||||
* @property \Pterodactyl\Models\Server|null $server
|
||||
*/
|
||||
class AuditLog extends Model
|
||||
@ -36,6 +36,8 @@ class AuditLog extends Model
|
||||
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
|
||||
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
|
||||
public const SERVER__BACKUP_DOWNLOADED = 'server:backup.downloaded';
|
||||
public const SERVER__BACKUP_LOCKED = 'server:backup.locked';
|
||||
public const SERVER__BACKUP_UNLOCKED = 'server:backup.unlocked';
|
||||
public const SERVER__BACKUP_RESTORE_STARTED = 'server:backup.restore.started';
|
||||
public const SERVER__BACKUP_RESTORE_COMPLETED = 'server:backup.restore.completed';
|
||||
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';
|
||||
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int $server_id
|
||||
* @property string $uuid
|
||||
* @property bool $is_successful
|
||||
* @property bool $is_locked
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property string $disk
|
||||
@ -46,6 +47,7 @@ class Backup extends Model
|
||||
protected $casts = [
|
||||
'id' => 'int',
|
||||
'is_successful' => 'bool',
|
||||
'is_locked' => 'bool',
|
||||
'ignored_files' => 'array',
|
||||
'bytes' => 'int',
|
||||
];
|
||||
@ -62,6 +64,7 @@ class Backup extends Model
|
||||
*/
|
||||
protected $attributes = [
|
||||
'is_successful' => true,
|
||||
'is_locked' => false,
|
||||
'checksum' => null,
|
||||
'bytes' => 0,
|
||||
'upload_id' => null,
|
||||
@ -79,6 +82,7 @@ class Backup extends Model
|
||||
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||
'uuid' => 'required|uuid',
|
||||
'is_successful' => 'boolean',
|
||||
'is_locked' => 'boolean',
|
||||
'name' => 'required|string',
|
||||
'ignored_files' => 'array',
|
||||
'disk' => 'required|string',
|
||||
|
@ -18,6 +18,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||
* @property string $cron_minute
|
||||
* @property bool $is_active
|
||||
* @property bool $is_processing
|
||||
* @property bool $only_when_online
|
||||
* @property \Carbon\Carbon|null $last_run_at
|
||||
* @property \Carbon\Carbon|null $next_run_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
@ -63,6 +64,7 @@ class Schedule extends Model
|
||||
'cron_minute',
|
||||
'is_active',
|
||||
'is_processing',
|
||||
'only_when_online',
|
||||
'last_run_at',
|
||||
'next_run_at',
|
||||
];
|
||||
@ -75,6 +77,7 @@ class Schedule extends Model
|
||||
'server_id' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'is_processing' => 'boolean',
|
||||
'only_when_online' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -99,6 +102,7 @@ class Schedule extends Model
|
||||
'cron_minute' => '*',
|
||||
'is_active' => true,
|
||||
'is_processing' => false,
|
||||
'only_when_online' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -114,6 +118,7 @@ class Schedule extends Model
|
||||
'cron_minute' => 'required|string',
|
||||
'is_active' => 'boolean',
|
||||
'is_processing' => 'boolean',
|
||||
'only_when_online' => 'boolean',
|
||||
'last_run_at' => 'nullable|date',
|
||||
'next_run_at' => 'nullable|date',
|
||||
];
|
||||
@ -122,6 +127,7 @@ class Schedule extends Model
|
||||
* Returns the schedule's execution crontab entry as a string.
|
||||
*
|
||||
* @return \Carbon\CarbonImmutable
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getNextRunDate()
|
||||
|
@ -14,6 +14,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||
* @property string $payload
|
||||
* @property int $time_offset
|
||||
* @property bool $is_queued
|
||||
* @property bool $continue_on_failure
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property string $hashid
|
||||
@ -30,6 +31,13 @@ class Task extends Model
|
||||
*/
|
||||
public const RESOURCE_NAME = 'schedule_task';
|
||||
|
||||
/**
|
||||
* The default actions that can exist for a task in Pterodactyl.
|
||||
*/
|
||||
public const ACTION_POWER = 'power';
|
||||
public const ACTION_COMMAND = 'command';
|
||||
public const ACTION_BACKUP = 'backup';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
@ -56,6 +64,7 @@ class Task extends Model
|
||||
'payload',
|
||||
'time_offset',
|
||||
'is_queued',
|
||||
'continue_on_failure',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -69,6 +78,7 @@ class Task extends Model
|
||||
'sequence_id' => 'integer',
|
||||
'time_offset' => 'integer',
|
||||
'is_queued' => 'boolean',
|
||||
'continue_on_failure' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -79,6 +89,7 @@ class Task extends Model
|
||||
protected $attributes = [
|
||||
'time_offset' => 0,
|
||||
'is_queued' => false,
|
||||
'continue_on_failure' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -91,6 +102,7 @@ class Task extends Model
|
||||
'payload' => 'required_unless:action,backup|string',
|
||||
'time_offset' => 'required|numeric|between:0,900',
|
||||
'is_queued' => 'boolean',
|
||||
'continue_on_failure' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -9,6 +9,7 @@ use Illuminate\Database\ConnectionInterface;
|
||||
use Pterodactyl\Extensions\Backups\BackupManager;
|
||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
||||
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
|
||||
class DeleteBackupService
|
||||
@ -55,6 +56,10 @@ class DeleteBackupService
|
||||
*/
|
||||
public function handle(Backup $backup)
|
||||
{
|
||||
if ($backup->is_locked) {
|
||||
throw new BackupLockedException();
|
||||
}
|
||||
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
$this->deleteFromS3($backup);
|
||||
|
||||
|
@ -21,6 +21,11 @@ class InitiateBackupService
|
||||
*/
|
||||
private $ignoredFiles;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $isLocked = false;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
|
||||
*/
|
||||
@ -49,7 +54,11 @@ class InitiateBackupService
|
||||
/**
|
||||
* InitiateBackupService constructor.
|
||||
*
|
||||
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
|
||||
* @param \Illuminate\Database\ConnectionInterface $connection
|
||||
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
|
||||
* @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService
|
||||
* @param \Pterodactyl\Extensions\Backups\BackupManager $backupManager
|
||||
*/
|
||||
public function __construct(
|
||||
BackupRepository $repository,
|
||||
@ -65,6 +74,19 @@ class InitiateBackupService
|
||||
$this->deleteBackupService = $deleteBackupService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if the backup should be locked once it is created which will prevent
|
||||
* its deletion by users or automated system processes.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setIsLocked(bool $isLocked): self
|
||||
{
|
||||
$this->isLocked = $isLocked;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the files to be ignored by this backup.
|
||||
*
|
||||
@ -91,7 +113,7 @@ class InitiateBackupService
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the backup process for a server on the daemon.
|
||||
* Initiates the backup process for a server on Wings.
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws \Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException
|
||||
@ -104,23 +126,30 @@ class InitiateBackupService
|
||||
if ($period > 0) {
|
||||
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
|
||||
if ($previous->count() >= $limit) {
|
||||
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period));
|
||||
$message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period);
|
||||
|
||||
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the server has reached or exceeded it's backup limit
|
||||
if (!$server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) {
|
||||
// Check if the server has reached or exceeded it's backup limit.
|
||||
$successful = $server->backups()->where('is_successful', true);
|
||||
if (!$server->backup_limit || $successful->count() >= $server->backup_limit) {
|
||||
// Do not allow the user to continue if this server is already at its limit and can't override.
|
||||
if (!$override || $server->backup_limit <= 0) {
|
||||
throw new TooManyBackupsException($server->backup_limit);
|
||||
}
|
||||
|
||||
// Get the oldest backup the server has.
|
||||
/** @var \Pterodactyl\Models\Backup $oldestBackup */
|
||||
$oldestBackup = $server->backups()->where('is_successful', true)->orderBy('created_at')->first();
|
||||
// Get the oldest backup the server has that is not "locked" (indicating a backup that should
|
||||
// never be automatically purged). If we find a backup we will delete it and then continue with
|
||||
// this process. If no backup is found that can be used an exception is thrown.
|
||||
/** @var \Pterodactyl\Models\Backup $oldest */
|
||||
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
|
||||
if (!$oldest) {
|
||||
throw new TooManyBackupsException($server->backup_limit);
|
||||
}
|
||||
|
||||
// Delete the oldest backup.
|
||||
$this->deleteBackupService->handle($oldestBackup);
|
||||
$this->deleteBackupService->handle($oldest);
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($server, $name) {
|
||||
@ -131,6 +160,7 @@ class InitiateBackupService
|
||||
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
|
||||
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
||||
'disk' => $this->backupManager->getDefaultAdapter(),
|
||||
'is_locked' => $this->isLocked,
|
||||
], true, true);
|
||||
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
|
@ -83,7 +83,7 @@ class FindViableNodesService
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException
|
||||
*/
|
||||
public function handle(int $page = null)
|
||||
public function handle(int $perPage = null, int $page = null)
|
||||
{
|
||||
Assert::integer($this->disk, 'Disk space must be an int, got %s');
|
||||
Assert::integer($this->memory, 'Memory usage must be an int, got %s');
|
||||
@ -103,7 +103,7 @@ class FindViableNodesService
|
||||
->havingRaw('(IFNULL(SUM(servers.disk), 0) + ?) <= (nodes.disk * (1 + (nodes.disk_overallocate / 100)))', [$this->disk]);
|
||||
|
||||
if (!is_null($page)) {
|
||||
$results = $results->paginate(50, ['*'], 'page', $page);
|
||||
$results = $results->paginate($perPage ?? 50, ['*'], 'page', $page);
|
||||
} else {
|
||||
$results = $results->get()->toBase();
|
||||
}
|
||||
|
@ -8,6 +8,8 @@ use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Pterodactyl\Jobs\Schedule\RunTaskJob;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
|
||||
class ProcessScheduleService
|
||||
{
|
||||
@ -21,13 +23,19 @@ class ProcessScheduleService
|
||||
*/
|
||||
private $connection;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Repositories\Wings\DaemonServerRepository
|
||||
*/
|
||||
private $serverRepository;
|
||||
|
||||
/**
|
||||
* ProcessScheduleService constructor.
|
||||
*/
|
||||
public function __construct(ConnectionInterface $connection, Dispatcher $dispatcher)
|
||||
public function __construct(ConnectionInterface $connection, DaemonServerRepository $serverRepository, Dispatcher $dispatcher)
|
||||
{
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->connection = $connection;
|
||||
$this->serverRepository = $serverRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,7 +46,7 @@ class ProcessScheduleService
|
||||
public function handle(Schedule $schedule, bool $now = false)
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Task $task */
|
||||
$task = $schedule->tasks()->orderBy('sequence_id', 'asc')->first();
|
||||
$task = $schedule->tasks()->orderBy('sequence_id')->first();
|
||||
|
||||
if (is_null($task)) {
|
||||
throw new DisplayException('Cannot process schedule for task execution: no tasks are registered.');
|
||||
@ -53,7 +61,31 @@ class ProcessScheduleService
|
||||
$task->update(['is_queued' => true]);
|
||||
});
|
||||
|
||||
$job = new RunTaskJob($task);
|
||||
$job = new RunTaskJob($task, $now);
|
||||
if ($schedule->only_when_online) {
|
||||
// Check that the server is currently in a starting or running state before executing
|
||||
// this schedule if this option has been set.
|
||||
try {
|
||||
$details = $this->serverRepository->setServer($schedule->server)->getDetails();
|
||||
$state = $details['state'] ?? 'offline';
|
||||
// If the server is stopping or offline just do nothing with this task.
|
||||
if (in_array($state, ['offline', 'stopping'])) {
|
||||
$job->failed();
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
if (!$exception instanceof DaemonConnectionException) {
|
||||
// If we encountered some exception during this process that wasn't just an
|
||||
// issue connecting to Wings run the failed sequence for a job. Otherwise we
|
||||
// can just quietly mark the task as completed without actually running anything.
|
||||
$job->failed($exception);
|
||||
}
|
||||
$job->failed();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$now) {
|
||||
$this->dispatcher->dispatch($job->delay($task->time_offset));
|
||||
|
@ -1,11 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
* Pterodactyl - Panel
|
||||
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
|
||||
*
|
||||
* This software is licensed under the terms of the MIT license.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
namespace Pterodactyl\Traits\Commands;
|
||||
|
||||
@ -13,6 +6,20 @@ use Pterodactyl\Exceptions\PterodactylException;
|
||||
|
||||
trait EnvironmentWriterTrait
|
||||
{
|
||||
/**
|
||||
* Escapes an environment value by looking for any characters that could
|
||||
* reasonablly cause environment parsing issues. Those values are then wrapped
|
||||
* in quotes before being returned.
|
||||
*/
|
||||
public function escapeEnvironmentValue(string $value): string
|
||||
{
|
||||
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
|
||||
return sprintf('"%s"', addslashes($value));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the .env file for the application using the passed in values.
|
||||
*
|
||||
@ -28,14 +35,7 @@ trait EnvironmentWriterTrait
|
||||
$saveContents = file_get_contents($path);
|
||||
collect($values)->each(function ($value, $key) use (&$saveContents) {
|
||||
$key = strtoupper($key);
|
||||
// If the key value is not sorrounded by quotation marks, and contains anything that could reasonably
|
||||
// cause environment parsing issues, wrap it in quotes before writing it. This also adds slashes to the
|
||||
// value to ensure quotes within it don't cause us issues.
|
||||
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
|
||||
$value = sprintf('"%s"', addslashes($value));
|
||||
}
|
||||
|
||||
$saveValue = sprintf('%s=%s', $key, $value);
|
||||
$saveValue = sprintf('%s=%s', $key, $this->escapeEnvironmentValue($value));
|
||||
|
||||
if (preg_match_all('/^' . $key . '=(.*)$/m', $saveContents) < 1) {
|
||||
$saveContents = $saveContents . PHP_EOL . $saveValue;
|
||||
|
@ -19,6 +19,7 @@ class BackupTransformer extends BaseClientTransformer
|
||||
return [
|
||||
'uuid' => $backup->uuid,
|
||||
'is_successful' => $backup->is_successful,
|
||||
'is_locked' => $backup->is_locked,
|
||||
'name' => $backup->name,
|
||||
'ignored_files' => $backup->ignored_files,
|
||||
'checksum' => $backup->checksum,
|
||||
|
@ -45,6 +45,7 @@ class ScheduleTransformer extends BaseClientTransformer
|
||||
],
|
||||
'is_active' => $model->is_active,
|
||||
'is_processing' => $model->is_processing,
|
||||
'only_when_online' => $model->only_when_online,
|
||||
'last_run_at' => $model->last_run_at ? $model->last_run_at->toIso8601String() : null,
|
||||
'next_run_at' => $model->next_run_at ? $model->next_run_at->toIso8601String() : null,
|
||||
'created_at' => $model->created_at->toIso8601String(),
|
||||
|
@ -21,13 +21,13 @@ class StatsTransformer extends BaseClientTransformer
|
||||
{
|
||||
return [
|
||||
'current_state' => Arr::get($data, 'state', 'stopped'),
|
||||
'is_suspended' => Arr::get($data, 'suspended', false),
|
||||
'is_suspended' => Arr::get($data, 'is_suspended', false),
|
||||
'resources' => [
|
||||
'memory_bytes' => Arr::get($data, 'memory_bytes', 0),
|
||||
'cpu_absolute' => Arr::get($data, 'cpu_absolute', 0),
|
||||
'disk_bytes' => Arr::get($data, 'disk_bytes', 0),
|
||||
'network_rx_bytes' => Arr::get($data, 'network.rx_bytes', 0),
|
||||
'network_tx_bytes' => Arr::get($data, 'network.tx_bytes', 0),
|
||||
'memory_bytes' => Arr::get($data, 'utilization.memory_bytes', 0),
|
||||
'cpu_absolute' => Arr::get($data, 'utilization.cpu_absolute', 0),
|
||||
'disk_bytes' => Arr::get($data, 'utilization.disk_bytes', 0),
|
||||
'network_rx_bytes' => Arr::get($data, 'utilization.network.rx_bytes', 0),
|
||||
'network_tx_bytes' => Arr::get($data, 'utilization.network.tx_bytes', 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ class TaskTransformer extends BaseClientTransformer
|
||||
'payload' => $model->payload,
|
||||
'time_offset' => $model->time_offset,
|
||||
'is_queued' => $model->is_queued,
|
||||
'continue_on_failure' => $model->continue_on_failure,
|
||||
'created_at' => $model->created_at->toIso8601String(),
|
||||
'updated_at' => $model->updated_at->toIso8601String(),
|
||||
];
|
||||
|
@ -1,5 +0,0 @@
|
||||
coverage:
|
||||
status:
|
||||
project: off
|
||||
patch: off
|
||||
comment: false
|
@ -4,7 +4,7 @@
|
||||
"version": "PTDL_v1",
|
||||
"update_url": null
|
||||
},
|
||||
"exported_at": "2021-03-15T18:04:38+02:00",
|
||||
"exported_at": "2021-04-21T23:01:35+03:00",
|
||||
"name": "Forge Minecraft",
|
||||
"author": "support@pterodactyl.io",
|
||||
"description": "Minecraft Forge Server. Minecraft Forge is a modding API (Application Programming Interface), which makes it easier to create mods, and also make sure mods are compatible with each other.",
|
||||
@ -25,7 +25,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"installation": {
|
||||
"script": "#!\/bin\/bash\r\n# Forge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y curl jq\r\n\r\n#Go into main direction\r\nif [ ! -d \/mnt\/server ]; then\r\n mkdir \/mnt\/server\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\nif [ ! -z ${FORGE_VERSION} ]; then\r\n DOWNLOAD_LINK=https:\/\/files.minecraftforge.net\/maven\/net\/minecraftforge\/forge\/${FORGE_VERSION}\/forge-${FORGE_VERSION}\r\n FORGE_JAR=forge-${FORGE_VERSION}*.jar\r\nelse\r\n JSON_DATA=$(curl -sSL https:\/\/files.minecraftforge.net\/maven\/net\/minecraftforge\/forge\/promotions_slim.json)\r\n\r\n if [ \"${MC_VERSION}\" == \"latest\" ] || [ \"${MC_VERSION}\" == \"\" ] ; then\r\n echo -e \"getting latest recommended version of forge.\"\r\n MC_VERSION=$(echo -e ${JSON_DATA} | jq -r '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains(\"recommended\")) | split(\"-\")[0]' | sort -t. -k 1,1n -k 2,2n -k 3,3n -k 4,4n | tail -1)\r\n \tBUILD_TYPE=recommended\r\n fi\r\n\r\n if [ \"${BUILD_TYPE}\" != \"recommended\" ] && [ \"${BUILD_TYPE}\" != \"latest\" ]; then\r\n BUILD_TYPE=recommended\r\n fi\r\n\r\n echo -e \"minecraft version: ${MC_VERSION}\"\r\n echo -e \"build type: ${BUILD_TYPE}\"\r\n\r\n ## some variables for getting versions and things\r\n FILE_SITE=$(echo -e ${JSON_DATA} | jq -r '.homepage' | sed \"s\/http:\/https:\/g\")\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" --arg BUILD_TYPE \"${BUILD_TYPE}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains($BUILD_TYPE))')\r\n\r\n ## locating the forge version\r\n if [ \"${VERSION_KEY}\" == \"\" ] && [ \"${BUILD_TYPE}\" == \"recommended\" ]; then\r\n echo -e \"dropping back to latest from recommended due to there not being a recommended version of forge for the mc version requested.\"\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains(\"recommended\"))')\r\n fi\r\n\r\n ## Error if the mc version set wasn't valid.\r\n if [ \"${VERSION_KEY}\" == \"\" ] || [ \"${VERSION_KEY}\" == \"null\" ]; then\r\n \techo -e \"The install failed because there is no valid version of forge for the version on minecraft selected.\"\r\n \texit 1\r\n fi\r\n\r\n FORGE_VERSION=$(echo -e ${JSON_DATA} | jq -r --arg VERSION_KEY \"$VERSION_KEY\" '.promos | .[$VERSION_KEY]')\r\n\r\n if [ \"${MC_VERSION}\" == \"1.7.10\" ] || [ \"${MC_VERSION}\" == \"1.8.9\" ]; then\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}.jar\r\n if [ \"${MC_VERSION}\" == \"1.7.10\" ]; then\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}-universal.jar\r\n fi\r\n else\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}.jar\r\n fi\r\nfi\r\n\r\n\r\n#Adding .jar when not eding by SERVER_JARFILE\r\nif [[ ! $SERVER_JARFILE = *\\.jar ]]; then\r\n SERVER_JARFILE=\"$SERVER_JARFILE.jar\"\r\nfi\r\n\r\n#Downloading jars\r\necho -e \"Downloading forge version ${FORGE_VERSION}\"\r\necho -e \"Download link is ${DOWNLOAD_LINK}\"\r\nif [ ! -z \"${DOWNLOAD_LINK}\" ]; then \r\n if curl --output \/dev\/null --silent --head --fail ${DOWNLOAD_LINK}-installer.jar; then\r\n echo -e \"installer jar download link is valid.\"\r\n else\r\n echo -e \"link is invalid closing out\"\r\n exit 2\r\n fi\r\nelse\r\n echo -e \"no download link closing out\"\r\n exit 3\r\nfi\r\n\r\ncurl -s -o installer.jar -sS ${DOWNLOAD_LINK}-installer.jar\r\n\r\n#Checking if downloaded jars exist\r\nif [ ! -f .\/installer.jar ]; then\r\n echo \"!!! Error by downloading forge version ${FORGE_VERSION} !!!\"\r\n exit\r\nfi\r\n\r\n#Installing server\r\necho -e \"Installing forge server.\\n\"\r\njava -jar installer.jar --installServer || { echo -e \"install failed\"; exit 4; }\r\n\r\nmv $FORGE_JAR $SERVER_JARFILE\r\n\r\n#Deleting installer.jar\r\necho -e \"Deleting installer.jar file.\\n\"\r\nrm -rf installer.jar",
|
||||
"script": "#!\/bin\/bash\r\n# Forge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y curl jq\r\n\r\n#Go into main direction\r\nif [ ! -d \/mnt\/server ]; then\r\n mkdir \/mnt\/server\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\nif [ ! -z ${FORGE_VERSION} ]; then\r\n DOWNLOAD_LINK=https:\/\/maven.minecraftforge.net\/net\/minecraftforge\/forge\/${FORGE_VERSION}\/forge-${FORGE_VERSION}\r\n FORGE_JAR=forge-${FORGE_VERSION}*.jar\r\nelse\r\n JSON_DATA=$(curl -sSL https:\/\/files.minecraftforge.net\/maven\/net\/minecraftforge\/forge\/promotions_slim.json)\r\n\r\n if [ \"${MC_VERSION}\" == \"latest\" ] || [ \"${MC_VERSION}\" == \"\" ] ; then\r\n echo -e \"getting latest recommended version of forge.\"\r\n MC_VERSION=$(echo -e ${JSON_DATA} | jq -r '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains(\"recommended\")) | split(\"-\")[0]' | sort -t. -k 1,1n -k 2,2n -k 3,3n -k 4,4n | tail -1)\r\n \tBUILD_TYPE=recommended\r\n fi\r\n\r\n if [ \"${BUILD_TYPE}\" != \"recommended\" ] && [ \"${BUILD_TYPE}\" != \"latest\" ]; then\r\n BUILD_TYPE=recommended\r\n fi\r\n\r\n echo -e \"minecraft version: ${MC_VERSION}\"\r\n echo -e \"build type: ${BUILD_TYPE}\"\r\n\r\n ## some variables for getting versions and things\r\n\tFILE_SITE=https:\/\/maven.minecraftforge.net\/net\/minecraftforge\/forge\/\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" --arg BUILD_TYPE \"${BUILD_TYPE}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains($BUILD_TYPE))')\r\n\r\n ## locating the forge version\r\n if [ \"${VERSION_KEY}\" == \"\" ] && [ \"${BUILD_TYPE}\" == \"recommended\" ]; then\r\n echo -e \"dropping back to latest from recommended due to there not being a recommended version of forge for the mc version requested.\"\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains(\"recommended\"))')\r\n fi\r\n\r\n ## Error if the mc version set wasn't valid.\r\n if [ \"${VERSION_KEY}\" == \"\" ] || [ \"${VERSION_KEY}\" == \"null\" ]; then\r\n \techo -e \"The install failed because there is no valid version of forge for the version on minecraft selected.\"\r\n \texit 1\r\n fi\r\n\r\n FORGE_VERSION=$(echo -e ${JSON_DATA} | jq -r --arg VERSION_KEY \"$VERSION_KEY\" '.promos | .[$VERSION_KEY]')\r\n\r\n if [ \"${MC_VERSION}\" == \"1.7.10\" ] || [ \"${MC_VERSION}\" == \"1.8.9\" ]; then\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}.jar\r\n if [ \"${MC_VERSION}\" == \"1.7.10\" ]; then\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}-universal.jar\r\n fi\r\n else\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}.jar\r\n fi\r\nfi\r\n\r\n\r\n#Adding .jar when not eding by SERVER_JARFILE\r\nif [[ ! $SERVER_JARFILE = *\\.jar ]]; then\r\n SERVER_JARFILE=\"$SERVER_JARFILE.jar\"\r\nfi\r\n\r\n#Downloading jars\r\necho -e \"Downloading forge version ${FORGE_VERSION}\"\r\necho -e \"Download link is ${DOWNLOAD_LINK}\"\r\nif [ ! -z \"${DOWNLOAD_LINK}\" ]; then \r\n if curl --output \/dev\/null --silent --head --fail ${DOWNLOAD_LINK}-installer.jar; then\r\n echo -e \"installer jar download link is valid.\"\r\n else\r\n echo -e \"link is invalid closing out\"\r\n exit 2\r\n fi\r\nelse\r\n echo -e \"no download link closing out\"\r\n exit 3\r\nfi\r\n\r\ncurl -s -o installer.jar -sS ${DOWNLOAD_LINK}-installer.jar\r\n\r\n#Checking if downloaded jars exist\r\nif [ ! -f .\/installer.jar ]; then\r\n echo \"!!! Error by downloading forge version ${FORGE_VERSION} !!!\"\r\n exit\r\nfi\r\n\r\n#Installing server\r\necho -e \"Installing forge server.\\n\"\r\njava -jar installer.jar --installServer || { echo -e \"install failed\"; exit 4; }\r\n\r\nmv $FORGE_JAR $SERVER_JARFILE\r\n\r\n#Deleting installer.jar\r\necho -e \"Deleting installer.jar file.\\n\"\r\nrm -rf installer.jar",
|
||||
"container": "openjdk:8-jdk-slim",
|
||||
"entrypoint": "bash"
|
||||
}
|
||||
|
@ -31,7 +31,7 @@
|
||||
"default_value": "A Rust Server",
|
||||
"user_viewable": true,
|
||||
"user_editable": true,
|
||||
"rules": "required|string|max:40"
|
||||
"rules": "required|string|max:60"
|
||||
},
|
||||
{
|
||||
"name": "OxideMod",
|
||||
|
@ -47,7 +47,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Server Map",
|
||||
"description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis",
|
||||
"description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis, CrystalIsles",
|
||||
"env_variable": "SERVER_MAP",
|
||||
"default_value": "TheIsland",
|
||||
"user_viewable": true,
|
||||
|
@ -1,13 +1,18 @@
|
||||
{
|
||||
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
|
||||
"meta": {
|
||||
"version": "PTDL_v1"
|
||||
"version": "PTDL_v1",
|
||||
"update_url": null
|
||||
},
|
||||
"exported_at": "2020-10-25T22:34:06+00:00",
|
||||
"exported_at": "2021-05-06T23:35:46+03:00",
|
||||
"name": "Mumble Server",
|
||||
"author": "support@pterodactyl.io",
|
||||
"description": "Mumble is an open source, low-latency, high quality voice chat software primarily intended for use while gaming.",
|
||||
"image": "quay.io\/pterodactyl\/core:glibc",
|
||||
"features": null,
|
||||
"images": [
|
||||
"quay.io\/pterodactyl\/core:glibc"
|
||||
],
|
||||
"file_denylist": [],
|
||||
"startup": ".\/murmur.x86 -fg",
|
||||
"config": {
|
||||
"files": "{\"murmur.ini\":{\"parser\": \"ini\", \"find\":{\"logfile\": \"murmur.log\", \"port\": \"{{server.build.default.port}}\", \"host\": \"0.0.0.0\", \"users\": \"{{server.build.env.MAX_USERS}}\"}}}",
|
||||
@ -39,7 +44,7 @@
|
||||
"default_value": "latest",
|
||||
"user_viewable": true,
|
||||
"user_editable": true,
|
||||
"rules": "required|regex:\/^([0-9_\\.-]{5,8})$\/"
|
||||
"rules": "required|string"
|
||||
}
|
||||
]
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
"version": "PTDL_v1",
|
||||
"update_url": null
|
||||
},
|
||||
"exported_at": "2021-01-12T15:44:41+01:00",
|
||||
"exported_at": "2021-05-06T23:38:28+03:00",
|
||||
"name": "Teamspeak3 Server",
|
||||
"author": "support@pterodactyl.io",
|
||||
"description": "VoIP software designed with security in mind, featuring crystal clear voice quality, endless customization options, and scalabilty up to thousands of simultaneous users.",
|
||||
@ -12,6 +12,7 @@
|
||||
"images": [
|
||||
"quay.io\/parkervcp\/pterodactyl-images:base_debian"
|
||||
],
|
||||
"file_denylist": [],
|
||||
"startup": ".\/ts3server default_voice_port={{SERVER_PORT}} query_port={{QUERY_PORT}} filetransfer_ip=0.0.0.0 filetransfer_port={{FILE_TRANSFER}} license_accepted=1",
|
||||
"config": {
|
||||
"files": "{}",
|
||||
@ -21,7 +22,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"installation": {
|
||||
"script": "#!\/bin\/bash\r\n# TS3 Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y tar curl jq bzip2\r\n\r\nif [ -z ${TS_VERSION} ] || [ ${TS_VERSION} == latest ]; then\r\n TS_VERSION=$(curl -sSL https:\/\/teamspeak.com\/versions\/server.json | jq -r '.linux.x86_64.version')\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\necho -e \"getting files from http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2\" \r\ncurl -L http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2 | tar -xvj --strip-components=1\r\n\r\nrm teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2",
|
||||
"script": "#!\/bin\/bash\r\n# TS3 Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y tar curl jq bzip2\r\n\r\nif [ -z ${TS_VERSION} ] || [ ${TS_VERSION} == latest ]; then\r\n TS_VERSION=$(curl -sSL https:\/\/teamspeak.com\/versions\/server.json | jq -r '.linux.x86_64.version')\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\necho -e \"getting files from http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2\" \r\ncurl -L http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2 | tar -xvj --strip-components=1",
|
||||
"container": "debian:buster-slim",
|
||||
"entrypoint": "bash"
|
||||
}
|
||||
|
@ -33,19 +33,17 @@ class StoreNodeTokensAsEncryptedValue extends Migration
|
||||
$table->text('daemon_token')->change();
|
||||
});
|
||||
|
||||
DB::transaction(function () {
|
||||
/** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */
|
||||
$encrypter = Container::getInstance()->make(Encrypter::class);
|
||||
/** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */
|
||||
$encrypter = Container::getInstance()->make(Encrypter::class);
|
||||
|
||||
foreach (DB::select('SELECT id, daemon_token FROM nodes') as $datum) {
|
||||
DB::update('UPDATE nodes SET uuid = ?, daemon_token_id = ?, daemon_token = ? WHERE id = ?', [
|
||||
Uuid::uuid4()->toString(),
|
||||
substr($datum->daemon_token, 0, 16),
|
||||
$encrypter->encrypt(substr($datum->daemon_token, 16)),
|
||||
$datum->id,
|
||||
]);
|
||||
}
|
||||
});
|
||||
foreach (DB::select('SELECT id, daemon_token FROM nodes') as $datum) {
|
||||
DB::update('UPDATE nodes SET uuid = ?, daemon_token_id = ?, daemon_token = ? WHERE id = ?', [
|
||||
Uuid::uuid4()->toString(),
|
||||
substr($datum->daemon_token, 0, 16),
|
||||
$encrypter->encrypt(substr($datum->daemon_token, 16)),
|
||||
$datum->id,
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table('nodes', function (Blueprint $table) {
|
||||
$table->unique(['uuid']);
|
||||
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddContinueOnFailureOptionToTasks extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('tasks', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('continue_on_failure')->after('is_queued')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('tasks', function (Blueprint $table) {
|
||||
$table->dropColumn('continue_on_failure');
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddOnlyRunWhenServerOnlineOptionToSchedules extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('schedules', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('only_when_online')->after('is_processing')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('schedules', function (Blueprint $table) {
|
||||
$table->dropColumn('only_when_online');
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddSupportForLockingABackup extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('is_locked')->after('is_successful')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->dropColumn('is_locked');
|
||||
});
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@
|
||||
"i18next-chained-backend": "^2.0.0",
|
||||
"i18next-localstorage-backend": "^3.0.0",
|
||||
"i18next-xhr-backend": "^3.2.2",
|
||||
"qrcode.react": "^1.0.1",
|
||||
"query-string": "^6.7.0",
|
||||
"react": "^16.13.1",
|
||||
"react-copy-to-clipboard": "^5.0.3",
|
||||
@ -62,6 +63,7 @@
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/node": "^14.11.10",
|
||||
"@types/qrcode.react": "^1.0.1",
|
||||
"@types/query-string": "^6.3.0",
|
||||
"@types/react": "^16.9.41",
|
||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||
@ -75,8 +77,8 @@
|
||||
"@types/uuid": "^3.4.5",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@types/yup": "^0.29.3",
|
||||
"@typescript-eslint/eslint-plugin": "^4.14.2",
|
||||
"@typescript-eslint/parser": "^4.14.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.1",
|
||||
"@typescript-eslint/parser": "^4.22.1",
|
||||
"autoprefixer": "^10.1.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-styled-components": "^1.12.0",
|
||||
@ -97,7 +99,7 @@
|
||||
"svg-url-loader": "^6.0.0",
|
||||
"terser-webpack-plugin": "^3.0.6",
|
||||
"twin.macro": "^2.0.7",
|
||||
"typescript": "^4.1.3",
|
||||
"typescript": "^4.2.4",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-assets-manifest": "^3.1.1",
|
||||
"webpack-bundle-analyzer": "^3.8.0",
|
||||
|
@ -2,12 +2,18 @@ import http from '@/api/http';
|
||||
import { ServerBackup } from '@/api/server/types';
|
||||
import { rawDataToServerBackup } from '@/api/transformers';
|
||||
|
||||
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.post(`/api/client/servers/${uuid}/backups`, {
|
||||
name, ignored,
|
||||
})
|
||||
.then(({ data }) => resolve(rawDataToServerBackup(data)))
|
||||
.catch(reject);
|
||||
interface RequestParameters {
|
||||
name?: string;
|
||||
ignored?: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export default async (uuid: string, params: RequestParameters): Promise<ServerBackup> => {
|
||||
const { data } = await http.post(`/api/client/servers/${uuid}/backups`, {
|
||||
name: params.name,
|
||||
ignored: params.ignored,
|
||||
is_locked: params.isLocked,
|
||||
});
|
||||
|
||||
return rawDataToServerBackup(data);
|
||||
};
|
||||
|
@ -1,20 +1,19 @@
|
||||
import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
import http from '@/api/http';
|
||||
|
||||
type Data = Pick<Schedule, 'cron' | 'name' | 'isActive'> & { id?: number }
|
||||
type Data = Pick<Schedule, 'cron' | 'name' | 'onlyWhenOnline' | 'isActive'> & { id?: number }
|
||||
|
||||
export default (uuid: string, schedule: Data): Promise<Schedule> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
|
||||
is_active: schedule.isActive,
|
||||
name: schedule.name,
|
||||
minute: schedule.cron.minute,
|
||||
hour: schedule.cron.hour,
|
||||
day_of_month: schedule.cron.dayOfMonth,
|
||||
month: schedule.cron.month,
|
||||
day_of_week: schedule.cron.dayOfWeek,
|
||||
})
|
||||
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))
|
||||
.catch(reject);
|
||||
export default async (uuid: string, schedule: Data): Promise<Schedule> => {
|
||||
const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
|
||||
is_active: schedule.isActive,
|
||||
only_when_online: schedule.onlyWhenOnline,
|
||||
name: schedule.name,
|
||||
minute: schedule.cron.minute,
|
||||
hour: schedule.cron.hour,
|
||||
day_of_month: schedule.cron.dayOfMonth,
|
||||
month: schedule.cron.month,
|
||||
day_of_week: schedule.cron.dayOfWeek,
|
||||
});
|
||||
|
||||
return rawDataToServerSchedule(data.attributes);
|
||||
};
|
||||
|
@ -5,15 +5,16 @@ interface Data {
|
||||
action: string;
|
||||
payload: string;
|
||||
timeOffset: string | number;
|
||||
continueOnFailure: boolean;
|
||||
}
|
||||
|
||||
export default (uuid: string, schedule: number, task: number | undefined, { timeOffset, ...data }: Data): Promise<Task> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
|
||||
...data,
|
||||
time_offset: timeOffset,
|
||||
})
|
||||
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))
|
||||
.catch(reject);
|
||||
export default async (uuid: string, schedule: number, task: number | undefined, data: Data): Promise<Task> => {
|
||||
const { data: response } = await http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
|
||||
action: data.action,
|
||||
payload: data.payload,
|
||||
continue_on_failure: data.continueOnFailure,
|
||||
time_offset: data.timeOffset,
|
||||
});
|
||||
|
||||
return rawDataToServerTask(response.attributes);
|
||||
};
|
||||
|
@ -12,6 +12,7 @@ export interface Schedule {
|
||||
};
|
||||
isActive: boolean;
|
||||
isProcessing: boolean;
|
||||
onlyWhenOnline: boolean;
|
||||
lastRunAt: Date | null;
|
||||
nextRunAt: Date | null;
|
||||
createdAt: Date;
|
||||
@ -27,6 +28,7 @@ export interface Task {
|
||||
payload: string;
|
||||
timeOffset: number;
|
||||
isQueued: boolean;
|
||||
continueOnFailure: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -38,6 +40,7 @@ export const rawDataToServerTask = (data: any): Task => ({
|
||||
payload: data.payload,
|
||||
timeOffset: data.time_offset,
|
||||
isQueued: data.is_queued,
|
||||
continueOnFailure: data.continue_on_failure,
|
||||
createdAt: new Date(data.created_at),
|
||||
updatedAt: new Date(data.updated_at),
|
||||
});
|
||||
@ -54,6 +57,7 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
|
||||
},
|
||||
isActive: data.is_active,
|
||||
isProcessing: data.is_processing,
|
||||
onlyWhenOnline: data.only_when_online,
|
||||
lastRunAt: data.last_run_at ? new Date(data.last_run_at) : null,
|
||||
nextRunAt: data.next_run_at ? new Date(data.next_run_at) : null,
|
||||
createdAt: new Date(data.created_at),
|
||||
@ -62,14 +66,12 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
|
||||
tasks: (data.relationships?.tasks?.data || []).map((row: any) => rawDataToServerTask(row.attributes)),
|
||||
});
|
||||
|
||||
export default (uuid: string): Promise<Schedule[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`/api/client/servers/${uuid}/schedules`, {
|
||||
params: {
|
||||
include: [ 'tasks' ],
|
||||
},
|
||||
})
|
||||
.then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes))))
|
||||
.catch(reject);
|
||||
export default async (uuid: string): Promise<Schedule[]> => {
|
||||
const { data } = await http.get(`/api/client/servers/${uuid}/schedules`, {
|
||||
params: {
|
||||
include: [ 'tasks' ],
|
||||
},
|
||||
});
|
||||
|
||||
return (data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes));
|
||||
};
|
||||
|
1
resources/scripts/api/server/types.d.ts
vendored
1
resources/scripts/api/server/types.d.ts
vendored
@ -3,6 +3,7 @@ export type ServerStatus = 'installing' | 'install_failed' | 'suspended' | 'rest
|
||||
export interface ServerBackup {
|
||||
uuid: string;
|
||||
isSuccessful: boolean;
|
||||
isLocked: boolean;
|
||||
name: string;
|
||||
ignoredFiles: string;
|
||||
checksum: string;
|
||||
|
@ -58,6 +58,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
|
||||
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
|
||||
uuid: attributes.uuid,
|
||||
isSuccessful: attributes.is_successful,
|
||||
isLocked: attributes.is_locked,
|
||||
name: attributes.name,
|
||||
ignoredFiles: attributes.ignored_files,
|
||||
checksum: attributes.checksum,
|
||||
|
@ -47,7 +47,7 @@ export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) =>
|
||||
</div>
|
||||
</Form>
|
||||
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
|
||||
© 2015 - 2020
|
||||
© 2015 - {(new Date()).getFullYear()}
|
||||
<a
|
||||
rel={'noopener nofollow noreferrer'}
|
||||
href={'https://pterodactyl.io'}
|
||||
|
@ -3,6 +3,7 @@ import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
|
||||
interface Props {
|
||||
apiKey: string;
|
||||
@ -19,7 +20,7 @@ const ApiKeyModal = ({ apiKey }: Props) => {
|
||||
shown again.
|
||||
</p>
|
||||
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
|
||||
<code css={tw`font-mono`}>{apiKey}</code>
|
||||
<CopyOnClick text={apiKey}><code css={tw`font-mono`}>{apiKey}</code></CopyOnClick>
|
||||
</pre>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'button'} onClick={() => dismiss()}>
|
||||
|
@ -25,8 +25,8 @@ export default () => {
|
||||
const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState(`${uuid}:show_all_servers`, false);
|
||||
|
||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||
[ '/api/client/servers', showOnlyAdmin, page ],
|
||||
() => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }),
|
||||
[ '/api/client/servers', (showOnlyAdmin && rootAdmin), page ],
|
||||
() => getServers({ page, type: (showOnlyAdmin && rootAdmin) ? 'admin' : undefined }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -7,53 +7,29 @@ import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
||||
export default () => {
|
||||
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
|
||||
|
||||
return user.useTotp ?
|
||||
return (
|
||||
<div>
|
||||
{visible &&
|
||||
<DisableTwoFactorModal
|
||||
appear
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
{visible && (
|
||||
isEnabled ?
|
||||
<DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
:
|
||||
<SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
)}
|
||||
<p css={tw`text-sm`}>
|
||||
Two-factor authentication is currently enabled on your account.
|
||||
{isEnabled ?
|
||||
'Two-factor authentication is currently enabled on your account.'
|
||||
:
|
||||
'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'
|
||||
}
|
||||
</p>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button
|
||||
color={'red'}
|
||||
isSecondary
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Disable
|
||||
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
{isEnabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
{visible &&
|
||||
<SetupTwoFactorModal
|
||||
appear
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
<p css={tw`text-sm`}>
|
||||
You do not currently have two-factor authentication enabled on your account. Click
|
||||
the button below to begin configuring it.
|
||||
</p>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button
|
||||
color={'green'}
|
||||
isSecondary
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Begin Setup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
;
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { object, string } from 'yup';
|
||||
@ -9,26 +8,31 @@ import { ApplicationStore } from '@/state';
|
||||
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
|
||||
interface Values {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default ({ ...props }: RequiredModalProps) => {
|
||||
const DisableTwoFactorModal = () => {
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
|
||||
|
||||
const submit = ({ password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
setPropOverrides({ showSpinnerOverlay: true, dismissable: false });
|
||||
disableAccountTwoFactor(password)
|
||||
.then(() => {
|
||||
updateUserData({ useTotp: false });
|
||||
props.onDismissed();
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
setSubmitting(false);
|
||||
setPropOverrides(null);
|
||||
});
|
||||
};
|
||||
|
||||
@ -42,29 +46,26 @@ export default ({ ...props }: RequiredModalProps) => {
|
||||
password: string().required('You must provider your current password in order to continue.'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
|
||||
<Form className={'mb-0'}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
|
||||
<Field
|
||||
id={'password'}
|
||||
name={'password'}
|
||||
type={'password'}
|
||||
label={'Current Password'}
|
||||
description={'In order to disable two-factor authentication you will need to provide your account password.'}
|
||||
autoFocus
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button
|
||||
color={'red'}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Disable Two-Factor
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
{({ isValid }) => (
|
||||
<Form className={'mb-0'}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
|
||||
<Field
|
||||
id={'password'}
|
||||
name={'password'}
|
||||
type={'password'}
|
||||
label={'Current Password'}
|
||||
description={'In order to disable two-factor authentication you will need to provide your account password.'}
|
||||
autoFocus
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button color={'red'} disabled={!isValid}>
|
||||
Disable Two-Factor
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal()(DisableTwoFactorModal);
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { object, string } from 'yup';
|
||||
import getTwoFactorTokenUrl from '@/api/account/getTwoFactorTokenUrl';
|
||||
@ -10,16 +9,19 @@ import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Field from '@/components/elements/Field';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import QRCode from 'qrcode.react';
|
||||
|
||||
interface Values {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export default ({ onDismissed, ...props }: RequiredModalProps) => {
|
||||
const SetupTwoFactorModal = () => {
|
||||
const [ token, setToken ] = useState('');
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ recoveryTokens, setRecoveryTokens ] = useState<string[]>([]);
|
||||
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
|
||||
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
@ -33,6 +35,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
|
||||
}, []);
|
||||
|
||||
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
setPropOverrides(state => ({ ...state, showSpinnerOverlay: true }));
|
||||
enableAccountTwoFactor(code)
|
||||
.then(tokens => {
|
||||
setRecoveryTokens(tokens);
|
||||
@ -42,16 +45,25 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
|
||||
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
})
|
||||
.then(() => setSubmitting(false));
|
||||
.then(() => {
|
||||
setSubmitting(false);
|
||||
setPropOverrides(state => ({ ...state, showSpinnerOverlay: false }));
|
||||
});
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
if (recoveryTokens.length > 0) {
|
||||
updateUserData({ useTotp: true });
|
||||
}
|
||||
useEffect(() => {
|
||||
setPropOverrides(state => ({
|
||||
...state,
|
||||
closeOnEscape: !recoveryTokens.length,
|
||||
closeOnBackground: !recoveryTokens.length,
|
||||
}));
|
||||
|
||||
onDismissed();
|
||||
};
|
||||
return () => {
|
||||
if (recoveryTokens.length > 0) {
|
||||
updateUserData({ useTotp: true });
|
||||
}
|
||||
};
|
||||
}, [ recoveryTokens ]);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
@ -63,79 +75,64 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
|
||||
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Modal
|
||||
{...props}
|
||||
top={false}
|
||||
onDismissed={dismiss}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={loading || isSubmitting}
|
||||
closeOnEscape={!recoveryTokens}
|
||||
closeOnBackground={!recoveryTokens}
|
||||
>
|
||||
{recoveryTokens.length > 0 ?
|
||||
<>
|
||||
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
|
||||
<p css={tw`text-neutral-300`}>
|
||||
Two-factor authentication has been enabled on your account. Should you loose access to
|
||||
this device you'll need to use one of the codes displayed below in order to access your
|
||||
account.
|
||||
</p>
|
||||
<p css={tw`text-neutral-300 mt-4`}>
|
||||
<strong>These codes will not be displayed again.</strong> Please take note of them now
|
||||
by storing them in a secure repository such as a password manager.
|
||||
</p>
|
||||
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
|
||||
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
|
||||
</pre>
|
||||
<div css={tw`text-right`}>
|
||||
<Button css={tw`mt-6`} onClick={dismiss}>
|
||||
Close
|
||||
{recoveryTokens.length > 0 ?
|
||||
<>
|
||||
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
|
||||
<p css={tw`text-neutral-300`}>
|
||||
Two-factor authentication has been enabled on your account. Should you loose access to
|
||||
this device you'll need to use one of the codes displayed below in order to access your
|
||||
account.
|
||||
</p>
|
||||
<p css={tw`text-neutral-300 mt-4`}>
|
||||
<strong>These codes will not be displayed again.</strong> Please take note of them now
|
||||
by storing them in a secure repository such as a password manager.
|
||||
</p>
|
||||
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
|
||||
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
|
||||
</pre>
|
||||
<div css={tw`text-right`}>
|
||||
<Button css={tw`mt-6`} onClick={dismiss}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<Form css={tw`mb-0`}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
|
||||
<div css={tw`flex flex-wrap`}>
|
||||
<div css={tw`w-full md:flex-1`}>
|
||||
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
|
||||
{!token || !token.length ?
|
||||
<img
|
||||
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
|
||||
css={tw`w-64 h-64 rounded`}
|
||||
/>
|
||||
:
|
||||
<QRCode renderAs={'svg'} value={token} css={tw`w-full h-full shadow-none rounded-none`}/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
|
||||
<div css={tw`flex-1`}>
|
||||
<Field
|
||||
id={'code'}
|
||||
name={'code'}
|
||||
type={'text'}
|
||||
title={'Code From Authenticator'}
|
||||
description={'Enter the code from your authenticator device after scanning the QR image.'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6 md:mt-0 text-right`}>
|
||||
<Button>
|
||||
Setup
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<Form css={tw`mb-0`}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
|
||||
<div css={tw`flex flex-wrap`}>
|
||||
<div css={tw`w-full md:flex-1`}>
|
||||
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
|
||||
{!token || !token.length ?
|
||||
<img
|
||||
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
|
||||
css={tw`w-64 h-64 rounded`}
|
||||
/>
|
||||
:
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${token}`}
|
||||
onLoad={() => setLoading(false)}
|
||||
css={tw`w-full h-full shadow-none rounded-none`}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
|
||||
<div css={tw`flex-1`}>
|
||||
<Field
|
||||
id={'code'}
|
||||
name={'code'}
|
||||
type={'text'}
|
||||
title={'Code From Authenticator'}
|
||||
description={'Enter the code from your authenticator device after scanning the QR image.'}
|
||||
autoFocus={!loading}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6 md:mt-0 text-right`}>
|
||||
<Button>
|
||||
Setup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal()(SetupTwoFactorModal);
|
||||
|
@ -17,7 +17,7 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
|
||||
<FormikField innerRef={ref} name={name} validate={validate}>
|
||||
{
|
||||
({ field, form: { errors, touched } }: FieldProps) => (
|
||||
<>
|
||||
<div>
|
||||
{label &&
|
||||
<Label htmlFor={id} isLight={light}>{label}</Label>
|
||||
}
|
||||
@ -35,7 +35,7 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
|
||||
:
|
||||
description ? <p className={'input-help'}>{description}</p> : null
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormikField>
|
||||
|
@ -28,7 +28,7 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
|
||||
</ContentContainer>
|
||||
<ContentContainer css={tw`mb-4`}>
|
||||
<p css={tw`text-center text-neutral-500 text-xs`}>
|
||||
© 2015 - 2020
|
||||
© 2015 - {(new Date()).getFullYear()}
|
||||
<a
|
||||
rel={'noopener nofollow noreferrer'}
|
||||
href={'https://pterodactyl.io'}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import styled, { css, keyframes } from 'styled-components/macro';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
@ -10,6 +10,11 @@ interface Props {
|
||||
isBlue?: boolean;
|
||||
}
|
||||
|
||||
interface Spinner extends React.FC<Props> {
|
||||
Size: Record<'SMALL' | 'BASE' | 'LARGE', SpinnerSize>;
|
||||
Suspense: React.FC<Props>;
|
||||
}
|
||||
|
||||
const spin = keyframes`
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
@ -30,7 +35,7 @@ const SpinnerComponent = styled.div<Props>`
|
||||
border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'};
|
||||
`;
|
||||
|
||||
const Spinner = ({ centered, ...props }: Props) => (
|
||||
const Spinner: Spinner = ({ centered, ...props }) => (
|
||||
centered ?
|
||||
<div
|
||||
css={[
|
||||
@ -43,12 +48,19 @@ const Spinner = ({ centered, ...props }: Props) => (
|
||||
:
|
||||
<SpinnerComponent {...props}/>
|
||||
);
|
||||
Spinner.DisplayName = 'Spinner';
|
||||
Spinner.displayName = 'Spinner';
|
||||
|
||||
Spinner.Size = {
|
||||
SMALL: 'small' as SpinnerSize,
|
||||
BASE: 'base' as SpinnerSize,
|
||||
LARGE: 'large' as SpinnerSize,
|
||||
SMALL: 'small',
|
||||
BASE: 'base',
|
||||
LARGE: 'large',
|
||||
};
|
||||
|
||||
Spinner.Suspense = ({ children, centered = true, size = Spinner.Size.LARGE, ...props }) => (
|
||||
<Suspense fallback={<Spinner centered={centered} size={size} {...props}/>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
Spinner.Suspense.displayName = 'Spinner.Suspense';
|
||||
|
||||
export default Spinner;
|
||||
|
@ -1,10 +0,0 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
|
||||
const SuspenseSpinner: React.FC = ({ children }) => (
|
||||
<Suspense fallback={<Spinner size={'large'} centered/>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
export default SuspenseSpinner;
|
@ -1,6 +1,5 @@
|
||||
import React, { lazy, memo } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
|
||||
import Can from '@/components/elements/Can';
|
||||
import ContentContainer from '@/components/elements/ContentContainer';
|
||||
import tw from 'twin.macro';
|
||||
@ -10,6 +9,7 @@ import isEqual from 'react-fast-compare';
|
||||
import PowerControls from '@/components/server/PowerControls';
|
||||
import { EulaModalFeature } from '@feature/index';
|
||||
import ErrorBoundary from '@/components/elements/ErrorBoundary';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
|
||||
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
@ -51,12 +51,12 @@ const ServerConsole = () => {
|
||||
}
|
||||
</div>
|
||||
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>
|
||||
<SuspenseSpinner>
|
||||
<Spinner.Suspense>
|
||||
<ErrorBoundary>
|
||||
<ChunkedConsole/>
|
||||
</ErrorBoundary>
|
||||
<ChunkedStatGraphs/>
|
||||
</SuspenseSpinner>
|
||||
</Spinner.Suspense>
|
||||
{eggFeatures.includes('eula') &&
|
||||
<React.Suspense fallback={null}>
|
||||
<EulaModalFeature/>
|
||||
|
@ -60,7 +60,7 @@ const BackupContainer = () => {
|
||||
</Pagination>
|
||||
{backupLimit === 0 &&
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
Backups cannot be created for this server.
|
||||
Backups cannot be created for this server because the backup limit is set to 0.
|
||||
</p>
|
||||
}
|
||||
<Can action={'backup.create'}>
|
||||
|
@ -1,10 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faBoxOpen,
|
||||
faCloudDownloadAlt,
|
||||
faEllipsisH,
|
||||
faLock,
|
||||
faTrashAlt,
|
||||
faUnlock,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
||||
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import deleteBackup from '@/api/server/backups/deleteBackup';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
@ -15,6 +21,7 @@ import { ServerBackup } from '@/api/server/types';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { restoreServerBackup } from '@/api/server/backups';
|
||||
import http, { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
interface Props {
|
||||
backup: ServerBackup;
|
||||
@ -76,14 +83,35 @@ export default ({ backup }: Props) => {
|
||||
.then(() => setModal(''));
|
||||
};
|
||||
|
||||
const onLockToggle = () => {
|
||||
if (backup.isLocked && modal !== 'unlock') {
|
||||
return setModal('unlock');
|
||||
}
|
||||
|
||||
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
|
||||
.then(() => mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
|
||||
...b,
|
||||
isLocked: !b.isLocked,
|
||||
}),
|
||||
}), false))
|
||||
.catch(error => alert(httpErrorToHuman(error)))
|
||||
.then(() => setModal(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChecksumModal
|
||||
appear
|
||||
visible={modal === 'checksum'}
|
||||
onDismissed={() => setModal('')}
|
||||
checksum={backup.checksum}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
visible={modal === 'unlock'}
|
||||
title={'Unlock this backup?'}
|
||||
onConfirmed={onLockToggle}
|
||||
onModalDismissed={() => setModal('')}
|
||||
buttonText={'Yes, unlock'}
|
||||
>
|
||||
Are you sure you want to unlock this backup? It will no longer be protected from automated or
|
||||
accidental deletions.
|
||||
</ConfirmationModal>
|
||||
<ConfirmationModal
|
||||
visible={modal === 'restore'}
|
||||
title={'Restore this backup?'}
|
||||
@ -151,15 +179,23 @@ export default ({ backup }: Props) => {
|
||||
<span css={tw`ml-2`}>Restore</span>
|
||||
</DropdownButtonRow>
|
||||
</Can>
|
||||
<DropdownButtonRow onClick={() => setModal('checksum')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Checksum</span>
|
||||
</DropdownButtonRow>
|
||||
<Can action={'backup.delete'}>
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
<>
|
||||
<DropdownButtonRow onClick={onLockToggle}>
|
||||
<FontAwesomeIcon
|
||||
fixedWidth
|
||||
icon={backup.isLocked ? faUnlock : faLock}
|
||||
css={tw`text-xs mr-2`}
|
||||
/>
|
||||
{backup.isLocked ? 'Unlock' : 'Lock'}
|
||||
</DropdownButtonRow>
|
||||
{!backup.isLocked &&
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
}
|
||||
</>
|
||||
</Can>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { bytesToHuman } from '@/helpers';
|
||||
@ -45,7 +45,10 @@ export default ({ backup, className }: Props) => {
|
||||
<div css={tw`flex items-center truncate w-full md:flex-1`}>
|
||||
<div css={tw`mr-4`}>
|
||||
{backup.completedAt ?
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
backup.isLocked ?
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
|
||||
:
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
:
|
||||
<Spinner size={'small'}/>
|
||||
}
|
||||
@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => {
|
||||
}
|
||||
</div>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||
{backup.uuid}
|
||||
{backup.checksum}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
|
||||
<Modal {...props}>
|
||||
<h3 css={tw`mb-6 text-2xl`}>Verify file checksum</h3>
|
||||
<p css={tw`text-sm`}>
|
||||
The checksum of this file is:
|
||||
</p>
|
||||
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
|
||||
<code css={tw`block font-mono overflow-auto`}>{checksum}</code>
|
||||
</pre>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default ChecksumModal;
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { object, string } from 'yup';
|
||||
import { boolean, object, string } from 'yup';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
@ -12,10 +12,13 @@ import tw from 'twin.macro';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
ignored: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
@ -26,14 +29,12 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
||||
<div css={tw`mb-6`}>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mb-6`}>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<FormikFieldWrapper
|
||||
name={'ignored'}
|
||||
label={'Ignored Files & Directories'}
|
||||
@ -47,7 +48,16 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<div css={tw`flex justify-end`}>
|
||||
<Can action={'backup.delete'}>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'isLocked'}
|
||||
label={'Locked'}
|
||||
description={'Prevents this backup from being deleted until explicitly unlocked.'}
|
||||
/>
|
||||
</div>
|
||||
</Can>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'submit'} disabled={isSubmitting}>
|
||||
Start backup
|
||||
</Button>
|
||||
@ -67,9 +77,9 @@ export default () => {
|
||||
clearFlashes('backups:create');
|
||||
}, [ visible ]);
|
||||
|
||||
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, name, ignored)
|
||||
createServerBackup(uuid, values)
|
||||
.then(backup => {
|
||||
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
||||
setVisible(false);
|
||||
@ -85,10 +95,11 @@ export default () => {
|
||||
{visible &&
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '' }}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
||||
|
@ -53,7 +53,7 @@ export default () => {
|
||||
/>
|
||||
))
|
||||
:
|
||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
{databaseLimit > 0 ?
|
||||
'It looks like you have no databases.'
|
||||
:
|
||||
|
@ -24,7 +24,7 @@ const LazyCodemirrorEditor = lazy(() => import(/* webpackChunkName: "editor" */'
|
||||
|
||||
export default () => {
|
||||
const [ error, setError ] = useState('');
|
||||
const { action } = useParams<{ action: string }>();
|
||||
const { action } = useParams<{ action: 'new' | string }>();
|
||||
const [ loading, setLoading ] = useState(action === 'edit');
|
||||
const [ content, setContent ] = useState('');
|
||||
const [ modalVisible, setModalVisible ] = useState(false);
|
||||
|
@ -63,7 +63,7 @@ const MassActionsBar = () => {
|
||||
|
||||
return (
|
||||
<Fade timeout={75} in={selectedFiles.length > 0} unmountOnExit>
|
||||
<div css={tw`fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
|
||||
<div css={tw`pointer-events-none fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
|
||||
<SpinnerOverlay visible={loading} size={'large'} fixed>
|
||||
{loadingMessage}
|
||||
</SpinnerOverlay>
|
||||
@ -74,7 +74,18 @@ const MassActionsBar = () => {
|
||||
onConfirmed={onClickConfirmDeletion}
|
||||
onModalDismissed={() => setShowConfirm(false)}
|
||||
>
|
||||
Deleting files is a permanent operation, you cannot undo this action.
|
||||
Are you sure you want to delete {selectedFiles.length} file(s)?
|
||||
<br/>
|
||||
Deleting the file(s) listed below is a permanent operation, you cannot undo this action.
|
||||
<br/>
|
||||
<code>
|
||||
{ selectedFiles.slice(0, 15).map(file => (
|
||||
<li key={file}>{file}<br/></li>))
|
||||
}
|
||||
{ selectedFiles.length > 15 &&
|
||||
<li> + {selectedFiles.length - 15} other(s) </li>
|
||||
}
|
||||
</code>
|
||||
</ConfirmationModal>
|
||||
{showMove &&
|
||||
<RenameFileModal
|
||||
@ -85,7 +96,7 @@ const MassActionsBar = () => {
|
||||
onDismissed={() => setShowMove(false)}
|
||||
/>
|
||||
}
|
||||
<div css={tw`rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
|
||||
<div css={tw`pointer-events-auto rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
|
||||
<Button size={'xsmall'} css={tw`mr-4`} onClick={() => setShowMove(true)}>
|
||||
<FontAwesomeIcon icon={faLevelUpAlt} css={tw`mr-2`}/> Move
|
||||
</Button>
|
||||
|
@ -11,6 +11,7 @@ import Can from '@/components/elements/Can';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import getServerAllocations from '@/api/swr/getServerAllocations';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
|
||||
|
||||
const NetworkContainer = () => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
@ -23,7 +24,7 @@ const NetworkContainer = () => {
|
||||
const { data, error, mutate } = getServerAllocations();
|
||||
|
||||
useEffect(() => {
|
||||
mutate(allocations, false);
|
||||
mutate(allocations);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -32,6 +33,12 @@ const NetworkContainer = () => {
|
||||
}
|
||||
}, [ error ]);
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
setServerFromState(state => ({ ...state, allocations: data }));
|
||||
}, [ data ]);
|
||||
|
||||
const onCreateAllocation = () => {
|
||||
clearFlashes('server:network');
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedule';
|
||||
import { ServerContext } from '@/state/server';
|
||||
@ -11,10 +10,12 @@ import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import asModal from '@/hoc/asModal';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
schedule?: Schedule;
|
||||
} & RequiredModalProps;
|
||||
}
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
@ -24,70 +25,21 @@ interface Values {
|
||||
hour: string;
|
||||
minute: string;
|
||||
enabled: boolean;
|
||||
onlyWhenOnline: boolean;
|
||||
}
|
||||
|
||||
const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdated'>) => {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
return (
|
||||
<Modal {...props} showSpinnerOverlay={isSubmitting}>
|
||||
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
|
||||
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
|
||||
<Form>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Schedule name'}
|
||||
description={'A human readable identifer for this schedule.'}
|
||||
/>
|
||||
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
|
||||
<div>
|
||||
<Field name={'minute'} label={'Minute'}/>
|
||||
</div>
|
||||
<div>
|
||||
<Field name={'hour'} label={'Hour'}/>
|
||||
</div>
|
||||
<div>
|
||||
<Field name={'dayOfMonth'} label={'Day of month'}/>
|
||||
</div>
|
||||
<div>
|
||||
<Field name={'month'} label={'Month'}/>
|
||||
</div>
|
||||
<div>
|
||||
<Field name={'dayOfWeek'} label={'Day of week'}/>
|
||||
</div>
|
||||
</div>
|
||||
<p css={tw`text-neutral-400 text-xs mt-2`}>
|
||||
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
|
||||
running. Use the fields above to specify when these tasks should begin running.
|
||||
</p>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'enabled'}
|
||||
description={'If disabled, this schedule and it\'s associated tasks will not run.'}
|
||||
label={'Enabled'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button css={tw`w-full sm:w-auto`} type={'submit'} disabled={isSubmitting}>
|
||||
{schedule ? 'Save changes' : 'Create schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ({ schedule, visible, ...props }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const EditScheduleModal = ({ schedule }: Props) => {
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ modalVisible, setModalVisible ] = useState(visible);
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
|
||||
useEffect(() => {
|
||||
setModalVisible(visible);
|
||||
clearFlashes('schedule:edit');
|
||||
}, [ visible ]);
|
||||
return () => {
|
||||
clearFlashes('schedule:edit');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('schedule:edit');
|
||||
@ -101,12 +53,13 @@ export default ({ schedule, visible, ...props }: Props) => {
|
||||
month: values.month,
|
||||
dayOfMonth: values.dayOfMonth,
|
||||
},
|
||||
onlyWhenOnline: values.onlyWhenOnline,
|
||||
isActive: values.enabled,
|
||||
})
|
||||
.then(schedule => {
|
||||
setSubmitting(false);
|
||||
appendSchedule(schedule);
|
||||
setModalVisible(false);
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
@ -126,15 +79,53 @@ export default ({ schedule, visible, ...props }: Props) => {
|
||||
dayOfMonth: schedule?.cron.dayOfMonth || '*',
|
||||
month: schedule?.cron.month || '*',
|
||||
dayOfWeek: schedule?.cron.dayOfWeek || '*',
|
||||
enabled: schedule ? schedule.isActive : true,
|
||||
enabled: schedule?.isActive ?? true,
|
||||
onlyWhenOnline: schedule?.onlyWhenOnline ?? true,
|
||||
} as Values}
|
||||
validationSchema={null}
|
||||
>
|
||||
<EditScheduleModal
|
||||
visible={modalVisible}
|
||||
schedule={schedule}
|
||||
{...props}
|
||||
/>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
|
||||
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Schedule name'}
|
||||
description={'A human readable identifer for this schedule.'}
|
||||
/>
|
||||
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
|
||||
<Field name={'minute'} label={'Minute'}/>
|
||||
<Field name={'hour'} label={'Hour'}/>
|
||||
<Field name={'dayOfMonth'} label={'Day of month'}/>
|
||||
<Field name={'month'} label={'Month'}/>
|
||||
<Field name={'dayOfWeek'} label={'Day of week'}/>
|
||||
</div>
|
||||
<p css={tw`text-neutral-400 text-xs mt-2`}>
|
||||
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
|
||||
running. Use the fields above to specify when these tasks should begin running.
|
||||
</p>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'onlyWhenOnline'}
|
||||
description={'Only execute this schedule when the server is in a running state.'}
|
||||
label={'Only When Server Is Online'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'enabled'}
|
||||
description={'This schedule will be executed automatically if enabled.'}
|
||||
label={'Schedule Enabled'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button css={tw`w-full sm:w-auto`} type={'submit'} disabled={isSubmitting}>
|
||||
{schedule ? 'Save changes' : 'Create schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal<Props>()(EditScheduleModal);
|
||||
|
@ -13,12 +13,7 @@ export default ({ schedule }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible &&
|
||||
<TaskDetailsModal
|
||||
schedule={schedule}
|
||||
onDismissed={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
<TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
<Button onClick={() => setVisible(true)} css={tw`flex-1`}>
|
||||
New Task
|
||||
</Button>
|
||||
|
@ -67,7 +67,7 @@ export default () => {
|
||||
}
|
||||
<Can action={'schedule.create'}>
|
||||
<div css={tw`mt-8 flex justify-end`}>
|
||||
{visible && <EditScheduleModal appear visible onDismissed={() => setVisible(false)}/>}
|
||||
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
<Button type={'button'} onClick={() => setVisible(true)}>
|
||||
Create schedule
|
||||
</Button>
|
||||
|
@ -46,9 +46,9 @@ const ActivePill = ({ active }: { active: boolean }) => (
|
||||
);
|
||||
|
||||
export default () => {
|
||||
const params = useParams() as Params;
|
||||
const history = useHistory();
|
||||
const state = useLocation<State>().state;
|
||||
const { state } = useLocation<State>();
|
||||
const { id: scheduleId } = useParams<Params>();
|
||||
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
@ -61,27 +61,27 @@ export default () => {
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
|
||||
useEffect(() => {
|
||||
if (schedule?.id === Number(params.id)) {
|
||||
if (schedule?.id === Number(scheduleId)) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
clearFlashes('schedules');
|
||||
getServerSchedule(uuid, Number(params.id))
|
||||
getServerSchedule(uuid, Number(scheduleId))
|
||||
.then(schedule => appendSchedule(schedule))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'schedules' });
|
||||
})
|
||||
.then(() => setIsLoading(false));
|
||||
}, [ params ]);
|
||||
}, [ scheduleId ]);
|
||||
|
||||
const toggleEditModal = useCallback(() => {
|
||||
setShowEditModal(s => !s);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<PageContentBlock title={'Schedules'}>
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
||||
{!schedule || isLoading ?
|
||||
<Spinner size={'large'} centered/>
|
||||
@ -153,7 +153,7 @@ export default () => {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
|
||||
<EditScheduleModal visible={showEditModal} schedule={schedule} onModalDismissed={toggleEditModal}/>
|
||||
<div css={tw`mt-6 flex sm:justify-end`}>
|
||||
<Can action={'schedule.delete'}>
|
||||
<DeleteScheduleButton
|
||||
@ -161,7 +161,7 @@ export default () => {
|
||||
onDeleted={() => history.push(`/server/${id}/schedules`)}
|
||||
/>
|
||||
</Can>
|
||||
{schedule.isActive && schedule.tasks.length > 0 &&
|
||||
{schedule.tasks.length > 0 &&
|
||||
<Can action={'schedule.update'}>
|
||||
<RunScheduleButton schedule={schedule}/>
|
||||
</Can>
|
||||
|
@ -1,7 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faArrowCircleDown,
|
||||
faClock,
|
||||
faCode,
|
||||
faFileArchive,
|
||||
faPencilAlt,
|
||||
faToggleOn,
|
||||
faTrashAlt,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
@ -59,11 +67,12 @@ export default ({ schedule, task }: Props) => {
|
||||
return (
|
||||
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
|
||||
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
|
||||
{isEditing && <TaskDetailsModal
|
||||
<TaskDetailsModal
|
||||
schedule={schedule}
|
||||
task={task}
|
||||
onDismissed={() => setIsEditing(false)}
|
||||
/>}
|
||||
visible={isEditing}
|
||||
onModalDismissed={() => setIsEditing(false)}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
title={'Confirm task deletion'}
|
||||
buttonText={'Delete Task'}
|
||||
@ -89,6 +98,14 @@ export default ({ schedule, task }: Props) => {
|
||||
}
|
||||
</div>
|
||||
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
|
||||
{task.continueOnFailure &&
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
|
||||
<Icon icon={faArrowCircleDown} css={tw`w-3 h-3 mr-2`}/>
|
||||
Continues on Failure
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{task.sequenceId > 1 && task.timeOffset > 0 &&
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
|
||||
|
@ -1,13 +1,12 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
|
||||
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { Field as FormikField, Form, Formik, FormikHelpers, useField } from 'formik';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { number, object, string } from 'yup';
|
||||
import { boolean, number, object, string } from 'yup';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import tw from 'twin.macro';
|
||||
@ -15,158 +14,177 @@ import Label from '@/components/elements/Label';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Select from '@/components/elements/Select';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
|
||||
interface Props {
|
||||
schedule: Schedule;
|
||||
// If a task is provided we can assume we're editing it. If not provided,
|
||||
// we are creating a new one.
|
||||
task?: Task;
|
||||
onDismissed: () => void;
|
||||
}
|
||||
|
||||
interface Values {
|
||||
action: string;
|
||||
payload: string;
|
||||
timeOffset: string;
|
||||
continueOnFailure: boolean;
|
||||
}
|
||||
|
||||
const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
|
||||
const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext<Values>();
|
||||
const schema = object().shape({
|
||||
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
|
||||
payload: string().when('action', {
|
||||
is: v => v !== 'backup',
|
||||
then: string().required('A task payload must be provided.'),
|
||||
otherwise: string(),
|
||||
}),
|
||||
continueOnFailure: boolean(),
|
||||
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
|
||||
.required('A time offset value must be provided.')
|
||||
.min(0, 'The time offset must be at least 0 seconds.')
|
||||
.max(900, 'The time offset must be less than 900 seconds.'),
|
||||
});
|
||||
|
||||
const ActionListener = () => {
|
||||
const [ { value }, { initialValue: initialAction } ] = useField<string>('action');
|
||||
const [ , { initialValue: initialPayload }, { setValue, setTouched } ] = useField<string>('payload');
|
||||
|
||||
useEffect(() => {
|
||||
if (action !== initialValues.action) {
|
||||
setFieldValue('payload', action === 'power' ? 'start' : '');
|
||||
setFieldTouched('payload', false);
|
||||
if (value !== initialAction) {
|
||||
setValue(value === 'power' ? 'start' : '');
|
||||
setTouched(false);
|
||||
} else {
|
||||
setFieldValue('payload', initialValues.payload);
|
||||
setFieldTouched('payload', false);
|
||||
setValue(initialPayload || '');
|
||||
setTouched(false);
|
||||
}
|
||||
}, [ action ]);
|
||||
}, [ value ]);
|
||||
|
||||
return (
|
||||
<Form css={tw`m-0`}>
|
||||
<h2 css={tw`text-2xl mb-6`}>{isEditingTask ? 'Edit Task' : 'Create Task'}</h2>
|
||||
<div css={tw`flex`}>
|
||||
<div css={tw`mr-2 w-1/3`}>
|
||||
<Label>Action</Label>
|
||||
<FormikFieldWrapper name={'action'}>
|
||||
<FormikField as={Select} name={'action'}>
|
||||
<option value={'command'}>Send command</option>
|
||||
<option value={'power'}>Send power action</option>
|
||||
<option value={'backup'}>Create backup</option>
|
||||
</FormikField>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<div css={tw`flex-1 ml-6`}>
|
||||
<Field
|
||||
name={'timeOffset'}
|
||||
label={'Time offset (in seconds)'}
|
||||
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
{action === 'command' ?
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6} />
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
:
|
||||
action === 'power' ?
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<FormikField as={Select} name={'payload'}>
|
||||
<option value={'start'}>Start the server</option>
|
||||
<option value={'restart'}>Restart the server</option>
|
||||
<option value={'stop'}>Stop the server</option>
|
||||
<option value={'kill'}>Terminate the server</option>
|
||||
</FormikField>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<Label>Ignored Files</Label>
|
||||
<FormikFieldWrapper
|
||||
name={'payload'}
|
||||
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used. If you have reached your backup limit, the oldest backup will be rotated.'}
|
||||
>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6} />
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'submit'} disabled={isSubmitting}>
|
||||
{isEditingTask ? 'Save Changes' : 'Create Task'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ({ task, schedule, onDismissed }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const TaskDetailsModal = ({ schedule, task }: Props) => {
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('schedule:task');
|
||||
return () => {
|
||||
clearFlashes('schedule:task');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('schedule:task');
|
||||
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
|
||||
.then(task => {
|
||||
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
|
||||
if (!schedule.tasks.find(t => t.id === task.id)) {
|
||||
tasks = [ ...tasks, task ];
|
||||
}
|
||||
if (backupLimit === 0 && values.action === 'backup') {
|
||||
setSubmitting(false);
|
||||
addError({ message: 'A backup task cannot be created when the server\'s backup limit is set to 0.', key: 'schedule:task' });
|
||||
} else {
|
||||
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
|
||||
.then(task => {
|
||||
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
|
||||
if (!schedule.tasks.find(t => t.id === task.id)) {
|
||||
tasks = [ ...tasks, task ];
|
||||
}
|
||||
|
||||
appendSchedule({ ...schedule, tasks });
|
||||
onDismissed();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
|
||||
});
|
||||
appendSchedule({ ...schedule, tasks });
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
validationSchema={schema}
|
||||
initialValues={{
|
||||
action: task?.action || 'command',
|
||||
payload: task?.payload || '',
|
||||
timeOffset: task?.timeOffset.toString() || '0',
|
||||
continueOnFailure: task?.continueOnFailure || false,
|
||||
}}
|
||||
validationSchema={object().shape({
|
||||
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
|
||||
payload: string().when('action', {
|
||||
is: v => v !== 'backup',
|
||||
then: string().required('A task payload must be provided.'),
|
||||
otherwise: string(),
|
||||
}),
|
||||
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
|
||||
.required('A time offset value must be provided.')
|
||||
.min(0, 'The time offset must be at least 0 seconds.')
|
||||
.max(900, 'The time offset must be less than 900 seconds.'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Modal
|
||||
visible
|
||||
appear
|
||||
onDismissed={() => onDismissed()}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
>
|
||||
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`} />
|
||||
<TaskDetailsForm isEditingTask={typeof task !== 'undefined'} />
|
||||
</Modal>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Form css={tw`m-0`}>
|
||||
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>{task ? 'Edit Task' : 'Create Task'}</h2>
|
||||
<div css={tw`flex`}>
|
||||
<div css={tw`mr-2 w-1/3`}>
|
||||
<Label>Action</Label>
|
||||
<ActionListener/>
|
||||
<FormikFieldWrapper name={'action'}>
|
||||
<FormikField as={Select} name={'action'}>
|
||||
<option value={'command'}>Send command</option>
|
||||
<option value={'power'}>Send power action</option>
|
||||
<option value={'backup'}>Create backup</option>
|
||||
</FormikField>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<div css={tw`flex-1 ml-6`}>
|
||||
<Field
|
||||
name={'timeOffset'}
|
||||
label={'Time offset (in seconds)'}
|
||||
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
{values.action === 'command' ?
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6}/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
:
|
||||
values.action === 'power' ?
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<FormikField as={Select} name={'payload'}>
|
||||
<option value={'start'}>Start the server</option>
|
||||
<option value={'restart'}>Restart the server</option>
|
||||
<option value={'stop'}>Stop the server</option>
|
||||
<option value={'kill'}>Terminate the server</option>
|
||||
</FormikField>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<Label>Ignored Files</Label>
|
||||
<FormikFieldWrapper
|
||||
name={'payload'}
|
||||
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used. If you have reached your backup limit, the oldest backup will be rotated.'}
|
||||
>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6}/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'continueOnFailure'}
|
||||
description={'Future tasks will be run when this task fails.'}
|
||||
label={'Continue on Failure'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'submit'} disabled={isSubmitting}>
|
||||
{task ? 'Save Changes' : 'Create Task'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal<Props>()(TaskDetailsModal);
|
||||
|
@ -32,7 +32,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const { dismiss, toggleSpinner } = useContext(ModalContext);
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
|
||||
const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const permissions = useStoreState(state => state.permissions.data);
|
||||
@ -56,7 +56,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
||||
}, [ isRootAdmin, permissions, loggedInPermissions ]);
|
||||
|
||||
const submit = (values: Values) => {
|
||||
toggleSpinner(true);
|
||||
setPropOverrides({ showSpinnerOverlay: true });
|
||||
clearFlashes('user:edit');
|
||||
|
||||
createOrUpdateSubuser(uuid, values, subuser)
|
||||
@ -66,7 +66,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
toggleSpinner(false);
|
||||
setPropOverrides(null);
|
||||
clearAndAddHttpError({ key: 'user:edit', error });
|
||||
|
||||
if (ref.current) {
|
||||
|
@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { SettableModalProps } from '@/hoc/asModal';
|
||||
|
||||
export interface ModalContextValues {
|
||||
dismiss: () => void;
|
||||
toggleSpinner: (visible?: boolean) => void;
|
||||
setPropOverrides: (value: ((current: Readonly<Partial<SettableModalProps>>) => Partial<SettableModalProps>) | Partial<SettableModalProps> | null) => void;
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<ModalContextValues>({
|
||||
dismiss: () => null,
|
||||
toggleSpinner: () => null,
|
||||
setPropOverrides: () => null,
|
||||
});
|
||||
|
||||
ModalContext.displayName = 'ModalContext';
|
||||
|
@ -1,24 +1,25 @@
|
||||
import React from 'react';
|
||||
import Modal, { ModalProps } from '@/components/elements/Modal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import PortaledModal, { ModalProps } from '@/components/elements/Modal';
|
||||
import ModalContext, { ModalContextValues } from '@/context/ModalContext';
|
||||
|
||||
export interface AsModalProps {
|
||||
visible: boolean;
|
||||
onModalDismissed?: () => void;
|
||||
}
|
||||
|
||||
type ExtendedModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>;
|
||||
export type SettableModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>;
|
||||
|
||||
interface State {
|
||||
render: boolean;
|
||||
visible: boolean;
|
||||
showSpinnerOverlay?: boolean;
|
||||
propOverrides: Partial<SettableModalProps>;
|
||||
}
|
||||
|
||||
type ExtendedComponentType<T> = (C: React.ComponentType<T>) => React.ComponentType<T & AsModalProps>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P) => ExtendedModalProps)): ExtendedComponentType<P> {
|
||||
function asModal<P extends {}> (modalProps?: SettableModalProps | ((props: P) => SettableModalProps)): ExtendedComponentType<P> {
|
||||
return function (Component) {
|
||||
return class extends React.PureComponent <P & AsModalProps, State> {
|
||||
static displayName = `asModal(${Component.displayName})`;
|
||||
@ -30,54 +31,64 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
|
||||
render: props.visible,
|
||||
visible: props.visible,
|
||||
showSpinnerOverlay: undefined,
|
||||
propOverrides: {},
|
||||
};
|
||||
}
|
||||
|
||||
get modalProps () {
|
||||
get computedModalProps (): Readonly<SettableModalProps & { visible: boolean }> {
|
||||
return {
|
||||
...(typeof modalProps === 'function' ? modalProps(this.props) : modalProps),
|
||||
showSpinnerOverlay: this.state.showSpinnerOverlay,
|
||||
...this.state.propOverrides,
|
||||
visible: this.state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @this {React.PureComponent<P & AsModalProps, State>}
|
||||
*/
|
||||
componentDidUpdate (prevProps: Readonly<P & AsModalProps>) {
|
||||
if (prevProps.visible && !this.props.visible) {
|
||||
// noinspection JSPotentiallyInvalidUsageOfThis
|
||||
this.setState({ visible: false, showSpinnerOverlay: false });
|
||||
} else if (!prevProps.visible && this.props.visible) {
|
||||
// noinspection JSPotentiallyInvalidUsageOfThis
|
||||
this.setState({ render: true, visible: true });
|
||||
}
|
||||
if (!this.state.render) {
|
||||
this.setState({ propOverrides: {} });
|
||||
}
|
||||
}
|
||||
|
||||
dismiss = () => this.setState({ visible: false });
|
||||
|
||||
toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value });
|
||||
setPropOverrides: ModalContextValues['setPropOverrides'] = value => this.setState(state => ({
|
||||
propOverrides: !value ? {} : (typeof value === 'function' ? value(state.propOverrides) : value),
|
||||
}));
|
||||
|
||||
/**
|
||||
* @this {React.PureComponent<P & AsModalProps, State>}
|
||||
*/
|
||||
render () {
|
||||
if (!this.state.render) return null;
|
||||
|
||||
return (
|
||||
this.state.render ?
|
||||
<Modal
|
||||
appear
|
||||
visible={this.state.visible}
|
||||
onDismissed={() => this.setState({ render: false }, () => {
|
||||
if (typeof this.props.onModalDismissed === 'function') {
|
||||
this.props.onModalDismissed();
|
||||
}
|
||||
})}
|
||||
{...this.modalProps}
|
||||
<PortaledModal
|
||||
appear
|
||||
onDismissed={() => this.setState({ render: false }, () => {
|
||||
if (typeof this.props.onModalDismissed === 'function') {
|
||||
this.props.onModalDismissed();
|
||||
}
|
||||
})}
|
||||
{...this.computedModalProps}
|
||||
>
|
||||
<ModalContext.Provider
|
||||
value={{
|
||||
dismiss: this.dismiss.bind(this),
|
||||
setPropOverrides: this.setPropOverrides.bind(this),
|
||||
}}
|
||||
>
|
||||
<ModalContext.Provider
|
||||
value={{
|
||||
dismiss: this.dismiss.bind(this),
|
||||
toggleSpinner: this.toggleSpinner.bind(this),
|
||||
}}
|
||||
>
|
||||
<Component {...this.props}/>
|
||||
</ModalContext.Provider>
|
||||
</Modal>
|
||||
:
|
||||
null
|
||||
<Component {...this.props}/>
|
||||
</ModalContext.Provider>
|
||||
</PortaledModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -9,7 +9,6 @@ import { ServerContext } from '@/state/server';
|
||||
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
|
||||
import FileManagerContainer from '@/components/server/files/FileManagerContainer';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
|
||||
import FileEditContainer from '@/components/server/files/FileEditContainer';
|
||||
import SettingsContainer from '@/components/server/settings/SettingsContainer';
|
||||
import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
|
||||
@ -151,9 +150,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
||||
</RequireServerPermission>
|
||||
</Route>
|
||||
<Route path={`${match.path}/files/:action(edit|new)`} exact>
|
||||
<SuspenseSpinner>
|
||||
<Spinner.Suspense>
|
||||
<FileEditContainer/>
|
||||
</SuspenseSpinner>
|
||||
</Spinner.Suspense>
|
||||
</Route>
|
||||
<Route path={`${match.path}/databases`} exact>
|
||||
<RequireServerPermission permissions={'database.*'}>
|
||||
|
@ -103,6 +103,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
||||
Route::post('/', 'Servers\BackupController@store');
|
||||
Route::get('/{backup}', 'Servers\BackupController@view');
|
||||
Route::get('/{backup}/download', 'Servers\BackupController@download');
|
||||
Route::post('/{backup}/lock', 'Servers\BackupController@toggleLock');
|
||||
Route::post('/{backup}/restore', 'Servers\BackupController@restore');
|
||||
Route::delete('/{backup}', 'Servers\BackupController@delete');
|
||||
});
|
||||
|
@ -89,9 +89,9 @@ class CreateServerScheduleTaskTest extends ClientApiIntegrationTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that backups can be tasked out correctly since they do not require a payload.
|
||||
* Test that backups can not be tasked when the backup limit is 0
|
||||
*/
|
||||
public function testBackupsCanBeTaskedCorrectly()
|
||||
public function testBackupsCanNotBeTaskedIfLimit0()
|
||||
{
|
||||
[$user, $server] = $this->generateTestAccount();
|
||||
|
||||
@ -101,13 +101,17 @@ class CreateServerScheduleTaskTest extends ClientApiIntegrationTestCase
|
||||
$this->actingAs($user)->postJson($this->link($schedule, '/tasks'), [
|
||||
'action' => 'backup',
|
||||
'time_offset' => 0,
|
||||
])->assertOk();
|
||||
])
|
||||
->assertStatus(Response::HTTP_FORBIDDEN)
|
||||
->assertJsonPath('errors.0.detail', 'A backup task cannot be created when the server\'s backup limit is set to 0.');
|
||||
|
||||
$this->actingAs($user)->postJson($this->link($schedule, '/tasks'), [
|
||||
'action' => 'backup',
|
||||
'payload' => "file.txt\nfile2.log",
|
||||
'time_offset' => 0,
|
||||
])->assertOk();
|
||||
])
|
||||
->assertStatus(Response::HTTP_FORBIDDEN)
|
||||
->assertJsonPath('errors.0.detail', 'A backup task cannot be created when the server\'s backup limit is set to 0.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
157
tests/Integration/Jobs/Schedule/RunTaskJobTest.php
Normal file
157
tests/Integration/Jobs/Schedule/RunTaskJobTest.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Tests\Integration\Jobs\Schedule;
|
||||
|
||||
use Mockery;
|
||||
use Exception;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Pterodactyl\Models\Task;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use InvalidArgumentException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Schedule;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Pterodactyl\Jobs\Schedule\RunTaskJob;
|
||||
use GuzzleHttp\Exception\BadResponseException;
|
||||
use Pterodactyl\Tests\Integration\IntegrationTestCase;
|
||||
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
|
||||
class RunTaskJobTest extends IntegrationTestCase
|
||||
{
|
||||
/**
|
||||
* An inactive job should not be run by the system.
|
||||
*/
|
||||
public function testInactiveJobIsNotRun()
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
/** @var \Pterodactyl\Models\Schedule $schedule */
|
||||
$schedule = Schedule::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'is_processing' => true,
|
||||
'last_run_at' => null,
|
||||
'is_active' => false,
|
||||
]);
|
||||
/** @var \Pterodactyl\Models\Task $task */
|
||||
$task = Task::factory()->create(['schedule_id' => $schedule->id, 'is_queued' => true]);
|
||||
|
||||
$job = new RunTaskJob($task);
|
||||
|
||||
Bus::dispatchNow($job);
|
||||
|
||||
$task->refresh();
|
||||
$schedule->refresh();
|
||||
|
||||
$this->assertFalse($task->is_queued);
|
||||
$this->assertFalse($schedule->is_processing);
|
||||
$this->assertFalse($schedule->is_active);
|
||||
$this->assertTrue(CarbonImmutable::now()->isSameAs(CarbonImmutable::ISO8601, $schedule->last_run_at));
|
||||
}
|
||||
|
||||
public function testJobWithInvalidActionThrowsException()
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
/** @var \Pterodactyl\Models\Schedule $schedule */
|
||||
$schedule = Schedule::factory()->create(['server_id' => $server->id]);
|
||||
/** @var \Pterodactyl\Models\Task $task */
|
||||
$task = Task::factory()->create(['schedule_id' => $schedule->id, 'action' => 'foobar']);
|
||||
|
||||
$job = new RunTaskJob($task);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid task action provided: foobar');
|
||||
Bus::dispatchNow($job);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider isManualRunDataProvider
|
||||
*/
|
||||
public function testJobIsExecuted(bool $isManualRun)
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
/** @var \Pterodactyl\Models\Schedule $schedule */
|
||||
$schedule = Schedule::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'is_active' => !$isManualRun,
|
||||
'is_processing' => true,
|
||||
'last_run_at' => null,
|
||||
]);
|
||||
/** @var \Pterodactyl\Models\Task $task */
|
||||
$task = Task::factory()->create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'action' => Task::ACTION_POWER,
|
||||
'payload' => 'start',
|
||||
'is_queued' => true,
|
||||
'continue_on_failure' => false,
|
||||
]);
|
||||
|
||||
$mock = Mockery::mock(DaemonPowerRepository::class);
|
||||
$this->instance(DaemonPowerRepository::class, $mock);
|
||||
|
||||
$mock->expects('setServer')->with(Mockery::on(function ($value) use ($server) {
|
||||
return $value instanceof Server && $value->id === $server->id;
|
||||
}))->andReturnSelf();
|
||||
$mock->expects('send')->with('start')->andReturn(new Response());
|
||||
|
||||
Bus::dispatchNow(new RunTaskJob($task, $isManualRun));
|
||||
|
||||
$task->refresh();
|
||||
$schedule->refresh();
|
||||
|
||||
$this->assertFalse($task->is_queued);
|
||||
$this->assertFalse($schedule->is_processing);
|
||||
$this->assertTrue(CarbonImmutable::now()->isSameAs(CarbonImmutable::ISO8601, $schedule->last_run_at));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider isManualRunDataProvider
|
||||
*/
|
||||
public function testExceptionDuringRunIsHandledCorrectly(bool $continueOnFailure)
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
/** @var \Pterodactyl\Models\Schedule $schedule */
|
||||
$schedule = Schedule::factory()->create(['server_id' => $server->id]);
|
||||
/** @var \Pterodactyl\Models\Task $task */
|
||||
$task = Task::factory()->create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'action' => Task::ACTION_POWER,
|
||||
'payload' => 'start',
|
||||
'continue_on_failure' => $continueOnFailure,
|
||||
]);
|
||||
|
||||
$mock = Mockery::mock(DaemonPowerRepository::class);
|
||||
$this->instance(DaemonPowerRepository::class, $mock);
|
||||
|
||||
$mock->expects('setServer->send')->andThrow(
|
||||
new DaemonConnectionException(new BadResponseException('Bad request', new Request('GET', '/test'), new Response()))
|
||||
);
|
||||
|
||||
if (!$continueOnFailure) {
|
||||
$this->expectException(DaemonConnectionException::class);
|
||||
}
|
||||
|
||||
Bus::dispatchNow(new RunTaskJob($task));
|
||||
|
||||
if ($continueOnFailure) {
|
||||
$task->refresh();
|
||||
$schedule->refresh();
|
||||
|
||||
$this->assertFalse($task->is_queued);
|
||||
$this->assertFalse($schedule->is_processing);
|
||||
$this->assertTrue(CarbonImmutable::now()->isSameAs(CarbonImmutable::ISO8601, $schedule->last_run_at));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function isManualRunDataProvider()
|
||||
{
|
||||
return [[true], [false]];
|
||||
}
|
||||
}
|
43
tests/Unit/Helpers/EnvironmentWriterTraitTest.php
Normal file
43
tests/Unit/Helpers/EnvironmentWriterTraitTest.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Tests\Unit\Helpers;
|
||||
|
||||
use Pterodactyl\Tests\TestCase;
|
||||
use Pterodactyl\Traits\Commands\EnvironmentWriterTrait;
|
||||
|
||||
class EnvironmentWriterTraitTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider variableDataProvider
|
||||
*/
|
||||
public function testVariableIsEscapedProperly($input, $expected)
|
||||
{
|
||||
$output = (new FooClass())->escapeEnvironmentValue($input);
|
||||
|
||||
$this->assertSame($expected, $output);
|
||||
}
|
||||
|
||||
public function variableDataProvider(): array
|
||||
{
|
||||
return [
|
||||
['foo', 'foo'],
|
||||
['abc123', 'abc123'],
|
||||
['val"ue', '"val\"ue"'],
|
||||
['my test value', '"my test value"'],
|
||||
['mysql_p@assword', '"mysql_p@assword"'],
|
||||
['mysql_p#assword', '"mysql_p#assword"'],
|
||||
['mysql p@$$word', '"mysql p@$$word"'],
|
||||
['mysql p%word', '"mysql p%word"'],
|
||||
['mysql p#word', '"mysql p#word"'],
|
||||
['abc_@#test', '"abc_@#test"'],
|
||||
['test 123 $$$', '"test 123 $$$"'],
|
||||
['#password%', '"#password%"'],
|
||||
['$pass ', '"$pass "'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class FooClass
|
||||
{
|
||||
use EnvironmentWriterTrait;
|
||||
}
|
@ -3,18 +3,20 @@
|
||||
"target": "es2015",
|
||||
"module": "es2020",
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"noImplicitReturns": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"es2015",
|
||||
"dom"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": ".",
|
||||
"importsNotUsedAsValues": "preserve",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
|
@ -71,10 +71,17 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
new AssetsManifestPlugin({ writeToDisk: true, publicPath: true, integrity: true, integrityHashes: ['sha384'] }),
|
||||
new ForkTsCheckerWebpackPlugin(isProduction ? {} : {
|
||||
eslint: {
|
||||
files: `${path.join(__dirname, '/resources/scripts')}/**/*.{ts,tsx}`,
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
typescript: {
|
||||
mode: 'write-references',
|
||||
diagnosticOptions: {
|
||||
semantic: true,
|
||||
syntactic: true,
|
||||
},
|
||||
},
|
||||
eslint: isProduction ? undefined : {
|
||||
files: `${path.join(__dirname, '/resources/scripts')}/**/*.{ts,tsx}`,
|
||||
}
|
||||
}),
|
||||
process.env.ANALYZE_BUNDLE ? new BundleAnalyzerPlugin({
|
||||
analyzerHost: '0.0.0.0',
|
||||
|
Loading…
Reference in New Issue
Block a user