Start push of service changes.

Changes the way service files are stored and allows for much easier
updates in the future that won’t affect custom services.

Also stores more configurations in the database to make life easier for
everyone.
This commit is contained in:
Dane Everitt 2017-03-10 18:25:12 -05:00
parent a5577f0afd
commit 70db461075
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
19 changed files with 1459 additions and 598 deletions

View file

@ -0,0 +1,71 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Pterodactyl\Http\Controllers\Admin;
use Log;
use Alert;
use Storage;
use Pterodactyl\Models;
use Illuminate\Http\Request;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\OptionRepository;
use Pterodactyl\Exceptions\DisplayValidationException;
class OptionController extends Controller
{
/**
* Display option overview page.
*
* @param Request $request
* @param int $id
* @return \Illuminate\View\View
*/
public function viewConfiguration(Request $request, $id)
{
return view('admin.services.options.view', ['option' => Models\ServiceOption::findOrFail($id)]);
}
public function editConfiguration(Request $request, $id)
{
$repo = new OptionRepository;
try {
$repo->update($id, $request->intersect([
'name', 'description', 'tag', 'docker_image', 'startup',
'config_from', 'config_stop', 'config_logs', 'config_files', 'config_startup',
]));
Alert::success('Service option configuration has been successfully updated.')->flash();
} catch (DisplayValidationException $ex) {
return redirect()->route('admin.services.option.view', $id)->withErrors(json_decode($ex->getMessage()));
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An unhandled exception occurred while attempting to update this service option. This error has been logged.')->flash();
}
return redirect()->route('admin.services.option.view', $id);
}
}

View file

