ui(admin): new egg page

This commit is contained in:
Matthew Penner 2021-10-23 13:13:25 -06:00
parent 336923ec18
commit cddf2ce41c
No known key found for this signature in database
GPG key ID: BAB67850901908A8
15 changed files with 210 additions and 84 deletions

View file

@ -2,10 +2,12 @@
namespace Pterodactyl\Http\Controllers\Api\Application\Eggs;
use Ramsey\Uuid\Uuid;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\Nest;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Spatie\QueryBuilder\QueryBuilder;
use Pterodactyl\Transformers\Api\Application\EggTransformer;
use Pterodactyl\Http\Requests\Api\Application\Eggs\GetEggRequest;
@ -56,7 +58,16 @@ class EggController extends ApplicationApiController
*/
public function store(StoreEggRequest $request): JsonResponse
{
$egg = Egg::query()->create($request->validated());
$validated = $request->validated();
Log::info(json_encode($validated, JSON_PRETTY_PRINT));
$merged = array_merge($validated, [
'uuid' => Uuid::uuid4()->toString(),
// TODO: allow this to be set in the request, and default to config value if null or not present.
'author' => config('pterodactyl.service.author'),
]);
Log::info(json_encode($merged, JSON_PRETTY_PRINT));
$egg = Egg::query()->create($merged);
return $this->fractal->item($egg)
->transformWith(EggTransformer::class)

View file

@ -2,13 +2,29 @@
namespace Pterodactyl\Http\Requests\Api\Application\Eggs;
use Pterodactyl\Models\Egg;
use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreEggRequest extends ApplicationApiRequest
{
public function rules(array $rules = null): array
{
return $rules ?? Egg::getRules();
return [
'nest_id' => 'required|bail|numeric|exists:nests,id',
'name' => 'required|string|max:191',
'description' => 'sometimes|string|nullable',
'features' => 'sometimes|array',
'docker_images' => 'required|array|min:1',
'docker_images.*' => 'required|string',
'file_denylist' => 'sometimes|array|nullable',
'file_denylist.*' => 'sometimes|string',
'config_files' => 'required|nullable|json',
'config_startup' => 'required|nullable|json',
'config_stop' => 'required|nullable|string|max:191',
// 'config_from' => 'sometimes|nullable|numeric|exists:eggs,id',
'startup' => 'required|string',
'script_container' => 'sometimes|string',
'script_entry' => 'sometimes|string',
'script_install' => 'sometimes|string',
];
}
}

View file

@ -10,16 +10,16 @@ class UpdateEggRequest extends StoreEggRequest
'nest_id' => 'sometimes|numeric|exists:nests,id',
'name' => 'sometimes|string|max:191',
'description' => 'sometimes|string|nullable',
'features' => 'sometimes|array|nullable',
'docker_images' => 'sometimes|required|array|min:1',
'features' => 'sometimes|array',
'docker_images' => 'sometimes|array|min:1',
'docker_images.*' => 'sometimes|string',
'file_denylist' => 'sometimes|array|nullable',
'file_denylist.*' => 'sometimes|string',
'config_files' => 'sometimes|nullable|json',
'config_startup' => 'sometimes|nullable|json',
'config_stop' => 'sometimes|nullable|string|max:191',
'config_from' => 'sometimes|nullable|numeric|exists:eggs,id',
'startup' => 'sometimes|nullable|string',
// 'config_from' => 'sometimes|nullable|numeric|exists:eggs,id',
'startup' => 'sometimes|string',
'script_container' => 'sometimes|string',
'script_entry' => 'sometimes|string',
'script_install' => 'sometimes|string',

View file

@ -16,7 +16,6 @@ namespace Pterodactyl\Models;
* @property array|null $file_denylist
* @property string|null $config_files
* @property string|null $config_startup
* @property string|null $config_logs
* @property string|null $config_stop
* @property int|null $config_from
* @property string|null $startup
@ -32,7 +31,6 @@ namespace Pterodactyl\Models;
* @property string $copy_script_container
* @property string|null $inherit_config_files
* @property string|null $inherit_config_startup
* @property string|null $inherit_config_logs
* @property string|null $inherit_config_stop
* @property string $inherit_file_denylist
* @property array|null $inherit_features
@ -75,14 +73,16 @@ class Egg extends Model
* @var array
*/
protected $fillable = [
'nest_id',
'uuid',
'name',
'description',
'features',
'author',
'docker_images',
'file_denylist',
'config_files',
'config_startup',
'config_logs',
'config_stop',
'config_from',
'startup',
@ -123,7 +123,6 @@ class Egg extends Model
'config_from' => 'sometimes|bail|nullable|numeric|exists:eggs,id',
'config_stop' => 'required_without:config_from|nullable|string|max:191',
'config_startup' => 'required_without:config_from|nullable|json',
'config_logs' => 'required_without:config_from|nullable|json',
'config_files' => 'required_without:config_from|nullable|json',
'update_url' => 'sometimes|nullable|string',
];
@ -136,7 +135,6 @@ class Egg extends Model
'file_denylist' => null,
'config_stop' => null,
'config_startup' => null,
'config_logs' => null,
'config_files' => null,
'update_url' => null,
];
@ -214,20 +212,6 @@ class Egg extends Model
return $this->configFrom->config_startup;
}
/**
* Return the log reading configuration for an egg.
*
* @return string
*/
public function getInheritConfigLogsAttribute()
{
if (!is_null($this->config_logs) || is_null($this->config_from)) {
return $this->config_logs;
}
return $this->configFrom->config_logs;
}
/**
* Return the stop command configuration for an egg.
*

View file

@ -50,7 +50,6 @@ class EggExporterService
'config' => [
'files' => $egg->inherit_config_files,
'startup' => $egg->inherit_config_startup,
'logs' => $egg->inherit_config_logs,
'stop' => $egg->inherit_config_stop,
],
'scripts' => [

View file

@ -151,7 +151,6 @@ class EggImporterService
'update_url' => Arr::get($parsed, 'meta.update_url'),
'config_files' => Arr::get($parsed, 'config.files'),
'config_startup' => Arr::get($parsed, 'config.startup'),
'config_logs' => Arr::get($parsed, 'config.logs'),
'config_stop' => Arr::get($parsed, 'config.stop'),
'startup' => Arr::get($parsed, 'startup'),
'script_install' => Arr::get($parsed, 'scripts.installation.script'),

View file

@ -74,7 +74,6 @@ class EggUpdateImporterService
'docker_images' => object_get($parsed, 'images') ?? [object_get($parsed, 'image')],
'config_files' => object_get($parsed, 'config.files'),
'config_startup' => object_get($parsed, 'config.startup'),
'config_logs' => object_get($parsed, 'config.logs'),
'config_stop' => object_get($parsed, 'config.stop'),
'startup' => object_get($parsed, 'startup'),
'script_install' => object_get($parsed, 'scripts.installation.script'),

View file

@ -41,10 +41,9 @@ class EggTransformer extends Transformer
'docker_image' => count($model->docker_images) > 0 ? $model->docker_images[0] : '',
'docker_images' => $model->docker_images,
'config' => [
'files' => json_decode($model->config_files, true),
'startup' => json_decode($model->config_startup, true),
'files' => json_decode($model->config_files),
'startup' => json_decode($model->config_startup),
'stop' => $model->config_stop,
'logs' => json_decode($model->config_logs, true),
'file_denylist' => $model->file_denylist,
'extends' => $model->config_from,
],
@ -106,7 +105,6 @@ class EggTransformer extends Transformer
'files' => json_decode($model->inherit_config_files),
'startup' => json_decode($model->inherit_config_startup),
'stop' => $model->inherit_config_stop,
'logs' => json_decode($model->inherit_config_logs),
];
});
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class DropConfigLogsColumnFromEggsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('eggs', function (Blueprint $table) {
$table->dropColumn('config_logs');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('eggs', function (Blueprint $table) {
$table->text('config_logs')->nullable()->after('docker_image');
});
}
}

View file

@ -1,7 +1,9 @@
import http from '@/api/http';
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
export default (egg: Partial<Egg>): Promise<Egg> => {
type Egg2 = Omit<Omit<Partial<Egg>, 'configFiles'>, 'configStartup'> & { configFiles: string, configStartup: string };
export default (egg: Partial<Egg2>): Promise<Egg> => {
return new Promise((resolve, reject) => {
http.post(
'/api/application/eggs',

View file

@ -196,9 +196,9 @@ export const Loading = () => {
);
};
export const NoItems = () => {
export const NoItems = ({ className }: { className?: string }) => {
return (
<div css={tw`w-full flex flex-col items-center justify-center py-6 px-8`}>
<div css={tw`w-full flex flex-col items-center justify-center py-6 px-8`} className={className}>
<div css={tw`h-48 flex`}>
<img src={'/assets/svgs/not_found.svg'} alt={'No Items'} css={tw`h-full select-none`}/>
</div>
@ -227,7 +227,8 @@ export const ContentWrapper = ({ checked, onSelectAllClick, onSearch, children }
}
setLoading(true);
onSearch(query).then(() => setLoading(false));
onSearch(query)
.then(() => setLoading(false));
}, 200),
[],
);

View file

@ -1,8 +1,51 @@
import React from 'react';
import createEgg from '@/api/admin/eggs/createEgg';
import { EggImageContainer, EggInformationContainer, EggLifecycleContainer, EggProcessContainer, EggProcessContainerRef, EggStartupContainer } from '@/components/admin/nests/eggs/EggSettingsContainer';
import Button from '@/components/elements/Button';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { Form, Formik, FormikHelpers } from 'formik';
import React, { useRef } from 'react';
import { useParams } from 'react-router';
import { useHistory } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import { object } from 'yup';
interface Values {
name: string;
description: string;
startup: string;
dockerImages: string;
configStop: string;
configStartup: string;
configFiles: string;
}
export default () => {
const params = useParams<{ nestId: string }>();
const history = useHistory();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const ref = useRef<EggProcessContainerRef>();
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('egg:create');
const nestId = Number(params.nestId);
values.configStartup = await ref.current?.getStartupConfiguration() || '';
values.configFiles = await ref.current?.getFilesConfiguration() || '';
createEgg({ ...values, dockerImages: values.dockerImages.split('\n'), nestId })
.then(egg => history.push(`/admin/nests/${nestId}/eggs/${egg.id}`))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'egg:create', error });
})
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New Egg'}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
@ -11,6 +54,48 @@ export default () => {
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>Add a new egg to the panel.</p>
</div>
</div>
<FlashMessageRender key={'egg:create'} css={tw`mb-4`}/>
<Formik
onSubmit={submit}
initialValues={{
name: '',
description: '',
startup: '',
dockerImages: '',
configStop: '',
configStartup: '{}',
configFiles: '{}',
}}
validationSchema={object().shape({
})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<EggInformationContainer/>
</div>
<EggStartupContainer css={tw`mb-6`}/>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<EggImageContainer/>
<EggLifecycleContainer/>
</div>
<EggProcessContainer ref={ref} css={tw`mb-6`}/>
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-6 mb-16`}>
<div css={tw`flex flex-row`}>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Create
</Button>
</div>
</div>
</Form>
)}
</Formik>
</AdminContentBlock>
);
};

View file

@ -18,7 +18,7 @@ import tw from 'twin.macro';
import { object } from 'yup';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
function EggInformationContainer () {
export function EggInformationContainer () {
const { isSubmitting } = useFormikContext();
return (
@ -72,7 +72,7 @@ function EggDetailsContainer ({ egg }: { egg: Egg }) {
);
}
function EggStartupContainer ({ className }: { className?: string }) {
export function EggStartupContainer ({ className }: { className?: string }) {
const { isSubmitting } = useFormikContext();
return (
@ -90,7 +90,7 @@ function EggStartupContainer ({ className }: { className?: string }) {
);
}
function EggImageContainer () {
export function EggImageContainer () {
const { isSubmitting } = useFormikContext();
return (
@ -107,7 +107,7 @@ function EggImageContainer () {
);
}
function EggLifecycleContainer () {
export function EggLifecycleContainer () {
const { isSubmitting } = useFormikContext();
return (
@ -115,8 +115,8 @@ function EggLifecycleContainer () {
<SpinnerOverlay visible={isSubmitting}/>
<Field
id={'stopCommand'}
name={'stopCommand'}
id={'configStop'}
name={'configStop'}
label={'Stop Command'}
type={'text'}
css={tw`mb-1`}
@ -127,17 +127,16 @@ function EggLifecycleContainer () {
interface EggProcessContainerProps {
className?: string;
egg: Egg;
}
interface EggProcessContainerRef {
export interface EggProcessContainerRef {
getStartupConfiguration: () => Promise<string | null>;
getFilesConfiguration: () => Promise<string | null>;
}
const EggProcessContainer = forwardRef<any, EggProcessContainerProps>(
function EggProcessContainer ({ className, egg }, ref) {
const { isSubmitting } = useFormikContext();
export const EggProcessContainer = forwardRef<any, EggProcessContainerProps>(
function EggProcessContainer ({ className }, ref) {
const { isSubmitting, values } = useFormikContext<Values>();
let fetchStartupConfiguration: (() => Promise<string>) | null = null;
let fetchFilesConfiguration: (() => Promise<string>) | null = null;
@ -166,7 +165,7 @@ const EggProcessContainer = forwardRef<any, EggProcessContainerProps>(
<Label>Startup Configuration</Label>
<Editor
mode={jsonLanguage}
initialContent={JSON.stringify(egg.configStartup, null, '\t') || ''}
initialContent={values.configStartup}
overrides={tw`h-32 rounded`}
fetchContent={value => {
fetchStartupConfiguration = value;
@ -178,7 +177,7 @@ const EggProcessContainer = forwardRef<any, EggProcessContainerProps>(
<Label>Configuration Files</Label>
<Editor
mode={jsonLanguage}
initialContent={JSON.stringify(egg.configFiles, null, '\t') || ''}
initialContent={values.configFiles}
overrides={tw`h-48 rounded`}
fetchContent={value => {
fetchFilesConfiguration = value;
@ -195,7 +194,7 @@ interface Values {
description: string;
startup: string;
dockerImages: string;
stopCommand: string;
configStop: string;
configStartup: string;
configFiles: string;
}
@ -229,9 +228,9 @@ export default function EggSettingsContainer ({ egg }: { egg: Egg }) {
description: egg.description || '',
startup: egg.startup,
dockerImages: egg.dockerImages.join('\n'),
stopCommand: egg.configStop || '',
configStartup: '',
configFiles: '',
configStop: egg.configStop || '',
configStartup: JSON.stringify(egg.configStartup, null, '\t') || '',
configFiles: JSON.stringify(egg.configFiles, null, '\t') || '',
}}
validationSchema={object().shape({
})}
@ -250,17 +249,13 @@ export default function EggSettingsContainer ({ egg }: { egg: Egg }) {
<EggLifecycleContainer/>
</div>
<EggProcessContainer
ref={ref}
egg={egg}
css={tw`mb-6`}
/>
<EggProcessContainer ref={ref} css={tw`mb-6`}/>
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-6 mb-16`}>
<div css={tw`flex flex-row`}>
<EggDeleteButton
eggId={egg.id}
onDeleted={() => history.push('/admin/nests')}
onDeleted={() => history.push(`/admin/nests/${egg.nestId}`)}
/>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Save Changes

View file

@ -1,4 +1,5 @@
import deleteEggVariable from '@/api/admin/eggs/deleteEggVariable';
import { NoItems } from '@/components/admin/AdminTable';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import React, { useState } from 'react';
@ -160,28 +161,32 @@ export default function EggVariablesContainer ({ egg }: { egg: Egg }) {
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`flex flex-col mb-16`}>
<div css={tw`grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-6`}>
{egg.relations?.variables?.map((v, i) => (
<EggVariableBox
key={i}
prefix={`[${i}].`}
variable={v}
onDeleteClick={(success) => {
deleteEggVariable(egg.id, v.id)
.then(async () => {
await mutate(egg => ({
...egg!,
relations: {
variables: egg!.relations.variables!.filter(v2 => v.id === v2.id),
},
}));
success();
})
.catch(error => clearAndAddHttpError({ key: 'egg', error }));
}}
/>
))}
</div>
{egg.relations?.variables?.length === 0 ?
<NoItems css={tw`bg-neutral-700 rounded-md shadow-md`}/>
:
<div css={tw`grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-6`}>
{egg.relations?.variables?.map((v, i) => (
<EggVariableBox
key={i}
prefix={`[${i}].`}
variable={v}
onDeleteClick={(success) => {
deleteEggVariable(egg.id, v.id)
.then(async () => {
await mutate(egg => ({
...egg!,
relations: {
variables: egg!.relations.variables!.filter(v2 => v.id === v2.id),
},
}));
success();
})
.catch(error => clearAndAddHttpError({ key: 'egg', error }));
}}
/>
))}
</div>
}
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-4 mt-6`}>
<div css={tw`flex flex-row`}>

View file

@ -80,7 +80,7 @@ export default function NewVariableButton ({ eggId }: { eggId: number }) {
</Formik>
<Button type={'button'} color={'green'} onClick={() => setVisible(true)}>
Add a fucking variable
New Variable
</Button>
</>
);