@ -36,276 +36,311 @@ use Pterodactyl\Exceptions\DisplayValidationException;
class ServiceController extends Controller
{
public function __construct()
{
//
}
public function getIndex(Request $request)
/**
* Display service overview page.
*
* @param Request $request
* @return \Illuminate\View\View
*/
public function index(Request $request)
{
return view('admin.services.index', [
'services' => Models\Service::withCount('servers')->get(),
'services' => Models\Service::withCount('servers', 'options', 'packs')->get(),
]);
}
public function getNew(Request $request)
/**
* Display create service page.
*
* @param Request $request
* @return \Illuminate\View\View
*/
public function new(Request $request)
{
return view('admin.services.new');
}
public function postNew(Request $request)
/**
* Return base view for a service.
*
* @param Request $request
* @param int $id
* @return \Illuminate\View\View
*/
public function view(Request $request, $id)
{
return view('admin.services.view', [
'service' => Models\Service::with('options', 'options.servers')->findOrFail($id),
]);
}
/**
* Handle post action for new service.
*
* @param Request $request
* @return \Illuminate\Response\RedirectResponse
*/
public function create(Request $request)
{
$repo = new ServiceRepository;
try {
$repo = new ServiceRepository\Service;
$service = $repo->create($request->only([
'name', 'description', 'file',
'executable', 'startup',
$service = $repo->create($request->intersect([
'name', 'description', 'folder', 'startup',
]));
Alert::success('Successfully created new service!')->flash();
return redirect()->route('admin.services.service', $service->id);
return redirect()->route('admin.services.view', $service->id);
} catch (DisplayValidationException $ex) {
return redirect()->route('admin.services.new')->withErrors(json_decode($ex->getMessage()))->withInput();
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occured while attempting to add a new service.')->flash();
Alert::danger('An error occured while attempting to add a new service. This error has been logged.')->flash();
}
return redirect()->route('admin.services.new')->withInput();
}
public function getService(Request $request, $service)
/**
* Delete a service from the system.
*
* @param Request $request
* @param int $id
* @return \Illuminate\Response\RedirectResponse
*/
public function delete(Request $request, $id)
{
return view('admin.services.view', [
'service' => Models\Service::with('options', 'options.servers')->findOrFail($service),
]);
}
$repo = new ServiceRepository;
public function postService(Request $request, $service)
{
try {
$repo = new ServiceRepository\Service;
$repo->update($service, $request->only([
'name', 'description', 'file',
'executable', 'startup',
]));
Alert::success('Successfully updated this service.')->flash();
} catch (DisplayValidationException $ex) {
return redirect()->route('admin.services.service', $service)->withErrors(json_decode($ex->getMessage()))->withInput();
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occurred while attempting to update this service.')->flash();
}
return redirect()->route('admin.services.service', $service)->withInput();
}
public function deleteService(Request $request, $service)
{
try {
$repo = new ServiceRepository\Service;
$repo->delete($service);
Alert::success('Successfully deleted that service.')->flash();
$repo->delete($id);
Alert::success('Successfully deleted service.')->flash();
return redirect()->route('admin.services');
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error was encountered while attempting to delete that service.')->flash();
Alert::danger('An error was encountered while attempting to delete that service. This error has been logged')->flash();
}
return redirect()->route('admin.services.service', $service);
return redirect()->route('admin.services.view', $id);
}
public function getOption(Request $request, $service, $option)
/**
* Edits configuration for a specific service.
*
* @param Request $request
* @param int $id
* @return \Illuminate\Response\RedirectResponse
*/
public function edit(Request $request, $id)
{
$option = Models\ServiceOption::with('service', 'variables')->findOrFail($option);
$option->setRelation('servers', $option->servers()->with('user')->paginate(25));
$repo = new ServiceRepository;
return view('admin.services.options.view', ['option' => $option]);
}
public function postOption(Request $request, $service, $option)
{
try {
$repo = new ServiceRepository\Option;
$repo->update($option, $request->only([
'name', 'description', 'tag',
'executable', 'docker_image', 'startup',
$repo->update($id, $request->intersect([
'name', 'description', 'folder', 'startup',
]));
Alert::success('Option settings successfully updated.')->flash();
Alert::success('Service has been updated successfully.')->flash();
} catch (DisplayValidationException $ex) {
return redirect()->route('admin.services.option', [$service, $option])->withErrors(json_decode($ex->getMessage()))->withInput();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occured while attempting to modify this option.')->flash();
}
return redirect()->route('admin.services.option', [$service, $option])->withInput();
}
public function deleteOption(Request $request, $service, $option)
{
try {
$repo = new ServiceRepository\Option;
$repo->delete($option);
Alert::success('Successfully deleted that option.')->flash();
return redirect()->route('admin.services.service', $service);
return redirect()->route('admin.services.view', $id)->withErrors(json_decode($ex->getMessage()))->withInput();
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error was encountered while attempting to delete this option.')->flash();
Alert::danger('An error occurred while attempting to update this service. This error has been logged.')->flash();
}
return redirect()->route('admin.services.option', [$service, $option]);
return redirect()->route('admin.services.view', $id);
}
public function postOptionVariable(Request $request, $service, $option, $variable)
{
try {
$repo = new ServiceRepository\Variable;
// Because of the way old() works on the display side we prefix all of the variables with thier ID
// We need to remove that prefix here since the repo doesn't want it.
$data = [
'user_viewable' => '0',
'user_editable' => '0',
'required' => '0',
];
foreach ($request->except(['_token']) as $id => $val) {
$data[str_replace($variable . '_', '', $id)] = $val;
}
$repo->update($variable, $data);
Alert::success('Successfully updated variable.')->flash();
} catch (DisplayValidationException $ex) {
$data = [];
foreach (json_decode($ex->getMessage(), true) as $id => $val) {
$data[$variable . '_' . $id] = $val;
}
return redirect()->route('admin.services.option', [$service, $option])->withErrors((object) $data)->withInput();
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occurred while attempting to update this service.')->flash();
}
return redirect()->route('admin.services.option', [$service, $option])->withInput();
}
public function getNewVariable(Request $request, $service, $option)
{
return view('admin.services.options.variable', [
'option' => Models\ServiceOption::with('service')->findOrFail($option),
]);
}
public function postNewVariable(Request $request, $service, $option)
{
try {
$repo = new ServiceRepository\Variable;
$repo->create($option, $request->only([
'name', 'description', 'env_variable',
'default_value', 'user_viewable',
'user_editable', 'required', 'regex',
]));
Alert::success('Successfully added new variable to this option.')->flash();
return redirect()->route('admin.services.option', [$service, $option]);
} catch (DisplayValidationException $ex) {
return redirect()->route('admin.services.option.variable.new', [$service, $option])->withErrors(json_decode($ex->getMessage()))->withInput();
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occurred while attempting to add this variable.')->flash();
}
return redirect()->route('admin.services.option.variable.new', [$service, $option])->withInput();
}
public function newOption(Request $request, $service)
{
return view('admin.services.options.new', [
'service' => Models\Service::findOrFail($service),
]);
}
public function postNewOption(Request $request, $service)
{
try {
$repo = new ServiceRepository\Option;
$id = $repo->create($service, $request->except([
'_token',
]));
Alert::success('Successfully created new service option.')->flash();
return redirect()->route('admin.services.option', [$service, $id]);
} catch (DisplayValidationException $ex) {
return redirect()->route('admin.services.option.new', $service)->withErrors(json_decode($ex->getMessage()))->withInput();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occured while attempting to add this service option.')->flash();
}
return redirect()->route('admin.services.option.new', $service)->withInput();
}
public function deleteVariable(Request $request, $service, $option, $variable)
{
try {
$repo = new ServiceRepository\Variable;
$repo->delete($variable);
Alert::success('Deleted variable.')->flash();
} catch (DisplayException $ex) {
Alert::danger($ex->getMessage())->flash();
} catch (\Exception $ex) {
Log::error($ex);
Alert::danger('An error occured while attempting to delete that variable.')->flash();
}
return redirect()->route('admin.services.option', [$service, $option]);
}
public function getConfiguration(Request $request, $serviceId)
{
$service = Models\Service::findOrFail($serviceId);
return view('admin.services.config', [
'service' => $service,
'contents' => [
'json' => Storage::get('services/' . $service->file . '/main.json'),
'index' => Storage::get('services/' . $service->file . '/index.js'),
],
]);
}
public function postConfiguration(Request $request, $serviceId)
{
try {
$repo = new ServiceRepository\Service;
$repo->updateFile($serviceId, $request->only(['file', 'contents']));
return response('', 204);
} catch (DisplayException $ex) {
return response()->json([
'error' => $ex->getMessage(),
], 503);
} catch (\Exception $ex) {
Log::error($ex);
return response()->json([
'error' => 'An error occured while attempting to save the file.',
], 503);
}
}
// public function getOption(Request $request, $service, $option)
// {
// $option = Models\ServiceOption::with('service', 'variables')->findOrFail($option);
// $option->setRelation('servers', $option->servers()->with('user')->paginate(25));
//
// return view('admin.services.options.view', ['option' => $option]);
// }
//
// public function postOption(Request $request, $service, $option)
// {
// try {
// $repo = new ServiceRepository\Option;
// $repo->update($option, $request->only([
// 'name', 'description', 'tag',
// 'executable', 'docker_image', 'startup',
// ]));
// Alert::success('Option settings successfully updated.')->flash();
// } catch (DisplayValidationException $ex) {
// return redirect()->route('admin.services.option', [$service, $option])->withErrors(json_decode($ex->getMessage()))->withInput();
// } catch (\Exception $ex) {
// Log::error($ex);
// Alert::danger('An error occured while attempting to modify this option.')->flash();
// }
//
// return redirect()->route('admin.services.option', [$service, $option])->withInput();
// }
//
// public function deleteOption(Request $request, $service, $option)
// {
// try {
// $repo = new ServiceRepository\Option;
// $repo->delete($option);
//
// Alert::success('Successfully deleted that option.')->flash();
//
// return redirect()->route('admin.services.service', $service);
// } catch (DisplayException $ex) {
// Alert::danger($ex->getMessage())->flash();
// } catch (\Exception $ex) {
// Log::error($ex);
// Alert::danger('An error was encountered while attempting to delete this option.')->flash();
// }
//
// return redirect()->route('admin.services.option', [$service, $option]);
// }
//
// public function postOptionVariable(Request $request, $service, $option, $variable)
// {
// try {
// $repo = new ServiceRepository\Variable;
//
// // Because of the way old() works on the display side we prefix all of the variables with thier ID
// // We need to remove that prefix here since the repo doesn't want it.
// $data = [
// 'user_viewable' => '0',
// 'user_editable' => '0',
// 'required' => '0',
// ];
// foreach ($request->except(['_token']) as $id => $val) {
// $data[str_replace($variable . '_', '', $id)] = $val;
// }
// $repo->update($variable, $data);
// Alert::success('Successfully updated variable.')->flash();
// } catch (DisplayValidationException $ex) {
// $data = [];
// foreach (json_decode($ex->getMessage(), true) as $id => $val) {
// $data[$variable . '_' . $id] = $val;
// }
//
// return redirect()->route('admin.services.option', [$service, $option])->withErrors((object) $data)->withInput();
// } catch (DisplayException $ex) {
// Alert::danger($ex->getMessage())->flash();
// } catch (\Exception $ex) {
// Log::error($ex);
// Alert::danger('An error occurred while attempting to update this service.')->flash();
// }
//
// return redirect()->route('admin.services.option', [$service, $option])->withInput();
// }
//
// public function getNewVariable(Request $request, $service, $option)
// {
// return view('admin.services.options.variable', [
// 'option' => Models\ServiceOption::with('service')->findOrFail($option),
// ]);
// }
//
// public function postNewVariable(Request $request, $service, $option)
// {
// try {
// $repo = new ServiceRepository\Variable;
// $repo->create($option, $request->only([
// 'name', 'description', 'env_variable',
// 'default_value', 'user_viewable',
// 'user_editable', 'required', 'regex',
// ]));
// Alert::success('Successfully added new variable to this option.')->flash();
//
// return redirect()->route('admin.services.option', [$service, $option]);
// } catch (DisplayValidationException $ex) {
// return redirect()->route('admin.services.option.variable.new', [$service, $option])->withErrors(json_decode($ex->getMessage()))->withInput();
// } catch (DisplayException $ex) {
// Alert::danger($ex->getMessage())->flash();
// } catch (\Exception $ex) {
// Log::error($ex);
// Alert::danger('An error occurred while attempting to add this variable.')->flash();
// }
//
// return redirect()->route('admin.services.option.variable.new', [$service, $option])->withInput();
// }
//
// public function newOption(Request $request, $service)
// {
// return view('admin.services.options.new', [
// 'service' => Models\Service::findOrFail($service),
// ]);
// }
//
// public function postNewOption(Request $request, $service)
// {
// try {
// $repo = new ServiceRepository\Option;
// $id = $repo->create($service, $request->except([
// '_token',
// ]));
// Alert::success('Successfully created new service option.')->flash();
//
// return redirect()->route('admin.services.option', [$service, $id]);
// } catch (DisplayValidationException $ex) {
// return redirect()->route('admin.services.option.new', $service)->withErrors(json_decode($ex->getMessage()))->withInput();
// } catch (\Exception $ex) {
// Log::error($ex);
// Alert::danger('An error occured while attempting to add this service option.')->flash();
// }
//
// return redirect()->route('admin.services.option.new', $service)->withInput();
// }
//
// public function deleteVariable(Request $request, $service, $option, $variable)
// {
// try {
// $repo = new ServiceRepository\Variable;
// $repo->delete($variable);
// Alert::success('Deleted variable.')->flash();
// } catch (DisplayException $ex) {
// Alert::danger($ex->getMessage())->flash();
// } catch (\Exception $ex) {
// Log::error($ex);
// Alert::danger('An error occured while attempting to delete that variable.')->flash();
// }
//
// return redirect()->route('admin.services.option', [$service, $option]);
// }
//
// public function getConfiguration(Request $request, $serviceId)
// {
// $service = Models\Service::findOrFail($serviceId);
//
// return view('admin.services.config', [
// 'service' => $service,
// 'contents' => [
// 'json' => Storage::get('services/' . $service->file . '/main.json'),
// 'index' => Storage::get('services/' . $service->file . '/index.js'),
// ],
// ]);
// }
//
// public function postConfiguration(Request $request, $serviceId)
// {
// try {
// $repo = new ServiceRepository\Service;
// $repo->updateFile($serviceId, $request->only(['file', 'contents']));
//
// return response('', 204);
// } catch (DisplayException $ex) {
// return response()->json([
// 'error' => $ex->getMessage(),
// ], 503);
// } catch (\Exception $ex) {
// Log::error($ex);
//
// return response()->json([
// 'error' => 'An error occured while attempting to save the file.',
// ], 503);
// }
// }
}

View file

@ -25,35 +25,28 @@
namespace Pterodactyl\Http\Controllers\Daemon;
use Storage;
use Pterodactyl\Models;
use Illuminate\Http\Request;
use Pterodactyl\Models\Service;
use Pterodactyl\Models\ServiceOption;
use Pterodactyl\Http\Controllers\Controller;
class ServiceController extends Controller
{
/**
* Controller Constructor.
*/
public function __construct()
{
//
}
/**
* Returns a listing of all services currently on the system,
* as well as the associated files and the file hashes for
* caching purposes.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
* @return \Illuminate\Http\JsonResponse
*/
public function list(Request $request)
{
$response = [];
foreach (Models\Service::all() as &$service) {
$response[$service->file] = [
'main.json' => sha1_file(storage_path('app/services/' . $service->file . '/main.json')),
'index.js' => sha1_file(storage_path('app/services/' . $service->file . '/index.js')),
foreach (Service::all() as $service) {
$response[$service->folder] = [
'main.json' => sha1($this->getConfiguration($service->id)->toJson()),
'index.js' => sha1_file(storage_path('app/services/' . $service->folder . '/index.js')),
];
}
@ -64,16 +57,45 @@ class ServiceController extends Controller
* Returns the contents of the requested file for the given service.
*
* @param \Illuminate\Http\Request $request
* @param string $service
* @param string $folder
* @param string $file
* @return \Illuminate\Http\Response
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\FileResponse
*/
public function pull(Request $request, $service, $file)
public function pull(Request $request, $folder, $file)
{
if (! Storage::exists('services/' . $service . '/' . $file)) {
return response()->json(['error' => 'No such file.'], 404);
$service = Service::where('folder', $folder)->firstOrFail();
if ($file === 'index.js') {
return response()->file(storage_path('app/services/' . $service->folder . '/index.js'));
} else if ($file === 'main.json') {
return response()->json($this->getConfiguration($service->id));
}
return response()->file(storage_path('app/services/' . $service . '/' . $file));
return abort(404);
}
/**
* Returns a `main.json` file based on the configuration
* of each service option.
*
* @param int $id
* @return \Illuminate\Support\Collection
*/
protected function getConfiguration($id)
{
$options = ServiceOption::where('service_id', $id)->get();
return $options->mapWithKeys(function ($item) use ($options) {
return [
$item->tag => array_filter([
'symlink' => $options->where('id', $item->config_from)->pluck('tag')->pop(),
'startup' => json_decode($item->config_startup),
'stop' => $item->config_stop,
'configs' => json_decode($item->config_files),
'log' => json_decode($item->config_logs),
'query' => 'none',
]),
];
});
}
}

View file

@ -386,128 +386,101 @@ class AdminRoutes
], function () use ($router) {
$router->get('/', [
'as' => 'admin.services',
'uses' => 'Admin\ServiceController@getIndex',
'uses' => 'Admin\ServiceController@index',
]);
$router->get('/new', [
'as' => 'admin.services.new',
'uses' => 'Admin\ServiceController@getNew',
'uses' => 'Admin\ServiceController@new',
]);
$router->post('/new', [
'uses' => 'Admin\ServiceController@postNew',
'uses' => 'Admin\ServiceController@create',
]);
$router->get('/service/{id}', [
'as' => 'admin.services.service',
'uses' => 'Admin\ServiceController@getService',
$router->get('/view/{id}', [
'as' => 'admin.services.view',
'uses' => 'Admin\ServiceController@view',
]);
$router->post('/service/{id}', [
'uses' => 'Admin\ServiceController@postService',
$router->post('/view/{id}', [
'uses' => 'Admin\ServiceController@edit',
]);
$router->delete('/service/{id}', [
'uses' => 'Admin\ServiceController@deleteService',
$router->delete('/view/{id}', [
'uses' => 'Admin\ServiceController@delete',
]);
$router->get('/service/{id}/configuration', [
'as' => 'admin.services.service.config',
'uses' => 'Admin\ServiceController@getConfiguration',
]);
$router->post('/service/{id}/configuration', [
'uses' => 'Admin\ServiceController@postConfiguration',
]);
$router->get('/service/{service}/option/new', [
// ---------------------
// Service Option Routes
// ---------------------
$router->get('/option/new', [
'as' => 'admin.services.option.new',
'uses' => 'Admin\ServiceController@newOption',
'uses' => 'Admin\OptionController@new',
]);
$router->post('/service/{service}/option/new', [
'uses' => 'Admin\ServiceController@postNewOption',
$router->get('/option/{id}', [
'as' => 'admin.services.option.view',
'uses' => 'Admin\OptionController@viewConfiguration',
]);
$router->get('/service/{service}/option/{option}', [
'as' => 'admin.services.option',
'uses' => 'Admin\ServiceController@getOption',
$router->get('/option/{id}/variables', [
'as' => 'admin.services.option.view.variables',
'uses' => 'Admin\OptionController@viewVariables',
]);
$router->post('/service/{service}/option/{option}', [
'uses' => 'Admin\ServiceController@postOption',
$router->post('/option/{id}', [
'uses' => 'Admin\OptionController@editConfiguration',
]);
$router->delete('/service/{service}/option/{id}', [
'uses' => 'Admin\ServiceController@deleteOption',
]);
$router->get('/service/{service}/option/{option}/variable/new', [
'as' => 'admin.services.option.variable.new',
'uses' => 'Admin\ServiceController@getNewVariable',
]);
$router->post('/service/{service}/option/{option}/variable/new', [
'uses' => 'Admin\ServiceController@postNewVariable',
]);
$router->post('/service/{service}/option/{option}/variable/{variable}', [
'as' => 'admin.services.option.variable',
'uses' => 'Admin\ServiceController@postOptionVariable',
]);
$router->get('/service/{service}/option/{option}/variable/{variable}/delete', [
'as' => 'admin.services.option.variable.delete',
'uses' => 'Admin\ServiceController@deleteVariable',
]);
});
// Service Packs
$router->group([
'prefix' => 'admin/services/packs',
'prefix' => 'admin/packs',
'middleware' => [
'auth',
'admin',
'csrf',
],
], function () use ($router) {
$router->get('/new/{option?}', [
'as' => 'admin.services.packs.new',
'uses' => 'Admin\PackController@new',
]);
$router->post('/new', [
'uses' => 'Admin\PackController@create',
]);
$router->get('/upload/{option?}', [
'as' => 'admin.services.packs.uploadForm',
'uses' => 'Admin\PackController@uploadForm',
]);
$router->post('/upload', [
'uses' => 'Admin\PackController@postUpload',
]);
// $router->get('/new/{option?}', [
// 'as' => 'admin.packs.new',
// 'uses' => 'Admin\PackController@new',
// ]);
// $router->post('/new', [
// 'uses' => 'Admin\PackController@create',
// ]);
// $router->get('/upload/{option?}', [
// 'as' => 'admin.packs.uploadForm',
// 'uses' => 'Admin\PackController@uploadForm',
// ]);
// $router->post('/upload', [
// 'uses' => 'Admin\PackController@postUpload',
// ]);
$router->get('/', [
'as' => 'admin.services.packs',
'as' => 'admin.packs',
'uses' => 'Admin\PackController@listAll',
]);
$router->get('/for/option/{option}', [
'as' => 'admin.services.packs.option',
'uses' => 'Admin\PackController@listByOption',
]);
$router->get('/for/service/{service}', [
'as' => 'admin.services.packs.service',
'uses' => 'Admin\PackController@listByService',
]);
$router->get('/edit/{pack}', [
'as' => 'admin.services.packs.edit',
'uses' => 'Admin\PackController@edit',
]);
$router->post('/edit/{pack}', [
'uses' => 'Admin\PackController@update',
]);
$router->get('/edit/{pack}/export/{archive?}', [
'as' => 'admin.services.packs.export',
'uses' => 'Admin\PackController@export',
]);
// $router->get('/for/option/{option}', [
// 'as' => 'admin.packs.option',
// 'uses' => 'Admin\PackController@listByOption',
// ]);
// $router->get('/for/service/{service}', [
// 'as' => 'admin.packs.service',
// 'uses' => 'Admin\PackController@listByService',
// ]);
// $router->get('/edit/{pack}', [
// 'as' => 'admin.packs.edit',
// 'uses' => 'Admin\PackController@edit',
// ]);
// $router->post('/edit/{pack}', [
// 'uses' => 'Admin\PackController@update',
// ]);
// $router->get('/edit/{pack}/export/{archive?}', [
// 'as' => 'admin.packs.export',
// 'uses' => 'Admin\PackController@export',
// ]);
});
}
}

View file

@ -0,0 +1,80 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Pterodactyl\Repositories;
use DB;
use Validator;
use Pterodactyl\Models\ServiceOption;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Exceptions\DisplayValidationException;
class OptionRepository
{
/**
* Updates a service option in the database which can then be used
* on nodes.
*
* @param int $id
* @param array $data
* @return \Pterodactyl\Models\ServiceOption
*/
public function update($id, array $data)
{
$option = ServiceOption::findOrFail($id);
$validator = Validator::make($data, [
'name' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string',
'tag' => 'sometimes|required|string|max:255|unique:service_options,tag,' . $option->id,
'docker_image' => 'sometimes|required|string|max:255',
'startup' => 'sometimes|required|string',
'config_from' => 'sometimes|required|numeric|exists:service_options,id',
]);
$validator->sometimes('config_startup', 'required_without:config_from|json', function ($input) use ($option) {
return ! (! $input->config_from && ! is_null($option->config_from));
});
$validator->sometimes('config_stop', 'required_without:config_from|string|max:255', function ($input) use ($option) {
return ! (! $input->config_from && ! is_null($option->config_from));
});
$validator->sometimes('config_logs', 'required_without:config_from|json', function ($input) use ($option) {
return ! (! $input->config_from && ! is_null($option->config_from));
});
$validator->sometimes('config_files', 'required_without:config_from|json', function ($input) use ($option) {
return ! (! $input->config_from && ! is_null($option->config_from));
});
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
$option->fill($data)->save();
return $option;
}
}

View file

@ -22,7 +22,7 @@
* SOFTWARE.
*/
namespace Pterodactyl\Repositories\ServiceRepository;
namespace Pterodactyl\Repositories;
use DB;
use Uuid;
@ -33,7 +33,7 @@ use Pterodactyl\Services\UuidService;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Exceptions\DisplayValidationException;
class Pack
class PackRepository
{
public function __construct()
{

View file

@ -0,0 +1,178 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Pterodactyl\Repositories;
use DB;
use Uuid;
use Storage;
use Validator;
use Pterodactyl\Models\Service;
use Pterodactyl\Models\ServiceVariable;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Exceptions\DisplayValidationException;
class ServiceRepository
{
/**
* Creates a new service on the system.
*
* @param array $data
* @return \Pterodactyl\Models\Service
*/
public function create(array $data)
{
$validator = Validator::make($data, [
'name' => 'required|string|min:1|max:255',
'description' => 'required|nullable|string',
'folder' => 'required|unique:services,folder|regex:/^[\w.-]{1,50}$/',
'startup' => 'required|nullable|string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
$service = DB::transaction(function () use ($data) {
$service = Service::create([
'author' => config('pterodactyl.service.author'),
'name' => $data['name'],
'description' => (isset($data['description'])) ? $data['description'] : null,
'folder' => $data['folder'],
'startup' => (isset($data['startup'])) ? $data['startup'] : null,
]);
// It is possible for an event to return false or throw an exception
// which won't necessarily be detected by this transaction.
//
// This check ensures the model was actually saved.
if (! $service->exists) {
throw new \Exception('Service model was created however the response appears to be invalid. Did an event fire wrongly?');
}
Storage::copy('services/.templates/index.js', 'services/' . $service->folder . '/index.js');
return $service;
});
return $service;
}
/**
* Updates a service.
*
* @param int $id
* @param array $data
* @return \Pterodactyl\Models\Service
*/
public function update($id, array $data)
{
$service = Service::findOrFail($id);
$validator = Validator::make($data, [
'name' => 'sometimes|required|string|min:1|max:255',
'description' => 'sometimes|required|nullable|string',
'folder' => 'sometimes|required|regex:/^[\w.-]{1,50}$/',
'startup' => 'sometimes|required|nullable|string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
return DB::transaction(function () use ($data, $service) {
$moveFiles = (isset($data['folder']) && $data['folder'] !== $service->folder);
$oldFolder = $service->folder;
$service->fill($data);
$service->save();
if ($moveFiles) {
Storage::move(sprintf('services/%s/index.js', $oldFolder), sprintf('services/%s/index.js', $service->folder));
}
return $service;
});
}
/**
* Deletes a service and associated files and options.
*
* @param int $id
* @return void
*/
public function delete($id)
{
$service = Service::withCount('servers', 'options')->findOrFail($id);
if ($service->servers_count > 0) {
throw new DisplayException('You cannot delete a service that has servers associated with it.');
}
DB::transaction(function () use ($service) {
ServiceVariable::whereIn('option_id', $service->options->pluck('id')->all())->delete();
$service->options->each(function ($item) {
$item->delete();
});
$service->delete();
Storage::deleteDirectory('services/' . $service->folder);
});
}
/**
* Updates a service file on the system.
*
* @param int $id
* @param array $data
* @return void
*
* @deprecated
*/
// public function updateFile($id, array $data)
// {
// $service = Service::findOrFail($id);
//
// $validator = Validator::make($data, [
// 'file' => 'required|in:index',
// 'contents' => 'required|string',
// ]);
//
// if ($validator->fails()) {
// throw new DisplayValidationException($validator->errors());
// }
//
// $filepath = 'services/' . $service->folder . '/' . $filename;
// $backup = 'services/.bak/' . str_random(12) . '.bak';
//
// try {
// Storage::move($filepath, $backup);
// Storage::put($filepath, $data['contents']);
// } catch (\Exception $ex) {
// Storage::move($backup, $filepath);
// throw $ex;
// }
// }
}

View file

@ -1,124 +0,0 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Pterodactyl\Repositories\ServiceRepository;
use DB;
use Validator;
use Pterodactyl\Models;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Exceptions\DisplayValidationException;
class Option
{
public function __construct()
{
//
}
public function create($service, array $data)
{
$service = Models\Service::findOrFail($service);
$validator = Validator::make($data, [
'name' => 'required|string|max:255',
'description' => 'required|string|min:1',
'tag' => 'required|string|max:255',
'executable' => 'sometimes|string|max:255',
'docker_image' => 'required|string|max:255',
'startup' => 'sometimes|string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
if (isset($data['executable']) && empty($data['executable'])) {
$data['executable'] = null;
}
if (isset($data['startup']) && empty($data['startup'])) {
$data['startup'] = null;
}
$option = new Models\ServiceOption;
$option->service_id = $service->id;
$option->fill($data);
$option->save();
return $option->id;
}
public function delete($id)
{
$option = Models\ServiceOption::findOrFail($id);
$servers = Models\Server::where('option', $option->id)->get();
if (count($servers) !== 0) {
throw new DisplayException('You cannot delete an option that has servers attached to it currently.');
}
DB::beginTransaction();
try {
Models\ServiceVariable::where('option_id', $option->id)->delete();
$option->delete();
DB::commit();
} catch (\Exception $ex) {
DB::rollBack();
throw $ex;
}
}
public function update($id, array $data)
{
$option = Models\ServiceOption::findOrFail($id);
$validator = Validator::make($data, [
'name' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string|min:1',
'tag' => 'sometimes|required|string|max:255',
'executable' => 'sometimes|string|max:255',
'docker_image' => 'sometimes|required|string|max:255',
'startup' => 'sometimes|string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
if (isset($data['executable']) && empty($data['executable'])) {
$data['executable'] = null;
}
if (isset($data['startup']) && empty($data['startup'])) {
$data['startup'] = null;
}
$option->fill($data);
return $option->save();
}
}

View file

@ -1,144 +0,0 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Pterodactyl\Repositories\ServiceRepository;
use DB;
use Uuid;
use Storage;
use Validator;
use Pterodactyl\Models;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Exceptions\DisplayValidationException;
class Service
{
public function __construct()
{
//
}
public function create(array $data)
{
$validator = Validator::make($data, [
'name' => 'required|string|min:1|max:255',
'description' => 'required|string',
'file' => 'required|unique:services,file|regex:/^[\w.-]{1,50}$/',
'executable' => 'max:255|regex:/^(.*)$/',
'startup' => 'string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
DB::beginTransaction();
try {
$service = new Models\Service;
$service->author = env('SERVICE_AUTHOR', (string) Uuid::generate(4));
$service->fill($data);
$service->save();
Storage::put('services/' . $service->file . '/main.json', '{}');
Storage::copy('services/.templates/index.js', 'services/' . $service->file . '/index.js');
DB::commit();
} catch (\Exception $ex) {
DB::rollBack();
throw $ex;
}
return $service;
}
public function update($id, array $data)
{
$service = Models\Service::findOrFail($id);
$validator = Validator::make($data, [
'name' => 'sometimes|required|string|min:1|max:255',
'description' => 'sometimes|required|string',
'file' => 'sometimes|required|regex:/^[\w.-]{1,50}$/',
'executable' => 'sometimes|max:255|regex:/^(.*)$/',
'startup' => 'sometimes|string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
$service->fill($data);
return $service->save();
}
public function delete($id)
{
$service = Models\Service::findOrFail($id);
$servers = Models\Server::where('service', $service->id)->get();
$options = Models\ServiceOption::select('id')->where('service_id', $service->id);
if (count($servers) !== 0) {
throw new DisplayException('You cannot delete a service that has servers associated with it.');
}
DB::beginTransaction();
try {
Models\ServiceVariable::whereIn('option_id', $options->get()->toArray())->delete();
$options->delete();
$service->delete();
Storage::deleteDirectory('services/' . $service->file);
DB::commit();
} catch (\Exception $ex) {
DB::rollBack();
throw $ex;
}
}
public function updateFile($id, array $data)
{
$service = Models\Service::findOrFail($id);
$validator = Validator::make($data, [
'file' => 'required|in:index,main',
'contents' => 'required|string',
]);
if ($validator->fails()) {
throw new DisplayValidationException($validator->errors());
}
$filename = ($data['file'] === 'main') ? 'main.json' : 'index.js';
$filepath = 'services/' . $service->file . '/' . $filename;
$backup = 'services/.bak/' . str_random(12) . '.bak';
try {
Storage::move($filepath, $backup);
Storage::put($filepath, $data['contents']);
} catch (\Exception $ex) {
Storage::move($backup, $filepath);
throw $ex;
}
}
}

View file

@ -22,7 +22,7 @@
* SOFTWARE.
*/
namespace Pterodactyl\Repositories\ServiceRepository;
namespace Pterodactyl\Repositories;
use DB;
use Validator;
@ -30,7 +30,7 @@ use Pterodactyl\Models;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Exceptions\DisplayValidationException;
class Variable
class VariableRepository
{
public function __construct()
{

18
config/pterodactyl.php Normal file
View file

@ -0,0 +1,18 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Service Author
|--------------------------------------------------------------------------
|
| Each panel installation is assigned a unique UUID to identify the
| author of custom services, and make upgrades easier by identifying
| standard Pterodactyl shipped services.
*/
'service' => [
'author' => env('SERVICE_AUTHOR'),
],
];

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class DeleteServiceExecutableOption extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('services', function (Blueprint $table) {
$table->dropColumn('executable');
$table->renameColumn('file', 'folder');
$table->text('description')->nullable()->change();
$table->text('startup')->nullable()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('services', function (Blueprint $table) {
$table->string('executable')->after('folder');
$table->renameColumn('folder', 'file');
$table->text('description')->nullable(false)->change();
$table->text('startup')->nullable(false)->change();
});
}
}

View file

@ -0,0 +1,52 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddNewServiceOptionsColumns extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::transaction(function() {
Schema::table('service_options', function (Blueprint $table) {
$table->dropColumn('executable');
$table->unsignedInteger('config_from')->nullable()->after('docker_image');
$table->string('config_stop')->nullable()->after('docker_image');
$table->text('config_logs')->nullable()->after('docker_image');
$table->text('config_startup')->nullable()->after('docker_image');
$table->text('config_files')->nullable()->after('docker_image');
$table->foreign('config_from')->references('id')->on('service_options');
});
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::transaction(function() {
Schema::table('service_options', function (Blueprint $table) {
$table->dropForeign('config_from');
$table->dropColumn('config_from');
$table->dropColumn('config_stop');
$table->dropColumn('config_logs');
$table->dropColumn('config_startup');
$table->dropColumn('config_files');
$table->string('executable')->after('docker_image')->nullable();
});
});
}
}

View file

@ -0,0 +1,223 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
use Pterodactyl\Models\Service;
use Pterodactyl\Models\ServiceOption;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class MigrateToNewServiceSystem extends Migration
{
protected $services;
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$this->services = Service::where('author', 'ptrdctyl-v040-11e6-8b77-86f30ca893d3')->get();
$this->minecraft();
$this->srcds();
$this->terraria();
$this->voice();
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// Not doing reversals right now...
}
public function minecraft()
{
$service = $this->services->where('folder', 'minecraft')->first();
if (! $service) {
return;
}
// Set New Default Startup
$service->startup = 'java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}';
DB::transaction(function () use ($service) {
$options = ServiceOption::where('service_id', $service->id)->get();
$options->each(function ($item) use ($options) {
switch($item->tag) {
case 'vanilla':
$item->config_startup = '{"done": ")! For help, type ", "userInteraction": [ "Go to eula.txt for more info."]}';
$item->config_files = '{"server.properties":{"parser": "properties", "find":{"server-ip": "0.0.0.0", "enable-query": "true", "server-port": "{{server.build.default.port}}", "query.port": "{{server.build.default.port}}"}}}';
$item->config_logs = '{"custom": false, "location": "logs/latest.log"}';
$item->config_stop = 'stop';
break;
case 'spigot':
$item->startup = null;
$item->config_from = $options->where('tag', 'vanilla')->pluck('id')->pop();
$item->config_files = '{"spigot.yml":{"parser": "yaml", "find":{"settings.restart-on-crash": "false"}}}';
break;
case 'bungeecord':
$item->config_startup = '{"done": "Listening on ", "userInteraction": [ "Listening on /0.0.0.0:25577"]}';
$item->config_files = '{"config.yml":{"parser": "yaml", "find":{"listeners[0].query_enabled": true, "listeners[0].query_port": "{{server.build.default.port}}", "listeners[0].host": "0.0.0.0:{{server.build.default.port}}", "servers.*.address":{"127.0.0.1": "{{config.docker.interface}}", "localhost": "{{config.docker.interface}}"}}}}';
$item->config_logs = '{"custom": false, "location": "proxy.log.0"}';
$item->config_stop = 'end';
break;
case 'sponge':
$item->startup = null;
$item->config_from = $options->where('tag', 'vanilla')->pluck('id')->pop();
$item->config_startup = '{"userInteraction": [ "You need to agree to the EULA"]}';
break;
default:
break;
}
$item->save();
});
$service->save();
});
}
public function srcds()
{
$service = $this->services->where('folder', 'srcds')->first();
if (! $service) {
return;
}
$service->startup = './srcds_run -game {{SRCDS_GAME}} -console -port {{SERVER_PORT}} +ip 0.0.0.0 -strictportbind -norestart';
DB::transaction(function () use ($service) {
$options = ServiceOption::where('service_id', $service->id)->get();
$options->each(function ($item) use ($options) {
if ($item->tag === 'srcds' && $item->name === 'Insurgency') {
$item->tag = 'insurgency';
} else if ($item->tag === 'srcds' && $item->name === 'Team Fortress 2') {
$item->tag = 'tf2';
} else if ($item->tag === 'srcds' && $item->name === 'Custom Source Engine Game') {
$item->tag = 'source';
}
switch($item->tag) {
case 'source':
$item->config_startup = '{"done": "Assigned anonymous gameserver Steam ID", "userInteraction": []}';
$item->config_files = '{}';
$item->config_logs = '{"custom": true, "location": "logs/latest.log"}';
$item->config_stop = 'quit';
break;
case 'insurgency':
case 'tf2':
$item->startup = './srcds_run -game {{SRCDS_GAME}} -console -port {{SERVER_PORT}} +map {{SRCDS_MAP}} +ip 0.0.0.0 -strictportbind -norestart';
$item->config_from = $options->where('name', 'Custom Source Engine Game')->pluck('id')->pop();
break;
case 'ark':
$item->startup = './ShooterGame/Binaries/Linux/ShooterGameServer TheIsland?listen?ServerPassword={{ARK_PASSWORD}}?ServerAdminPassword={{ARK_ADMIN_PASSWORD}}?Port={{SERVER_PORT}}?MaxPlayers={{SERVER_MAX_PLAYERS}}';
$item->config_from = $options->where('name', 'Custom Source Engine Game')->pluck('id')->pop();
$item->config_startup = '{"done": "Setting breakpad minidump AppID"}';
$item->config_stop = '^C';
break;
default:
break;
}
$item->save();
});
$service->save();
});
}
public function terraria()
{
$service = $this->services->where('folder', 'terraria')->first();
if (! $service) {
return;
}
$service->startup = 'mono TerrariaServer.exe -port {{SERVER_PORT}} -autocreate 2 -worldname World';
DB::transaction(function () use ($service) {
$options = ServiceOption::where('service_id', $service->id)->get();
$options->each(function ($item) use ($options) {
switch($item->tag) {
case 'tshock':
$item->startup = null;
$item->config_startup = '{"done": "Type \'help\' for a list of commands", "userInteraction": []}';
$item->config_files = '{"tshock/config.json":{"parser": "json", "find":{"ServerPort": "{{server.build.default.port}}", "MaxSlots": "{{server.build.env.MAX_SLOTS}}"}}}';
$item->config_logs = '{"custom": false, "location": "ServerLog.txt"}';
$item->config_stop = 'exit';
break;
default:
break;
}
$item->save();
});
$service->save();
});
}
public function voice()
{
$service = $this->services->where('folder', 'voice')->first();
if (! $service) {
return;
}
$service->startup = null;
DB::transaction(function () use ($service) {
$options = ServiceOption::where('service_id', $service->id)->get();
$options->each(function ($item) use ($options) {
switch($item->tag) {
case 'mumble':
$item->startup = './murmur.x86 -fg';
$item->config_startup = '{"done": "Server listening on", "userInteraction": [ "Generating new server certificate"]}';
$item->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}}"}}}';
$item->config_logs = '{"custom": true, "location": "logs/murmur.log"}';
$item->config_stop = '^C';
break;
case 'ts3':
$item->startup = './ts3server_minimal_runscript.sh default_voice_port={{SERVER_PORT}} query_port={{SERVER_PORT}}';
$item->config_startup = '{"done": "listening on 0.0.0.0:", "userInteraction": []}';
$item->config_files = '{"ts3server.ini":{"parser": "ini", "find":{"default_voice_port": "{{server.build.default.port}}", "voice_ip": "0.0.0.0", "query_port": "{{server.build.default.port}}", "query_ip": "0.0.0.0"}}}';
$item->config_logs = '{"custom": true, "location": "logs/ts3.log"}';
$item->config_stop = '^C';
break;
default:
break;
}
$item->save();
});
$service->save();
});
}
}

View file

@ -0,0 +1,67 @@
{{-- Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com> --}}
{{-- Permission is hereby granted, free of charge, to any person obtaining a copy --}}
{{-- of this software and associated documentation files (the "Software"), to deal --}}
{{-- in the Software without restriction, including without limitation the rights --}}
{{-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell --}}
{{-- copies of the Software, and to permit persons to whom the Software is --}}
{{-- furnished to do so, subject to the following conditions: --}}
{{-- The above copyright notice and this permission notice shall be included in all --}}
{{-- copies or substantial portions of the Software. --}}
{{-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR --}}
{{-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, --}}
{{-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE --}}
{{-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER --}}
{{-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, --}}
{{-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE --}}
{{-- SOFTWARE. --}}
@extends('layouts.admin')
@section('title')
Services
@endsection
@section('content-header')
<h1>Services<small>All services currently available on this system.</small></h1>
<ol class="breadcrumb">
<li><a href="{{ route('admin.index') }}">Admin</a></li>
<li class="active">Services</li>
</ol>
@endsection
@section('content')
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Configured Services</h3>
</div>
<div class="box-body table-responsive no-padding">
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Description</th>
<th class="text-center">Options</th>
<th class="text-center">Packs</th>
<th class="text-center">Servers</th>
</tr>
@foreach($services as $service)
<tr>
<td class="middle"><a href="{{ route('admin.services.view', $service->id) }}">{{ $service->name }}</a></td>
<td class="col-xs-6 middle">{{ $service->description }}</td>
<td class="text-center middle"><code>{{ $service->options_count }}</code></td>
<td class="text-center middle"><code>{{ $service->packs_count }}</code></td>
<td class="text-center middle"><code>{{ $service->servers_count }}</code></td>
</tr>
@endforeach
</table>
</div>
<div class="box-footer">
<a href="{{ route('admin.services.new') }}"><button class="btn btn-primary btn-sm pull-right">Create Service</button></a>
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,86 @@
{{-- Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com> --}}
{{-- Permission is hereby granted, free of charge, to any person obtaining a copy --}}
{{-- of this software and associated documentation files (the "Software"), to deal --}}
{{-- in the Software without restriction, including without limitation the rights --}}
{{-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell --}}
{{-- copies of the Software, and to permit persons to whom the Software is --}}
{{-- furnished to do so, subject to the following conditions: --}}
{{-- The above copyright notice and this permission notice shall be included in all --}}
{{-- copies or substantial portions of the Software. --}}
{{-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR --}}
{{-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, --}}
{{-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE --}}
{{-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER --}}
{{-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, --}}
{{-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE --}}
{{-- SOFTWARE. --}}
@extends('layouts.admin')
@section('title')
New Service
@endsection
@section('content-header')
<h1>New Service<small>Configure a new service to deploy to all nodes.</small></h1>
<ol class="breadcrumb">
<li><a href="{{ route('admin.index') }}">Admin</a></li>
<li><a href="{{ route('admin.services') }}">Services</a></li>
<li class="active">New</li>
</ol>
@endsection
@section('content')
<form action="{{ route('admin.services.new') }}" method="POST">
<div class="row">
<div class="col-xs-6">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">New Service</h3>
</div>
<div class="box-body">
<div class="form-group">
<label class="control-label">Name</label>
<div>
<input type="text" name="name" class="form-control" value="{{ old('name') }}" />
<p class="text-muted"><small>This should be a descriptive category name that emcompasses all of the options within the service.</small></p>
</div>
</div>
<div class="form-group">
<label class="control-label">Description</label>
<div>
<textarea name="description" class="form-control" rows="6">{{ old('description') }}</textarea>
</div>
</div>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="box">
<div class="box-body">
<div class="form-group">
<label class="control-label">Folder Name</label>
<div>
<input type="text" name="folder" class="form-control" value="{{ old('folder') }}" />
<p class="text-muted"><small>Services are downloaded by the daemon and stored in a folder using this name. The storage location is <code>/srv/daemon/services/{NAME}</code> by default.</small></p>
</div>
</div>
<div class="form-group">
<label class="control-label">Default Start Command</label>
<div>
<textarea name="startup" class="form-control" rows="2">{{ old('startup') }}</textarea>
<p class="text-muted"><small>The default start command to use when running options under this service. This command can be modified per-option and should include the executable to be called in the container.</small></p>
</div>
</div>
</div>
<div class="box-footer">
{!! csrf_field() !!}
<button type="input" class="btn btn-primary pull-right">Save Service</button>
</div>
</div>
</div>
</div>
</form>
@endsection

View file

@ -0,0 +1,162 @@
{{-- Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com> --}}
{{-- Permission is hereby granted, free of charge, to any person obtaining a copy --}}
{{-- of this software and associated documentation files (the "Software"), to deal --}}
{{-- in the Software without restriction, including without limitation the rights --}}
{{-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell --}}
{{-- copies of the Software, and to permit persons to whom the Software is --}}
{{-- furnished to do so, subject to the following conditions: --}}
{{-- The above copyright notice and this permission notice shall be included in all --}}
{{-- copies or substantial portions of the Software. --}}
{{-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR --}}
{{-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, --}}
{{-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE --}}
{{-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER --}}
{{-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, --}}
{{-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE --}}
{{-- SOFTWARE. --}}
@extends('layouts.admin')
@section('title')
Services &rarr; Option: {{ $option->name }}
@endsection
@section('content-header')
<h1>{{ $option->name }}<small>{{ str_limit($option->description, 50) }}</small></h1>
<ol class="breadcrumb">
<li><a href="{{ route('admin.index') }}">Admin</a></li>
<li><a href="{{ route('admin.services') }}">Services</a></li>
<li><a href="{{ route('admin.services.view', $option->service->id) }}">{{ $option->service->name }}</a></li>
<li class="active">{{ $option->name }}</li>
</ol>
@endsection
@section('content')
<div class="row">
<div class="col-xs-12">
<div class="nav-tabs-custom nav-tabs-floating">
<ul class="nav nav-tabs">
<li class="active"><a href="{{ route('admin.services.option.view', $option->id) }}">Configuration</a></li>
<li><a href="{{ route('admin.services.option.view.variables', $option->id) }}">Variables</a></li>
</ul>
</div>
</div>
</div>
<form action="{{ route('admin.services.option.view', $option->id) }}" method="POST">
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Configuration</h3>
</div>
<div class="box-body">
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="pName" class="form-label">Option Name</label>
<input type="text" id="pName" name="name" value="{{ $option->name }}" class="form-control" />
<p class="text-muted small">A simple, human-readable name to use as an identifier for this service.</p>
</div>
<div class="form-group">
<label for="pDescription" class="form-label">Description</label>
<textarea id="pDescription" name="description" class="form-control" rows="10">{{ $option->description }}</textarea>
<p class="text-muted small">A description of this service that will be displayed throughout the panel as needed.</p>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="pTag" class="form-label">Option Tag</label>
<input type="text" id="pTag" name="tag" value="{{ $option->tag }}" class="form-control" />
<p class="text-muted small">This should be a unique identifer for this service option that is not used for any other service options.</p>
</div>
<div class="form-group">
<label for="pDockerImage" class="form-label">Docker Image</label>
<input type="text" id="pDockerImage" name="docker_image" value="{{ $option->docker_image }}" class="form-control" />
<p class="text-muted small">The default docker image that should be used for new servers under this service option. This can be left blank to use the parent service's defined image, and can also be changed per-server.</p>
</div>
<div class="form-group">
<label for="pStartup" class="form-label">Startup Command</label>
<textarea id="pStartup" name="startup" class="form-control" rows="4" placeholder="{{ $option->service->startup }}">{{ $option->startup }}</textarea>
<p class="text-muted small">The default statup command that should be used for new servers under this service option. This can be left blank to use the parent service's startup, and can also be changed per-server.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Process Management</h3>
</div>
<div class="box-body">
<div class="row">
<div class="col-xs-12">
<div class="alert alert-warning">
<p>The following configuration options should not be edited unless you understand how this system works. If wrongly modified it is possible for the daemon to break.</p>
<p>All fields are required unless you select a seperate option from the 'Copy Settings From' dropdown, in which case fields may be left blank to use the values from that option.</p>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="pConfigFrom" class="form-label">Copy Settings From</label>
<select name="config_from" id="pConfigFrom" class="form-control">
<option value="0">None</option>
@foreach($option->service->options as $o)
<option value="{{ $o->id }}" {{ ($option->config_from !== $o->id) ?: 'selected' }}>{{ $o->name }}</option>
@endforeach
</select>
<p class="text-muted small">If you would like to default to settings from another option select the option from the menu above.</p>
</div>
<div class="form-group">
<label for="pConfigStop" class="form-label">Stop Command</label>
<input type="text" id="pConfigStop" name="config_stop" class="form-control" value="{{ $option->config_stop }}" />
<p class="text-muted small">The command that should be sent to server processes to stop them gracefully. If you need to send a <code>SIGINT</code> you should enter <code>^C</code> here.</p>
</div>
<div class="form-group">
<label for="pConfigLogs" class="form-label">Log Configuration</label>
<textarea data-action="handle-tabs" id="pConfigLogs" name="config_logs" class="form-control" rows="6">{{ ! is_null($option->config_logs) ? json_encode(json_decode($option->config_logs), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '' }}</textarea>
<p class="text-muted small">This should be a JSON representation of where log files are stored, and wether or not the daemon should be creating custom logs.</p>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="pConfigFiles" class="form-label">Configuration Files</label>
<textarea data-action="handle-tabs" id="pConfigFiles" name="config_files" class="form-control" rows="6">{{ ! is_null($option->config_files) ? json_encode(json_decode($option->config_files), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '' }}</textarea>
<p class="text-muted small">This should be a JSON representation of configuration files to modify and what parts should be changed.</p>
</div>
<div class="form-group">
<label for="pConfigStartup" class="form-label">Start Configuration</label>
<textarea data-action="handle-tabs" id="pConfigStartup" name="config_startup" class="form-control" rows="6">{{ ! is_null($option->config_startup) ? json_encode(json_decode($option->config_startup), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '' }}</textarea>
<p class="text-muted small">This should be a JSON representation of what values the daemon should be looking for when booting a server to determine completion.</p>
</div>
</div>
</div>
<div class="box-footer">
{!! csrf_field() !!}
<button type="submit" class="btn btn-primary btn-sm pull-right">Edit Service</button>
</div>
</div>
</div>
</div>
</form>
@endsection
@section('footer-scripts')
@parent
<script>
$('textarea[data-action="handle-tabs"]').on('keydown', function(event) {
if (event.keyCode === 9) {
event.preventDefault();
var curPos = $(this)[0].selectionStart;
var prepend = $(this).val().substr(0, curPos);
var append = $(this).val().substr(curPos);
$(this).val(prepend + ' ' + append);
}
});
</script>
@endsection

View file

@ -0,0 +1,113 @@
{{-- Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com> --}}
{{-- Permission is hereby granted, free of charge, to any person obtaining a copy --}}
{{-- of this software and associated documentation files (the "Software"), to deal --}}
{{-- in the Software without restriction, including without limitation the rights --}}
{{-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell --}}
{{-- copies of the Software, and to permit persons to whom the Software is --}}
{{-- furnished to do so, subject to the following conditions: --}}
{{-- The above copyright notice and this permission notice shall be included in all --}}
{{-- copies or substantial portions of the Software. --}}
{{-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR --}}
{{-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, --}}
{{-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE --}}
{{-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER --}}
{{-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, --}}
{{-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE --}}
{{-- SOFTWARE. --}}
@extends('layouts.admin')
@section('title')
Services: {{ $service->name }}
@endsection
@section('content-header')
<h1>{{ $service->name }}<small>{{ str_limit($service->description, 50) }}</small></h1>
<ol class="breadcrumb">
<li><a href="{{ route('admin.index') }}">Admin</a></li>
<li><a href="{{ route('admin.services') }}">Services</a></li>
<li class="active">{{ $service->name }}</li>
</ol>
@endsection
@section('content')
<form action="{{ route('admin.services.view', $service->id) }}" method="POST">
<div class="row">
<div class="col-xs-6">
<div class="box">
<div class="box-body">
<div class="form-group">
<label class="control-label">Name</label>
<div>
<input type="text" name="name" class="form-control" value="{{ $service->name }}" />
<p class="text-muted"><small>This should be a descriptive category name that emcompasses all of the options within the service.</small></p>
</div>
</div>
<div class="form-group">
<label class="control-label">Description</label>
<div>
<textarea name="description" class="form-control" rows="6">{{ $service->description }}</textarea>
</div>
</div>
</div>
</div>
</div>
<div class="col-xs-6">
<div class="box">
<div class="box-body">
<div class="form-group">
<label class="control-label">Folder Name</label>
<div>
<input type="text" name="folder" class="form-control" value="{{ $service->folder }}" />
<p class="text-muted"><small>Services are downloaded by the daemon and stored in a folder using this name. The storage location is <code>/srv/daemon/services/{NAME}</code> by default.</small></p>
</div>
</div>
<div class="form-group">
<label class="control-label">Default Start Command</label>
<div>
<textarea name="startup" class="form-control" rows="2">{{ $service->startup }}</textarea>
<p class="text-muted"><small>The default start command to use when running options under this service. This command can be modified per-option and should include the executable to be called in the container.</small></p>
</div>
</div>
</div>
<div class="box-footer">
{!! csrf_field() !!}
<button type="input" class="btn btn-primary btn-sm pull-right">Edit Service</button>
</div>
</div>
</div>
</div>
</form>
<div class="row">
<div class="col-xs-12">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">Configured Options</h3>
</div>
<div class="box-body table-responsive no-padding">
<table class="table table-hover">
<tr>
<th class="col-sm-4 col-md-3">Name</th>
<th>Description</th>
<th>Tag</th>
<th class="text-center">Servers</th>
</tr>
@foreach($service->options as $option)
<tr>
<td><a href="{{ route('admin.services.option.view', $option->id) }}">{{ $option->name }}</a></td>
<td>{!! $option->description !!}</td>
<td><code>{{ $option->tag }}</code></td>
<td class="text-center">{{ $option->servers->count() }}</td>
</tr>
@endforeach
</table>
</div>
<div class="box-footer">
<a href="{{ route('admin.services.option.new') }}"><button class="btn btn-success btn-sm pull-right">New Service Option</button></a>
</div>
</div>
</div>
</div>
@endsection

View file

@ -106,6 +106,17 @@
<i class="fa fa-users"></i> <span>Users</span>
</a>
</li>
<li class="header">SERVICE MANAGEMENT</li>
<li class="{{ ! starts_with(Route::currentRouteName(), 'admin.services') ?: 'active' }}">
<a href="{{ route('admin.services') }}">
<i class="fa fa-th-large"></i> <span>Services</span>
</a>
</li>
<li class="{{ ! starts_with(Route::currentRouteName(), 'admin.packs') ?: 'active' }}">
<a href="{{ route('admin.packs') }}">
<i class="fa fa-archive"></i> <span>Packs</span>
</a>
</li>
</ul>
</section>
</aside>