Compare commits

...

92 Commits

Author SHA1 Message Date
github-actions[bot]
73cefa59fe ci(release): bump version 2024-05-18 01:03:43 +00:00
notCharles
c18d291f8f database page updates 2024-05-17 20:56:12 -04:00
Charles
45c0cfe4d8 Merge pull request #240 from Boy132/backport/ptero-backup-node-check
backups: ensure requesting node is checked
2024-05-17 20:21:57 -04:00
Charles
89d555f308 Merge pull request #244 from pelican-dev/charles/fix-newuser
fix: Creating User
2024-05-17 20:20:10 -04:00
notCharles
9b2e00ead2 pint 2024-05-17 20:10:53 -04:00
notCharles
d0997dca1a Remove hints 2024-05-17 19:59:41 -04:00
notCharles
cca723d21e Update unique, remove dehydrates 2024-05-17 19:59:04 -04:00
Lance Pioch
68dbd6e329 Remove ref 2024-05-17 19:50:34 -04:00
Lance Pioch
544364f061 Merge branch 'main' of github.com:pelican-dev/panel 2024-05-17 19:49:36 -04:00
Lance Pioch
649f319776 Swap route key 2024-05-17 19:49:33 -04:00
notCharles
6ffe800e0d Forgot to delete these. 2024-05-17 19:14:29 -04:00
notCharles
12af8fe51e This is better... 2024-05-16 20:34:37 -04:00
notCharles
9f5d27896a Pint 2024-05-16 18:44:59 -04:00
notCharles
15969da41e Fix New User
Removes the required password field for making new accounts, triggers email for user to make their own password.
2024-05-16 18:43:05 -04:00
Charles
628a6e54e3 Merge pull request #243 from pelican-dev/charles/remove-log
Remove Log Viewer
2024-05-16 18:04:46 -04:00
notCharles
edcd406b82 Remove Log Viewer 2024-05-16 17:18:27 -04:00
Boy132
1742061807 Merge branch 'pelican-dev:main' into backport/ptero-backup-node-check 2024-05-16 17:32:19 +02:00
Charles
289eda64ad Merge pull request #238 from Boy132/fix/236
Add default for disk/ memory and overallocation
2024-05-16 11:31:25 -04:00
Boy132
cc46c463ac run pint 2024-05-16 17:28:49 +02:00
Boy132
e6dded61a4 backups: ensure requesting node is checked
Co-authored-by: matthewpi <matthew@pterodactyl.io>
2024-05-16 11:53:18 +02:00
Boy132
921b76f1e1 add default for disk/ memory and overallocation 2024-05-16 08:43:09 +02:00
Lance Pioch
91a3bb969e Better scrambling to fix #235 2024-05-16 00:23:29 -04:00
Lance Pioch
8b62df6c53 Composer update 2024-05-16 00:15:35 -04:00
Lance Pioch
3c14e1ffa4 Update edit images to match create and fix #229 2024-05-16 00:04:13 -04:00
Lance Pioch
095fff7ad6 Merge branch 'main' of github.com:pelican-dev/panel 2024-05-15 22:33:59 -04:00
notCharles
0cd03e95f4 Update database view 2024-05-15 21:21:57 -04:00
notCharles
00dda7dbe4 update composer file 2024-05-15 20:26:44 -04:00
notCharles
eee1260fcc upgrade 2024-05-15 19:58:17 -04:00
notCharles
71c91e086f Update sidebar width 2024-05-15 19:51:12 -04:00
Lance Pioch
94bbf46659 Small adjustments for now 2024-05-15 15:14:17 -04:00
Lance Pioch
558b8b2ea7 Update funding 2024-05-15 14:16:30 -04:00
Lance Pioch
4bb16887c4 Add top navigation config 2024-05-15 14:14:31 -04:00
Lance Pioch
0c72833af7 Show blank if only 0 2024-05-15 14:11:09 -04:00
Lance Pioch
ee3f8cd3ec No longer need this 2024-05-15 14:10:56 -04:00
Lance Pioch
b513366f35 Merge branch 'main' of github.com:pelican-dev/panel 2024-05-15 13:24:44 -04:00
Lance Pioch
b1d056a301 Merge pull request #232 from Poseidon281/Navigation-Sidebar
Update: Navigation sidebar
2024-05-15 13:24:25 -04:00
Poseidon281
c72d3f1338 Added topNavigation 2024-05-15 16:57:30 +02:00
Poseidon281
7e6231e6de Run Pint 2024-05-15 16:48:32 +02:00
Poseidon281
4ad837ff14 Added count badge to navigation sidebar 2024-05-15 16:45:35 +02:00
Poseidon281
d7ebde5f5f Composer update because of error 2024-05-15 16:40:48 +02:00
Boy132
6bdd1b3ccb Add api for server transfers (#153)
* add application api endpoint to start server transfer

* add basics for "cancel transfer" endpoint

* wire up wings cancel

* lint
2024-05-15 07:37:56 -07:00
Boy132
afd9f2eb0e Add api for database hosts (#159)
* add application api endpoints for database hosts

* run pint

* forgot to lint this one

* Update app/Http/Controllers/Api/Application/DatabaseHosts/DatabaseHostController.php

Co-authored-by: Devonte W <devnote.dev75@gmail.com>

* Update routes/api-application.php

Co-authored-by: Devonte W <devnote.dev75@gmail.com>

* rename all "databaseHost" to "database_host"

---------

Co-authored-by: Devonte W <devnote.dev75@gmail.com>
2024-05-15 07:37:38 -07:00
notCharles
8a617d416e Add Log Viewer
If you `ln /var/log/pelican/wings.log /var/www/pelican/storage/logs/wings.log` &  `chmod 755 /var/log/pelican/wings.log` They will show in the viewer <3
2024-05-14 22:08:26 -04:00
Charles
7f4cc64d11 Update readme.md 2024-05-14 21:19:50 -04:00
Charles
38e117e304 Update readme.md
Does an SVG work?
2024-05-14 21:16:39 -04:00
notCharles
4d272a1234 WIP: show databases on database host edit screen 2024-05-14 20:10:27 -04:00
notCharles
e9dc6cd32c Update database hosts 2024-05-14 19:30:07 -04:00
notCharles
98ba2c1b8b Update node pages 2024-05-14 19:22:14 -04:00
Charles
2d643ec79f Merge pull request #225 from Boy132/update/gb-to-gib
Update GB to GiB + minValues for resources + other stuff
2024-05-14 18:59:33 -04:00
Charles
2b4625e9b9 Merge pull request #228 from Boy132/patch-1
Add back config variable for redis client
2024-05-14 18:47:02 -04:00
Boy132
9f40ed2f84 add back config variable for redis client 2024-05-14 19:05:52 +02:00
Boy132
af797b3018 run pint 2024-05-14 11:40:01 +02:00
Boy132
e5146e0dbb add helper to cpu input 2024-05-14 11:21:58 +02:00
Boy132
586d1b413c set default overallocation to 0 2024-05-14 10:53:28 +02:00
Boy132
7d239de7f6 add min values for resources 2024-05-14 10:53:04 +02:00
Boy132
5f13c15c70 convert chart data from bytes to GiB 2024-05-14 10:50:26 +02:00
Boy132
4e3f919d8e change all GB/ MB to GiB/ MiB 2024-05-14 10:41:06 +02:00
Lance Pioch
7efa04f1ef Merge pull request #215 from Boy132/remove-brandName
Remove hardcoded brandName
2024-05-14 00:55:51 -04:00
notCharles
35ac1f863a Move save buttons to the top. 2024-05-13 19:58:01 -04:00
notCharles
68195ab0b7 Remove ports, >1024 | <65535 2024-05-13 19:57:42 -04:00
notCharles
f79dac2d13 Show associated servers on edit egg page 2024-05-13 19:39:28 -04:00
notCharles
4f176c47d2 Fix saving servers when egg variable is blank
closes https://github.com/pelican-dev/panel/issues/201
2024-05-13 19:18:04 -04:00
Charles
f7e7864dfe Merge pull request #216 from Boy132/update/storeschedulerequest
Add missing fields to StoreScheduleRequest
2024-05-13 19:14:51 -04:00
Charles
2f8a15facd Merge pull request #210 from Boy132/patch-1
Use correct variable name for mail driver
2024-05-13 19:14:27 -04:00
Charles
7bf175190b Merge pull request #206 from Boy132/update-default-drivers
Update default drivers
2024-05-13 19:14:02 -04:00
Charles
8866ca3d96 Merge pull request #207 from Boy132/rename/oom_disabled
Convert `oom_disabled` to `oom_killer`
2024-05-13 19:12:59 -04:00
Boy132
703e5480ff add missing fields to StoreScheduleRequest 2024-05-13 18:55:59 +02:00
Boy132
45a6c37594 add back env variable for APP_NAME 2024-05-13 18:24:26 +02:00
Boy132
340085cc9b remove hardcoded brandName 2024-05-13 18:23:17 +02:00
Boy132
ef9d1ab614 use env value as default for driver choice 2024-05-13 08:57:06 +02:00
Boy132
55a0bfdf7e remove "MAIL_DRIVER" from phpunit.xml 2024-05-13 08:53:14 +02:00
Boy132
d6f3934f80 use correct variable name for mail driver
closes #209
2024-05-13 08:51:12 +02:00
notCharles
f9e8adad30 Update EditServer egg variables to match CreateServer
validation is still broken on `nullable|<##>` rules
2024-05-12 22:37:40 -04:00
notCharles
08dc5753d4 Merge branch 'main' of https://github.com/pelican-dev/panel 2024-05-12 22:34:53 -04:00
notCharles
0c851ea075 Fix database host saving of password
Closes https://github.com/pelican-dev/panel/issues/203
2024-05-12 22:34:41 -04:00
kubi
cf3ea38e65 Update FUNDING.yml 2024-05-12 15:42:02 -07:00
Boy132
b813de0467 run pint 2024-05-12 22:24:37 +02:00
Boy132
399bed7576 fix typo 2024-05-12 22:21:22 +02:00
Boy132
5aa7128b9c backwards compatibility 2024-05-12 22:21:22 +02:00
Boy132
92d167eb10 add migration to db schemas 2024-05-12 22:21:22 +02:00
Boy132
f02eb5bfba rename "oom_disabled" to "oom_killer" and invert logic 2024-05-12 22:21:22 +02:00
Boy132
f348ac9f0a update example .env file 2024-05-12 22:19:30 +02:00
Boy132
893b2dca89 update drivers in setup command 2024-05-12 22:18:22 +02:00
notCharles
1f5217a9d9 Add Egg ID to page 2024-05-12 16:16:13 -04:00
notCharles
e84b47410a move some stuff 2024-05-12 14:30:35 -04:00
notCharles
76cf4391ae Add advanced node setting tab 2024-05-12 14:11:17 -04:00
notCharles
7688006574 Pint 2024-05-12 14:11:00 -04:00
notCharles
ed36041a7e Allow alias to be edited on edit server page 2024-05-12 12:44:44 -04:00
notCharles
bb52485606 Update Allocation Relationship
Allow editing of alias, and ip after allocation is created.
2024-05-12 12:35:08 -04:00
notCharles
615b70f9a2 Fix rule validation? 2024-05-12 00:55:44 -04:00
Lance Pioch
3609873c4c These are emails 2024-05-11 23:44:59 -04:00
Lance Pioch
7f6e4a18c1 Validate these 2024-05-11 23:44:12 -04:00
85 changed files with 2184 additions and 1184 deletions

View File

@@ -13,12 +13,8 @@ LOG_LEVEL=debug
DB_CONNECTION=sqlite
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_STORE=file
QUEUE_CONNECTION=sync
QUEUE_CONNECTION=database
SESSION_DRIVER=file
HASHIDS_SALT=

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: pelican-dev
custom: [https://buy.stripe.com/14kdU99SI4UT7ni9AB, https://buy.stripe.com/14kaHXc0Q9b9372eUU]
custom: [https://hub.pelican.dev/donors]

View File

@@ -10,24 +10,23 @@ use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command
{
use EnvironmentWriterTrait;
public const CACHE_DRIVERS = [
'redis' => 'Redis',
'memcached' => 'Memcached',
'file' => 'Filesystem (recommended)',
'redis' => 'Redis',
];
public const SESSION_DRIVERS = [
'redis' => 'Redis',
'memcached' => 'Memcached',
'database' => 'MySQL Database',
'file' => 'Filesystem (recommended)',
'redis' => 'Redis',
'database' => 'Database',
'cookie' => 'Cookie',
];
public const QUEUE_DRIVERS = [
'sync' => 'Synchronous (recommended)',
'database' => 'Database',
'redis' => 'Redis',
'database' => 'MySQL Database',
'sync' => 'Sync (recommended)',
];
protected $description = 'Configure basic environment settings for the Panel.';
@@ -86,7 +85,7 @@ class AppSettingsCommand extends Command
array_key_exists($selected, self::SESSION_DRIVERS) ? $selected : null
);
$selected = config('queue.default', 'sync');
$selected = config('queue.default', 'database');
$this->variables['QUEUE_CONNECTION'] = $this->option('queue') ?? $this->choice(
'Queue Driver',
self::QUEUE_DRIVERS,

View File

@@ -31,7 +31,7 @@ class EmailSettingsCommand extends Command
*/
public function handle(): void
{
$this->variables['MAIL_DRIVER'] = $this->option('driver') ?? $this->choice(
$this->variables['MAIL_MAILER'] = $this->option('driver') ?? $this->choice(
trans('command/messages.environment.mail.ask_driver'),
[
'log' => 'Log',
@@ -41,10 +41,10 @@ class EmailSettingsCommand extends Command
'mandrill' => 'Mandrill',
'postmark' => 'Postmark',
],
'smtp',
env('MAIL_MAILER', env('MAIL_DRIVER', 'smtp')),
);
$method = 'setup' . studly_case($this->variables['MAIL_DRIVER']) . 'DriverVariables';
$method = 'setup' . studly_case($this->variables['MAIL_MAILER']) . 'DriverVariables';
if (method_exists($this, $method)) {
$this->{$method}();
}

View File

@@ -7,9 +7,7 @@ use Filament\Notifications\Notification;
use Illuminate\Http\Request;
use Psr\Log\LoggerInterface;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Container\Container;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

View File

@@ -12,9 +12,13 @@ class ApiKeyResource extends Resource
{
protected static ?string $model = ApiKey::class;
protected static ?string $label = 'API Key';
protected static ?string $navigationIcon = 'tabler-key';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function canEdit($record): bool
{
return false;

View File

@@ -14,6 +14,11 @@ class DatabaseHostResource extends Resource
protected static ?string $navigationIcon = 'tabler-database';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [

View File

@@ -22,50 +22,59 @@ class CreateDatabaseHost extends CreateRecord
{
return $form
->schema([
Section::make()->schema([
Forms\Components\TextInput::make('host')
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->required()
->live()
->debounce(500)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(191),
Forms\Components\TextInput::make('port')
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
Forms\Components\TextInput::make('username')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->required()
->maxLength(191),
Forms\Components\TextInput::make('password')
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(191)
->required(),
Forms\Components\TextInput::make('name')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required()
->maxLength(60),
Forms\Components\Select::make('node_id')
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),
])->columns([
'default' => 1,
'lg' => 2,
]),
Section::make()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
Forms\Components\TextInput::make('host')
->columnSpan(2)
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(191),
Forms\Components\TextInput::make('port')
->columnSpan(1)
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
Forms\Components\TextInput::make('max_databases')
->label('Max databases')
->helpertext('Blank is unlimited.')
->numeric(),
Forms\Components\TextInput::make('name')
->label('Display Name')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required()
->maxLength(60),
Forms\Components\TextInput::make('username')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->required()
->maxLength(191),
Forms\Components\TextInput::make('password')
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(191)
->required(),
Forms\Components\Select::make('node_id')
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),
]),
]);
}
protected function mutateFormDataBeforeSave(array $data): array
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['password'])) {
$data['password'] = encrypt($data['password']);
@@ -73,4 +82,17 @@ class CreateDatabaseHost extends CreateRecord
return $data;
}
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -17,45 +17,54 @@ class EditDatabaseHost extends EditRecord
{
return $form
->schema([
Section::make()->schema([
Forms\Components\TextInput::make('host')
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->required()
->live()
->debounce(500)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(191),
Forms\Components\TextInput::make('port')
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
Forms\Components\TextInput::make('username')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->required()
->maxLength(191),
Forms\Components\TextInput::make('password')
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(191)
->required(),
Forms\Components\TextInput::make('name')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required()
->maxLength(60),
Forms\Components\Select::make('node_id')
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),
])->columns([
'default' => 1,
'lg' => 2,
]),
Section::make()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
Forms\Components\TextInput::make('host')
->columnSpan(2)
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(191),
Forms\Components\TextInput::make('port')
->columnSpan(1)
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->minValue(0)
->maxValue(65535),
Forms\Components\TextInput::make('max_databases')
->label('Max databases')
->helpertext('Blank is unlimited.')
->numeric(),
Forms\Components\TextInput::make('name')
->label('Display Name')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required()
->maxLength(60),
Forms\Components\TextInput::make('username')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->required()
->maxLength(191),
Forms\Components\TextInput::make('password')
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(191)
->required(),
Forms\Components\Select::make('node_id')
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),
]),
]);
}
@@ -63,6 +72,7 @@ class EditDatabaseHost extends EditRecord
{
return [
Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
];
}
@@ -74,4 +84,16 @@ class EditDatabaseHost extends EditRecord
return $data;
}
protected function getFormActions(): array
{
return [];
}
public function getRelationManagers(): array
{
return [
DatabaseHostResource\RelationManagers\DatabasesRelationManager::class,
];
}
}

View File

@@ -12,6 +12,8 @@ class ListDatabaseHosts extends ListRecords
{
protected static string $resource = DatabaseHostResource::class;
protected ?string $heading = 'Database Hosts';
public function table(Table $table): Table
{
return $table
@@ -48,7 +50,7 @@ class ListDatabaseHosts extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make('create')->label('New Database Host'),
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources\DatabaseHostResource\RelationManagers;
use App\Models\Database;
use App\Services\Databases\DatabasePasswordService;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class DatabasesRelationManager extends RelationManager
{
protected static string $relationship = 'databases';
protected $listeners = ['refresh' => 'refreshForm'];
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('database')->columnSpanFull(),
Forms\Components\TextInput::make('username'),
Forms\Components\TextInput::make('password')
->hintAction(
Action::make('rotate')
->icon('tabler-refresh')
->requiresConfirmation()
->action(fn (DatabasePasswordService $service, Database $database) => $service->handle($database))
)
->formatStateUsing(fn (Database $database) => decrypt($database->password)),
Forms\Components\TextInput::make('remote')->label('Connections From'),
Forms\Components\TextInput::make('max_connections'),
Forms\Components\TextInput::make('JDBC')
->label('JDBC Connection String')
->columnSpanFull()
->formatStateUsing(fn (Forms\Get $get, Database $database) => 'jdbc:mysql://' . $get('username') . ':' . urlencode(decrypt($database->password)) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database')),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('servers')
->columns([
Tables\Columns\TextColumn::make('database')->icon('tabler-database'),
Tables\Columns\TextColumn::make('username')->icon('tabler-user'),
//Tables\Columns\TextColumn::make('password'),
Tables\Columns\TextColumn::make('remote'),
Tables\Columns\TextColumn::make('server.name')
->icon('tabler-brand-docker')
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
Tables\Columns\TextColumn::make('max_connections'),
Tables\Columns\TextColumn::make('created_at')->dateTime(),
])
->actions([
Tables\Actions\DeleteAction::make(),
Tables\Actions\ViewAction::make()->color('primary'),
//Tables\Actions\EditAction::make(),
]);
}
}

View File

@@ -14,6 +14,11 @@ class DatabaseResource extends Resource
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [

View File

@@ -16,6 +16,11 @@ class EggResource extends Resource
protected static ?string $recordRouteKeyName = 'id';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [

View File

@@ -31,6 +31,7 @@ class CreateEgg extends CreateRecord
Forms\Components\TextInput::make('author')
->maxLength(191)
->required()
->email()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('The author of this version of the Egg.'),
Forms\Components\Textarea::make('description')

View File

@@ -25,12 +25,16 @@ class EditEgg extends EditRecord
Forms\Components\TextInput::make('name')
->required()
->maxLength(191)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1])
->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
Forms\Components\TextInput::make('uuid')
->label('Egg UUID')
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('This is the globally unique identifier for this Egg which Wings uses as an identifier.'),
Forms\Components\TextInput::make('id')
->label('Egg ID')
->disabled(),
Forms\Components\Textarea::make('description')
->rows(3)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
@@ -38,6 +42,7 @@ class EditEgg extends EditRecord
Forms\Components\TextInput::make('author')
->required()
->maxLength(191)
->email()
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('The author of this version of the Egg. Uploading a new Egg configuration from a different author will change this.'),
@@ -202,6 +207,19 @@ class EditEgg extends EditRecord
->color('primary')
// TODO uses old admin panel export service
->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg['id']])),
$this->getSaveFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
public function getRelationManagers(): array
{
return [
EggResource\RelationManagers\ServersRelationManager::class,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\EggResource\RelationManagers;
use App\Models\Server;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class ServersRelationManager extends RelationManager
{
protected static string $relationship = 'servers';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('servers')
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned this egg.')
->searchable(false)
->columns([
Tables\Columns\TextColumn::make('user.username')
->label('Owner')
->icon('tabler-user')
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->sortable(),
Tables\Columns\TextColumn::make('name')
->icon('tabler-brand-docker')
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->sortable(),
Tables\Columns\TextColumn::make('node.name')
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])),
Tables\Columns\TextColumn::make('image')
->label('Docker Image'),
Tables\Columns\SelectColumn::make('allocation.id')
->label('Primary Allocation')
->options(fn ($state, Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),
]);
}
}

View File

@@ -12,6 +12,11 @@ class MountResource extends Resource
protected static ?string $navigationIcon = 'tabler-layers-linked';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [

View File

@@ -3,7 +3,6 @@
namespace App\Filament\Resources\MountResource\Pages;
use App\Filament\Resources\MountResource;
use App\Services\Servers\ServerCreationService;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;

View File

@@ -98,6 +98,12 @@ class EditMount extends EditRecord
{
return [
Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -15,6 +15,11 @@ class NodeResource extends Resource
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource;
use Filament\Forms;
use Filament\Forms\Components\Tabs;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\HtmlString;
@@ -17,179 +18,302 @@ class CreateNode extends CreateRecord
public function form(Forms\Form $form): Forms\Form
{
return $form
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
Forms\Components\TextInput::make('fqdn')
->columnSpan(2)
->required()
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
return $form->schema([
Tabs::make('Tabs')
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->persistTabInQueryString()
->columnSpanFull()
->tabs([
Tabs\Tab::make('Basic Settings')
->icon('tabler-server')
->schema([
Forms\Components\TextInput::make('fqdn')
->columnSpan(2)
->required()
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses
';
}
}
return '';
}
return '';
}
return "
return "
This is the domain name that points to your node's IP Address.
If you've already set up this, you can verify it by checking the next field!
";
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL';
}
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL';
}
return '';
})
->afterStateUpdated(function (Forms\Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
return '';
})
->afterStateUpdated(function (Forms\Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
return;
}
$validRecords = gethostbynamel($state);
if ($validRecords) {
$set('dns', true);
$validRecords = gethostbynamel($state);
if ($validRecords) {
$set('dns', true);
$set('ip', collect($validRecords)->first());
$set('ip', collect($validRecords)->first());
return;
}
return;
}
$set('dns', false);
})
->maxLength(191),
$set('dns', false);
})
->maxLength(191),
Forms\Components\TextInput::make('ip')
->disabled()
->hidden(),
Forms\Components\TextInput::make('ip')
->disabled()
->hidden(),
Forms\Components\ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->disabled()
->inline()
->default(null)
->hint(fn (Forms\Get $get) => $get('ip'))
->hintColor('success')
->options([
true => 'Valid',
false => 'Invalid',
])
->colors([
true => 'success',
false => 'danger',
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Forms\Components\ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->disabled()
->inline()
->default(null)
->hint(fn (Forms\Get $get) => $get('ip'))
->hintColor('success')
->options([
true => 'Valid',
false => 'Invalid',
])
->colors([
true => 'success',
false => 'danger',
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Forms\Components\TextInput::make('daemon_listen')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->minValue(0)
->maxValue(65536)
->default(8080)
->required()
->integer(),
Forms\Components\TextInput::make('daemon_listen')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->minValue(0)
->maxValue(65536)
->default(8080)
->required()
->integer(),
Forms\Components\TextInput::make('name')
->label('Display Name')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
Forms\Components\TextInput::make('name')
->label('Display Name')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
Forms\Components\ToggleButtons::make('scheme')
->label('Communicate over SSL')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->inline()
->helperText(function (Forms\Get $get) {
if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
}
Forms\Components\ToggleButtons::make('scheme')
->label('Communicate over SSL')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->inline()
->helperText(function (Forms\Get $get) {
if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
}
if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.';
}
if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.';
}
return '';
})
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
])
->colors([
'http' => 'warning',
'https' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Forms\Components\Textarea::make('description')
->label('strings.description')
->hidden()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 4,
])
->rows(5),
Forms\Components\Hidden::make('skipValidation')->default(true),
]);
return '';
})
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
])
->colors([
'http' => 'warning',
'https' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'),
]),
Tabs\Tab::make('Advanced Settings')
->icon('tabler-server-cog')
->schema([
Forms\Components\TextInput::make('upload_size')
->label('Upload Limit')
->helperText('Enter the maximum size of files that can be uploaded through the web-based file manager.')
->columnSpan(1)
->numeric()->required()
->default(256)
->minValue(1)
->maxValue(1024)
->suffix('MiB'),
Forms\Components\ToggleButtons::make('public')
->label('Automatic Allocation')->inline()
->default(true)
->columnSpan(1)
->options([
true => 'Yes',
false => 'No',
])
->colors([
true => 'success',
false => 'danger',
]),
Forms\Components\ToggleButtons::make('maintenance_mode')
->label('Maintenance Mode')->inline()
->columnSpan(1)
->default(false)
->hinticon('tabler-question-mark')
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
->options([
true => 'Enable',
false => 'Disable',
])
->colors([
true => 'danger',
false => 'success',
]),
Forms\Components\TagsInput::make('tags')
->label('Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented')
->columnSpan(1),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('memory_overallocate', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
Forms\Components\TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MiB')
->columnSpan(2)
->numeric()
->minValue(0)
->default(0),
Forms\Components\TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label('Overallocate')->inlineLabel()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->default(0)
->suffix('%'),
]),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('disk_overallocate', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
Forms\Components\TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel()
->suffix('MiB')
->columnSpan(2)
->numeric()
->minValue(0)
->default(0),
Forms\Components\TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->default(0)
->suffix('%'),
]),
]),
]),
]);
}
protected function getRedirectUrlParameters(): array

View File

@@ -32,13 +32,293 @@ class EditNode extends EditRecord
->tabs([
Tabs\Tab::make('Basic Settings')
->icon('tabler-server')
->schema((new CreateNode())->form($form)->getComponents()),
// Tabs\Tab::make('Advanced Settings')
// ->icon('tabler-server-cog')
// ->schema([
// Forms\Components\Placeholder::make('Coming soon!'),
// ]),
Tabs\Tab::make('Configuration')
->schema([
Forms\Components\TextInput::make('fqdn')
->columnSpan(2)
->required()
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses
';
}
return '';
}
return "
This is the domain name that points to your node's IP Address.
If you've already set up this, you can verify it by checking the next field!
";
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL';
}
return '';
})
->afterStateUpdated(function (Forms\Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
$validRecords = gethostbynamel($state);
if ($validRecords) {
$set('dns', true);
$set('ip', collect($validRecords)->first());
return;
}
$set('dns', false);
})
->maxLength(191),
Forms\Components\TextInput::make('ip')
->disabled()
->hidden(),
Forms\Components\ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->disabled()
->inline()
->default(null)
->hint(fn (Forms\Get $get) => $get('ip'))
->hintColor('success')
->options([
true => 'Valid',
false => 'Invalid',
])
->colors([
true => 'success',
false => 'danger',
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Forms\Components\TextInput::make('daemon_listen')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->minValue(0)
->maxValue(65536)
->default(8080)
->required()
->integer(),
Forms\Components\TextInput::make('name')
->label('Display Name')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
Forms\Components\ToggleButtons::make('scheme')
->label('Communicate over SSL')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->inline()
->helperText(function (Forms\Get $get) {
if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
}
if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.';
}
return '';
})
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
])
->colors([
'http' => 'warning',
'https' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
Tabs\Tab::make('Advanced Settings')
->icon('tabler-server-cog')
->schema([
Forms\Components\TextInput::make('id')
->label('Node ID')
->disabled(),
Forms\Components\TextInput::make('uuid')
->label('Node UUID')
->hintAction(CopyAction::make())
->columnSpan(2)
->disabled(),
Forms\Components\TagsInput::make('tags')
->label('Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented')
->columnSpan(1),
Forms\Components\ToggleButtons::make('public')
->label('Automatic Allocation')->inline()
->columnSpan(1)
->options([
true => 'Yes',
false => 'No',
])
->colors([
true => 'success',
false => 'danger',
]),
Forms\Components\ToggleButtons::make('maintenance_mode')
->label('Maintenance Mode')->inline()
->columnSpan(1)
->hinticon('tabler-question-mark')
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
->options([
true => 'Enable',
false => 'Disable',
])
->colors([
true => 'danger',
false => 'success',
]),
Forms\Components\TextInput::make('upload_size')
->label('Upload Limit')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.')
->columnStart(4)->columnSpan(1)
->numeric()->required()
->minValue(1)
->maxValue(1024)
->suffix('MiB'),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('memory_overallocate', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
Forms\Components\TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MiB')
->required()
->columnSpan(2)
->numeric()
->minValue(0),
Forms\Components\TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label('Overallocate')->inlineLabel()
->required()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->suffix('%'),
]),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('disk_overallocate', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
Forms\Components\TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel()
->suffix('MiB')
->required()
->columnSpan(2)
->numeric()
->minValue(0),
Forms\Components\TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->required()
->numeric()
->minValue(-1)
->maxValue(100)
->suffix('%'),
]),
]),
Tabs\Tab::make('Configuration File')
->icon('tabler-code')
->schema([
Forms\Components\Placeholder::make('instructions')
@@ -66,18 +346,17 @@ class EditNode extends EditRecord
return $data;
}
protected function getSteps(): array
protected function getFormActions(): array
{
return [
];
return [];
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->disabled(fn (Node $node) => $node->servers()->count() > 0)
->label(fn (Node $node) => $node->servers()->count() > 0 ? 'Node Has Servers' : 'Delete'),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@@ -42,15 +42,15 @@ class ListNodes extends ListRecords
->visibleFrom('sm')
->icon('tabler-device-desktop-analytics')
->numeric()
->suffix(' GB')
->formatStateUsing(fn ($state) => number_format($state / 1000, 2))
->suffix(' GiB')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
->sortable(),
Tables\Columns\TextColumn::make('disk')
->visibleFrom('sm')
->icon('tabler-file')
->numeric()
->suffix(' GB')
->formatStateUsing(fn ($state) => number_format($state / 1000, 2))
->suffix(' GiB')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
->sortable(),
Tables\Columns\IconColumn::make('scheme')
->visibleFrom('xl')

View File

@@ -43,10 +43,13 @@ class AllocationsRelationManager extends RelationManager
Tables\Columns\TextColumn::make('server.name')
->label('Server')
->icon('tabler-brand-docker')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
Tables\Columns\TextColumn::make('ip_alias')
Tables\Columns\TextInputColumn::make('ip_alias')
->searchable()
->label('Alias'),
Tables\Columns\TextColumn::make('ip')
Tables\Columns\TextInputColumn::make('ip')
->searchable()
->label('IP'),
Tables\Columns\TextColumn::make('port')
->searchable()
@@ -126,6 +129,8 @@ class AllocationsRelationManager extends RelationManager
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}

View File

@@ -40,8 +40,8 @@ class NodeMemoryChart extends ChartWidget
/** @var Node $node */
$node = $this->record;
$total = $node->statistics()['memory_total'] ?? 0;
$used = $node->statistics()['memory_used'] ?? 0;
$total = ($node->statistics()['memory_total'] ?? 0) / 1024 / 1024 / 1024;
$used = ($node->statistics()['memory_used'] ?? 0) / 1024 / 1024 / 1024;
$unused = $total - $used;
return [

View File

@@ -40,8 +40,8 @@ class NodeStorageChart extends ChartWidget
/** @var Node $node */
$node = $this->record;
$total = $node->statistics()['disk_total'] ?? 0;
$used = $node->statistics()['disk_used'] ?? 0;
$total = ($node->statistics()['disk_total'] ?? 0) / 1024 / 1024 / 1024;
$used = ($node->statistics()['disk_used'] ?? 0) / 1024 / 1024 / 1024;
$unused = $total - $used;
return [

View File

@@ -14,6 +14,11 @@ class ServerResource extends Resource
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [

View File

@@ -487,11 +487,12 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MB')
->suffix('MiB')
->default(0)
->required()
->columnSpan(2)
->numeric(),
->numeric()
->minValue(0),
]),
Forms\Components\Grid::make()
@@ -517,11 +518,12 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->suffix('MB')
->suffix('MiB')
->default(0)
->required()
->columnSpan(2)
->numeric(),
->numeric()
->minValue(0),
]),
Forms\Components\Grid::make()
@@ -551,7 +553,9 @@ class CreateServer extends CreateRecord
->default(0)
->required()
->columnSpan(2)
->numeric(),
->numeric()
->minValue(0)
->helperText('100% equals one logical thread'),
]),
Forms\Components\Grid::make()
@@ -593,7 +597,7 @@ class CreateServer extends CreateRecord
})
->label('Swap Memory')
->default(0)
->suffix('MB')
->suffix('MiB')
->minValue(-1)
->columnSpan(2)
->inlineLabel()
@@ -610,7 +614,7 @@ class CreateServer extends CreateRecord
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('oom_disabled')
Forms\Components\ToggleButtons::make('oom_killer')
->label('OOM Killer')
->inlineLabel()->inline()
->default(false)

View File

@@ -134,7 +134,7 @@ class EditServer extends EditRecord
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 6,
'lg' => 5,
])
->relationship('egg', 'name')
->searchable()
@@ -157,59 +157,48 @@ class EditServer extends EditRecord
])
->required(),
Forms\Components\ToggleButtons::make('custom_image')
->live()
->label('Custom Image?')->inline()
->formatStateUsing(function ($state, Forms\Get $get) {
if ($state !== null) {
return $state;
}
$images = Egg::find($get('egg_id'))->docker_images;
return !in_array($get('image'), $images);
})
->options([
false => 'No',
true => 'Yes',
])
->colors([
false => 'primary',
true => 'danger',
])
->icons([
false => 'tabler-settings-cancel',
true => 'tabler-settings-check',
]),
Forms\Components\TextInput::make('image')
->hidden(fn (Forms\Get $get) => !$get('custom_image'))
->disabled(fn (Forms\Get $get) => !$get('custom_image'))
->label('Docker Image')
->placeholder('Enter a custom Image')
->columnSpan([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->required(),
Forms\Components\Select::make('image')
->hidden(fn (Forms\Get $get) => $get('custom_image'))
->disabled(fn (Forms\Get $get) => $get('custom_image'))
->label('Docker Image')
Forms\Components\Select::make('select_image')
->label('Docker Image Name')
->prefixIcon('tabler-brand-docker')
->options(fn (Forms\Get $get) => Egg::find($get('egg_id'))->docker_images)
->disabled(fn (Forms\Components\Select $component) => empty($component->getOptions()))
->live()
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state))
->formatStateUsing(fn (Forms\Get $get) => $get('image'))
->options(function ($state, Forms\Get $get) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
return ['ghcr.io/custom-image' => 'Custom Image'] + array_flip($images);
})
->selectablePlaceholder(false)
->columnSpan([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->required(),
'lg' => 3,
]),
Forms\Components\TextInput::make('image')
->label('Docker Image')
->prefixIcon('tabler-brand-docker')
->live()
->debounce(500)
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
if (in_array($state, $images)) {
$set('select_image', $state);
} else {
$set('select_image', 'ghcr.io/custom-image');
}
})
->placeholder('Enter a custom Image')
->columnSpan([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 3,
]),
Forms\Components\Textarea::make('startup')
->hintIcon('tabler-code')
@@ -244,35 +233,57 @@ class EditServer extends EditRecord
]))
->schema([
Forms\Components\Repeater::make('server_variables')
->label('')
->relationship('serverVariables')
->grid()
->deletable(false)
->addable(false)
->schema([
Forms\Components\TextInput::make('variable_value')
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
foreach ($data as $key => $value) {
if (!isset($data['variable_value'])) {
$data['variable_value'] = '';
}
}
return $data;
})
->reorderable(false)->addable(false)->deletable(false)
->schema(function () {
$text = Forms\Components\TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->maxLength(191)
->rules([
fn (ServerVariable $variable): Closure => function (string $attribute, $value, Closure $fail) use ($variable) {
fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $variable->variable->rules,
'validatorkey' => $serverVariable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $variable->variable->name);
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message);
}
},
])
->label(fn (ServerVariable $variable) => $variable->variable->name)
->hintIcon('tabler-code')
->hintIconTooltip(fn (ServerVariable $variable) => $variable->variable->rules)
->prefix(fn (ServerVariable $variable) => '{{' . $variable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $variable) => $variable->variable->description ?: '—')
->maxLength(191),
]);
Forms\Components\Hidden::make('variable_id'),
])
$select = Forms\Components\Select::make('variable_value')
->hidden($this->shouldHideComponent(...))
->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false);
$components = [$text, $select];
/** @var Forms\Components\Component $component */
foreach ($components as &$component) {
$component = $component
->live(onBlur: true)
->hintIcon('tabler-code')
->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name)
->hintIconTooltip(fn (ServerVariable $serverVariable) => $serverVariable->variable->rules)
->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description);
}
return $components;
})
->columnSpan(2),
]),
@@ -311,10 +322,11 @@ class EditServer extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MB')
->suffix('MiB')
->required()
->columnSpan(2)
->numeric(),
->numeric()
->minValue(0),
]),
Forms\Components\Grid::make()
@@ -340,10 +352,11 @@ class EditServer extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->suffix('MB')
->suffix('MiB')
->required()
->columnSpan(2)
->numeric(),
->numeric()
->minValue(0),
]),
Forms\Components\Grid::make()
@@ -372,7 +385,8 @@ class EditServer extends EditRecord
->suffix('%')
->required()
->columnSpan(2)
->numeric(),
->numeric()
->minValue(0),
]),
Forms\Components\Grid::make()
@@ -417,7 +431,7 @@ class EditServer extends EditRecord
'limited', false => false,
})
->label('Swap Memory')->inlineLabel()
->suffix('MB')
->suffix('MiB')
->minValue(-1)
->columnSpan(2)
->required()
@@ -432,7 +446,7 @@ class EditServer extends EditRecord
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('oom_disabled')
Forms\Components\ToggleButtons::make('oom_killer')
->label('OOM Killer')->inlineLabel()->inline()
->columnSpan(2)
->options([
@@ -487,13 +501,6 @@ class EditServer extends EditRecord
->color('danger')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server))
->requiresConfirmation(),
Actions\DeleteAction::make('Force Delete')
->label('Force Delete')
->hidden()
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->withForce()->handle($server))
->requiresConfirmation(),
Actions\Action::make('console')
->label('Console')
->icon('tabler-terminal')
@@ -520,4 +527,35 @@ class EditServer extends EditRecord
ServerResource\RelationManagers\AllocationsRelationManager::class,
];
}
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool
{
$containsRuleIn = str($get('rules'))->explode('|')->reduce(
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
);
if ($component instanceof Forms\Components\Select) {
return $containsRuleIn;
}
if ($component instanceof Forms\Components\TextInput) {
return !$containsRuleIn;
}
throw new \Exception('Component type not supported: ' . $component::class);
}
private function getSelectOptionsFromRules(Forms\Get $get): array
{
$inRule = str($get('rules'))->explode('|')->reduce(
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''
);
return str($inRule)
->after('in:')
->explode(',')
->each(fn ($value) => str($value)->trim())
->mapWithKeys(fn ($value) => [$value => $value])
->all();
}
}

View File

@@ -31,7 +31,7 @@ class AllocationsRelationManager extends RelationManager
// ->actions
// ->groups
->columns([
Tables\Columns\TextColumn::make('ip_alias')->label('Alias'),
Tables\Columns\TextInputColumn::make('ip_alias')->label('Alias'),
Tables\Columns\TextColumn::make('ip')->label('IP'),
Tables\Columns\TextColumn::make('port')->label('Port'),
Tables\Columns\IconColumn::make('primary')
@@ -56,8 +56,8 @@ class AllocationsRelationManager extends RelationManager
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
])
->headerActions([
Tables\Actions\CreateAction::make()->label('Create Allocation'),
//Tables\Actions\AssociateAction::make()->label('Add Allocation'),
//TODO Tables\Actions\CreateAction::make()->label('Create Allocation'),
//TODO Tables\Actions\AssociateAction::make()->label('Add Allocation'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([

View File

@@ -15,6 +15,11 @@ class UserResource extends Resource
protected static ?string $recordTitleAttribute = 'username';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [
@@ -26,7 +31,6 @@ class UserResource extends Resource
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}

View File

@@ -1,66 +0,0 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Components\Section;
use Filament\Forms\Form;
use Illuminate\Support\Facades\Hash;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected static bool $canCreateAnother = false;
public function form(Form $form): Form
{
return $form
->schema([
Section::make()->schema([
Forms\Components\TextInput::make('username')->required()->maxLength(191),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(191),
Forms\Components\TextInput::make('password')
->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->disableOptionWhen(function (string $operation, $value, User $user) {
if ($operation !== 'edit' || $value) {
return false;
}
return $user->isLastRootAdmin();
})
->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '')
->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '')
->hintColor('warning')
->inline()
->required()
->default(false),
Forms\Components\Hidden::make('skipValidation')->default(true),
Forms\Components\Select::make('language')
->required()
->hidden()
->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()),
])->columns(2),
]);
}
}

View File

@@ -67,6 +67,12 @@ class EditUser extends EditRecord
{
return [
Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -4,10 +4,13 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Models\User;
use App\Services\Users\UserCreationService;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Table;
use Filament\Tables;
use Filament\Forms;
class ListUsers extends ListRecords
{
@@ -73,8 +76,49 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('Create User'),
Actions\CreateAction::make('create')
->label('Create User')
->createAnother(false)
->form([
Forms\Components\Grid::make()
->schema([
Forms\Components\TextInput::make('username')
->alphaNum()
->required()
->maxLength(191),
Forms\Components\TextInput::make('email')
->email()
->required()
->unique()
->maxLength(191),
Forms\Components\TextInput::make('password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false),
]),
])
->successRedirectUrl(route('filament.admin.resources.users.index'))
->action(function (array $data) {
resolve(UserCreationService::class)->handle($data);
Notification::make()->title('User Created!')->success()->send();
return redirect()->route('filament.admin.resources.users.index');
}),
];
}
}

View File

@@ -2,21 +2,12 @@
namespace App\Http\Controllers\Admin\Servers;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Models\Allocation;
use App\Models\Node;
use Carbon\CarbonImmutable;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Http\Request;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Token\Plain;
use Prologue\Alerts\AlertsMessageBag;
use App\Models\ServerTransfer;
use Illuminate\Database\ConnectionInterface;
use App\Http\Controllers\Controller;
use App\Services\Nodes\NodeJWTService;
use App\Models\Server;
use App\Services\Servers\TransferServerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Prologue\Alerts\AlertsMessageBag;
class ServerTransferController extends Controller
{
@@ -25,30 +16,10 @@ class ServerTransferController extends Controller
*/
public function __construct(
private AlertsMessageBag $alert,
private ConnectionInterface $connection,
private NodeJWTService $nodeJWTService,
private TransferServerService $transferServerService,
) {
}
private function notify(Server $server, Plain $token): void
{
try {
Http::daemon($server->node)->post('/api/transfer', [
'json' => [
'server_id' => $server->uuid,
'url' => $server->node->getConnectionAddress() . "/api/servers/$server->uuid/archive",
'token' => 'Bearer ' . $token->toString(),
'server' => [
'uuid' => $server->uuid,
'start_on_completion' => false,
],
],
])->toPsrResponse();
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Starts a transfer of a server to a new node.
*
@@ -62,85 +33,12 @@ class ServerTransferController extends Controller
'allocation_additional' => 'nullable',
]);
$node_id = $validatedData['node_id'];
$allocation_id = intval($validatedData['allocation_id']);
$additional_allocations = array_map('intval', $validatedData['allocation_additional'] ?? []);
// Check if the node is viable for the transfer.
$node = Node::query()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_listen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.id', $node_id)
->first();
if (!$node->isViable($server->memory, $server->disk)) {
if ($this->transferServerService->handle($server, $validatedData)) {
$this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
} else {
$this->alert->danger(trans('admin/server.alerts.transfer_not_viable'))->flash();
return redirect()->route('admin.servers.view.manage', $server->id);
}
$server->validateTransferState();
$this->connection->transaction(function () use ($server, $node_id, $allocation_id, $additional_allocations) {
// Create a new ServerTransfer entry.
$transfer = new ServerTransfer();
$transfer->server_id = $server->id;
$transfer->old_node = $server->node_id;
$transfer->new_node = $node_id;
$transfer->old_allocation = $server->allocation_id;
$transfer->new_allocation = $allocation_id;
$transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')->all();
$transfer->new_additional_allocations = $additional_allocations;
$transfer->save();
// Add the allocations to the server, so they cannot be automatically assigned while the transfer is in progress.
$this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations);
// Generate a token for the destination node that the source node can use to authenticate with.
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
// Notify the source node of the pending outgoing transfer.
$this->notify($server, $token);
return $transfer;
});
$this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
return redirect()->route('admin.servers.view.manage', $server->id);
}
/**
* Assigns the specified allocations to the specified server.
*/
private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations)
{
$allocations = $additional_allocations;
$allocations[] = $allocation_id;
$node = Node::query()->findOrFail($node_id);
$unassigned = $node->allocations()
->whereNull('server_id')
->pluck('id')
->toArray();
$updateIds = [];
foreach ($allocations as $allocation) {
if (!in_array($allocation, $unassigned)) {
continue;
}
$updateIds[] = $allocation;
}
if (!empty($updateIds)) {
Allocation::query()->whereIn('id', $updateIds)->update(['server_id' => $server->id]);
}
}
}

View File

@@ -126,7 +126,7 @@ class ServersController extends Controller
$this->buildModificationService->handle($server, $request->only([
'allocation_id', 'add_allocations', 'remove_allocations',
'memory', 'swap', 'io', 'cpu', 'threads', 'disk',
'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled',
'database_limit', 'allocation_limit', 'backup_limit', 'oom_killer',
]));
} catch (DataValidationException $exception) {
throw new ValidationException($exception->getValidator());

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Api\Application\DatabaseHosts;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\DatabaseHost;
use Spatie\QueryBuilder\QueryBuilder;
use App\Services\Databases\Hosts\HostUpdateService;
use App\Services\Databases\Hosts\HostCreationService;
use App\Transformers\Api\Application\DatabaseHostTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\DatabaseHosts\GetDatabaseHostRequest;
use App\Http\Requests\Api\Application\DatabaseHosts\StoreDatabaseHostRequest;
use App\Http\Requests\Api\Application\DatabaseHosts\DeleteDatabaseHostRequest;
use App\Http\Requests\Api\Application\DatabaseHosts\UpdateDatabaseHostRequest;
class DatabaseHostController extends ApplicationApiController
{
/**
* DatabaseHostController constructor.
*/
public function __construct(
private HostCreationService $creationService,
private HostUpdateService $updateService
) {
parent::__construct();
}
/**
* Return all the database hosts currently registered on the Panel.
*/
public function index(GetDatabaseHostRequest $request): array
{
$databases = QueryBuilder::for(DatabaseHost::query())
->allowedFilters(['name', 'host'])
->allowedSorts(['id', 'name', 'host'])
->paginate($request->query('per_page') ?? 10);
return $this->fractal->collection($databases)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->toArray();
}
/**
* Return a single database host.
*/
public function view(GetDatabaseHostRequest $request, DatabaseHost $databaseHost): array
{
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->toArray();
}
/**
* Store a new database host on the Panel and return an HTTP/201 response code with the
* new database host attached.
*
* @throws \Throwable
*/
public function store(StoreDatabaseHostRequest $request): JsonResponse
{
$databaseHost = $this->creationService->handle($request->validated());
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->addMeta([
'resource' => route('api.application.databases.view', [
'database_host' => $databaseHost->id,
]),
])
->respond(201);
}
/**
* Update a database host on the Panel and return the updated record to the user.
*
* @throws \Throwable
*/
public function update(UpdateDatabaseHostRequest $request, DatabaseHost $databaseHost): array
{
$databaseHost = $this->updateService->handle($databaseHost->id, $request->validated());
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->toArray();
}
/**
* Delete a database host from the Panel.
*
* @throws \Exception
*/
public function delete(DeleteDatabaseHostRequest $request, DatabaseHost $databaseHost): Response
{
$databaseHost->delete();
return $this->returnNoContent();
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Http\Controllers\Api\Application\Servers;
use Illuminate\Http\Response;
use App\Models\Server;
use App\Services\Servers\SuspensionService;
use App\Services\Servers\ReinstallServerService;
use App\Http\Requests\Api\Application\Servers\ServerWriteRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Servers\ServerWriteRequest;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Servers\ReinstallServerService;
use App\Services\Servers\SuspensionService;
use App\Services\Servers\TransferServerService;
use Illuminate\Http\Response;
class ServerManagementController extends ApplicationApiController
{
@@ -16,7 +18,9 @@ class ServerManagementController extends ApplicationApiController
*/
public function __construct(
private ReinstallServerService $reinstallServerService,
private SuspensionService $suspensionService
private SuspensionService $suspensionService,
private TransferServerService $transferServerService,
private DaemonServerRepository $daemonServerRepository,
) {
parent::__construct();
}
@@ -57,4 +61,44 @@ class ServerManagementController extends ApplicationApiController
return $this->returnNoContent();
}
/**
* Starts a transfer of a server to a new node.
*/
public function startTransfer(ServerWriteRequest $request, Server $server): Response
{
$validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable',
]);
if ($this->transferServerService->handle($server, $validatedData)) {
// Transfer started
$this->returnNoContent();
} else {
// Node was not viable
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
}
}
/**
* Cancels a transfer of a server to a new node.
*
* @throws \App\Exceptions\Http\Connection\DaemonConnectionException
*/
public function cancelTransfer(ServerWriteRequest $request, Server $server): Response
{
if (!$transfer = $server->transfer) {
// Server is not transferring
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
}
$transfer->successful = true;
$transfer->save();
$this->daemonServerRepository->setServer($server)->cancelTransfer();
return $this->returnNoContent();
}
}

View File

@@ -11,6 +11,7 @@ use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use App\Exceptions\Http\HttpForbiddenException;
class BackupRemoteUploadController extends Controller
{
@@ -32,18 +33,32 @@ class BackupRemoteUploadController extends Controller
*/
public function __invoke(Request $request, string $backup): JsonResponse
{
// Get the node associated with the request.
/** @var \App\Models\Node $node */
$node = $request->attributes->get('node');
// Get the size query parameter.
$size = (int) $request->query('size');
if (empty($size)) {
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
}
/** @var \App\Models\Backup $backup */
$backup = Backup::query()->where('uuid', $backup)->firstOrFail();
/** @var \App\Models\Backup $model */
$model = Backup::query()
->where('uuid', $backup)
->firstOrFail();
// Check that the backup is "owned" by the node making the request. This avoids other nodes
// from messing with backups that they don't own.
/** @var \App\Models\Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
}
// Prevent backups that have already been completed from trying to
// be uploaded again.
if (!is_null($backup->completed_at)) {
if (!is_null($model->completed_at)) {
throw new ConflictHttpException('This backup is already in a completed state.');
}
@@ -54,7 +69,7 @@ class BackupRemoteUploadController extends Controller
}
// The path where backup will be uploaded to
$path = sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid);
$path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid);
// Get the S3 client
$client = $adapter->getClient();
@@ -92,7 +107,7 @@ class BackupRemoteUploadController extends Controller
}
// Set the upload_id on the backup in the database.
$backup->update(['upload_id' => $params['UploadId']]);
$model->update(['upload_id' => $params['UploadId']]);
return new JsonResponse([
'parts' => $parts,

View File

@@ -13,6 +13,7 @@ use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use App\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
use App\Exceptions\Http\HttpForbiddenException;
class BackupStatusController extends Controller
{
@@ -30,8 +31,22 @@ class BackupStatusController extends Controller
*/
public function index(ReportBackupCompleteRequest $request, string $backup): JsonResponse
{
// Get the node associated with the request.
/** @var \App\Models\Node $node */
$node = $request->attributes->get('node');
/** @var \App\Models\Backup $model */
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$model = Backup::query()
->where('uuid', $backup)
->firstOrFail();
// Check that the backup is "owned" by the node making the request. This avoids other nodes
// from messing with backups that they don't own.
/** @var \App\Models\Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
}
if ($model->is_successful) {
throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.');

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteDatabaseHostRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_DATABASE_HOSTS;
protected int $permission = AdminAcl::WRITE;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetDatabaseHostRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_DATABASE_HOSTS;
protected int $permission = AdminAcl::READ;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Models\DatabaseHost;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreDatabaseHostRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_DATABASE_HOSTS;
protected int $permission = AdminAcl::WRITE;
public function rules(array $rules = null): array
{
return $rules ?? DatabaseHost::getRules();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Models\DatabaseHost;
class UpdateDatabaseHostRequest extends StoreDatabaseHostRequest
{
public function rules(array $rules = null): array
{
/** @var DatabaseHost $databaseHost */
$databaseHost = $this->route()->parameter('database_host');
return $rules ?? DatabaseHost::getRulesForUpdate($databaseHost->id);
}
}

View File

@@ -32,7 +32,7 @@ class StoreServerRequest extends ApplicationApiRequest
'startup' => $rules['startup'],
'environment' => 'present|array',
'skip_scripts' => 'sometimes|boolean',
'oom_disabled' => 'sometimes|boolean',
'oom_killer' => 'sometimes|boolean',
// Resource limitations
'limits' => 'required|array',
@@ -94,7 +94,7 @@ class StoreServerRequest extends ApplicationApiRequest
'database_limit' => array_get($data, 'feature_limits.databases'),
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
'backup_limit' => array_get($data, 'feature_limits.backups'),
'oom_disabled' => array_get($data, 'oom_disabled'),
'oom_killer' => array_get($data, 'oom_killer'),
];
}

View File

@@ -16,7 +16,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
return [
'allocation' => $rules['allocation_id'],
'oom_disabled' => $rules['oom_disabled'],
'oom_killer' => $rules['oom_killer'],
'limits' => 'sometimes|array',
'limits.memory' => $this->requiredToOptional('memory', $rules['memory'], true),

View File

@@ -19,9 +19,11 @@ class StoreScheduleRequest extends ViewScheduleRequest
return [
'name' => $rules['name'],
'is_active' => array_merge(['filled'], $rules['is_active']),
'only_when_online' => $rules['only_when_online'],
'minute' => $rules['cron_minute'],
'hour' => $rules['cron_hour'],
'day_of_month' => $rules['cron_day_of_month'],
'month' => $rules['cron_month'],
'day_of_week' => $rules['cron_day_of_week'],
];
}

View File

@@ -35,7 +35,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @property int $io
* @property int $cpu
* @property string|null $threads
* @property bool $oom_disabled
* @property bool $oom_killer
* @property int $allocation_id
* @property int $egg_id
* @property string $startup
@@ -90,7 +90,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @method static \Illuminate\Database\Eloquent\Builder|Server whereMemory($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOomDisabled($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOomKiller($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOwnerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereSkipScripts($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereStartup($value)
@@ -124,7 +124,7 @@ class Server extends Model
*/
protected $attributes = [
'status' => ServerState::Installing,
'oom_disabled' => true,
'oom_killer' => false,
'installed_at' => null,
];
@@ -150,7 +150,7 @@ class Server extends Model
'io' => 'required|numeric|between:0,1000',
'cpu' => 'required|numeric|min:0',
'threads' => 'nullable|regex:/^[0-9-,]+$/',
'oom_disabled' => 'sometimes|boolean',
'oom_killer' => 'sometimes|boolean',
'disk' => 'required|numeric|min:0',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'egg_id' => 'required|exists:eggs,id',
@@ -174,7 +174,7 @@ class Server extends Model
'disk' => 'integer',
'io' => 'integer',
'cpu' => 'integer',
'oom_disabled' => 'boolean',
'oom_killer' => 'boolean',
'allocation_id' => 'integer',
'egg_id' => 'integer',
'database_limit' => 'integer',
@@ -326,6 +326,11 @@ class Server extends Model
return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects');
}
public function getRouteKeyName(): string
{
return 'id';
}
public function resolveRouteBinding($value, $field = null): ?self
{
return match ($field) {

View File

@@ -6,6 +6,9 @@ use App\Extensions\Themes\Theme;
use App\Models;
use App\Models\ApiKey;
use App\Models\Node;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Broadcast;
@@ -66,6 +69,10 @@ class AppServiceProvider extends ServiceProvider
$this->bootAuth();
$this->bootBroadcast();
Scramble::registerApi('application', ['api_path' => 'api/application', 'info' => ['version' => '1.0']]);
Scramble::registerApi('client', ['api_path' => 'api/client', 'info' => ['version' => '1.0']]);
Scramble::registerApi('remote', ['api_path' => 'api/remote', 'info' => ['version' => '1.0']]);
}
/**
@@ -81,6 +88,9 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton('extensions.themes', function () {
return new Theme();
});
Scramble::extendOpenApi(fn (OpenApi $openApi) => $openApi->secure(SecurityScheme::http('bearer')));
Scramble::ignoreDefaultRoutes();
}
/**

View File

@@ -25,7 +25,7 @@ class AdminPanelProvider extends PanelProvider
public function boot()
{
FilamentAsset::registerCssVariables([
'sidebar-width' => '12rem !important',
'sidebar-width' => '14rem !important',
]);
}
@@ -35,10 +35,11 @@ class AdminPanelProvider extends PanelProvider
->default()
->id('admin')
->path('admin')
->topNavigation(config('panel.filament.top-navigation', false))
->login()
->brandName('Pelican')
->homeUrl('/')
->favicon('/pelican.ico')
->brandName('Pelican')
->profile(EditProfile::class, false)
->colors([
'danger' => Color::Red,

View File

@@ -141,6 +141,46 @@ class DaemonServerRepository extends DaemonRepository
}
}
/**
* Cancels a server transfer.
*
* @throws \App\Exceptions\Http\Connection\DaemonConnectionException
*/
public function cancelTransfer(): void
{
Assert::isInstanceOf($this->server, Server::class);
if ($transfer = $this->server->transfer) {
// Source node
$this->setNode($transfer->oldNode);
try {
$this->getHttpClient()->delete(sprintf(
'/api/servers/%s/transfer',
$this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
// Destination node
$this->setNode($transfer->newNode);
try {
$this->getHttpClient()->delete('/api/transfer', [
'json' => [
'server_id' => $this->server->uuid,
'server' => [
'uuid' => $this->server->uuid,
],
],
]);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}
/**
* Revokes a single user's JTI by using their ID. This is simply a helper function to
* make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted

View File

@@ -40,8 +40,12 @@ class BuildModificationService
throw_unless($existingAllocation, new DisplayException('The requested default allocation is not currently assigned to this server.'));
}
if (!isset($data['oom_killer']) && isset($data['oom_disabled'])) {
$data['oom_killer'] = !$data['oom_disabled'];
}
// If any of these values are passed through in the data array go ahead and set them correctly on the server model.
$merge = Arr::only($data, ['oom_disabled', 'memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']);
$merge = Arr::only($data, ['oom_killer', 'memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']);
$server->forceFill(array_merge($merge, [
'database_limit' => Arr::get($data, 'database_limit', 0) ?? null,

View File

@@ -59,14 +59,12 @@ class ServerConfigurationStructureService
'cpu_limit' => $server->cpu,
'threads' => $server->threads,
'disk_space' => $server->disk,
'oom_disabled' => $server->oom_disabled,
// This field is deprecated — use "oom_killer".
'oom_disabled' => !$server->oom_killer,
'oom_killer' => $server->oom_killer,
],
'container' => [
'image' => $server->image,
// This field is deprecated — use the value in the "build" block.
//
// TODO: remove this key in V2.
'oom_disabled' => $server->oom_disabled,
'requires_rebuild' => false,
],
'allocations' => [
@@ -110,7 +108,7 @@ class ServerConfigurationStructureService
return $item->pluck('port');
})->toArray(),
'env' => $this->environment->handle($server),
'oom_disabled' => $server->oom_disabled,
'oom_disabled' => !$server->oom_killer,
'memory' => (int) $server->memory,
'swap' => (int) $server->swap,
'io' => (int) $server->io,

View File

@@ -47,6 +47,10 @@ class ServerCreationService
*/
public function handle(array $data, DeploymentObject $deployment = null): Server
{
if (!isset($data['oom_killer']) && isset($data['oom_disabled'])) {
$data['oom_killer'] = !$data['oom_disabled'];
}
// If a deployment object has been passed we need to get the allocation
// that the server should use, and assign the node from that allocation.
if ($deployment instanceof DeploymentObject) {
@@ -142,7 +146,7 @@ class ServerCreationService
'io' => Arr::get($data, 'io'),
'cpu' => Arr::get($data, 'cpu'),
'threads' => Arr::get($data, 'threads'),
'oom_disabled' => Arr::get($data, 'oom_disabled') ?? true,
'oom_killer' => Arr::get($data, 'oom_killer') ?? false,
'allocation_id' => Arr::get($data, 'allocation_id'),
'egg_id' => Arr::get($data, 'egg_id'),
'startup' => Arr::get($data, 'startup'),

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Services\Servers;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Models\Allocation;
use App\Models\Node;
use App\Models\Server;
use App\Models\ServerTransfer;
use App\Services\Nodes\NodeJWTService;
use Carbon\CarbonImmutable;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Token\Plain;
class TransferServerService
{
/**
* TransferService constructor.
*/
public function __construct(
private ConnectionInterface $connection,
private NodeJWTService $nodeJWTService,
) {
}
private function notify(Server $server, Plain $token): void
{
try {
Http::daemon($server->node)->post('/api/transfer', [
'json' => [
'server_id' => $server->uuid,
'url' => $server->node->getConnectionAddress() . "/api/servers/$server->uuid/archive",
'token' => 'Bearer ' . $token->toString(),
'server' => [
'uuid' => $server->uuid,
'start_on_completion' => false,
],
],
])->toPsrResponse();
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Starts a transfer of a server to a new node.
*
* @throws \Throwable
*/
public function handle(Server $server, array $data): bool
{
$node_id = $data['node_id'];
$allocation_id = intval($data['allocation_id']);
$additional_allocations = array_map('intval', $data['allocation_additional'] ?? []);
// Check if the node is viable for the transfer.
$node = Node::query()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_listen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.id', $node_id)
->first();
if (!$node->isViable($server->memory, $server->disk)) {
return false;
}
$server->validateTransferState();
$this->connection->transaction(function () use ($server, $node_id, $allocation_id, $additional_allocations) {
// Create a new ServerTransfer entry.
$transfer = new ServerTransfer();
$transfer->server_id = $server->id;
$transfer->old_node = $server->node_id;
$transfer->new_node = $node_id;
$transfer->old_allocation = $server->allocation_id;
$transfer->new_allocation = $allocation_id;
$transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')->all();
$transfer->new_additional_allocations = $additional_allocations;
$transfer->save();
// Add the allocations to the server, so they cannot be automatically assigned while the transfer is in progress.
$this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations);
// Generate a token for the destination node that the source node can use to authenticate with.
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
// Notify the source node of the pending outgoing transfer.
$this->notify($server, $token);
return $transfer;
});
return true;
}
/**
* Assigns the specified allocations to the specified server.
*/
private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations)
{
$allocations = $additional_allocations;
$allocations[] = $allocation_id;
$node = Node::query()->findOrFail($node_id);
$unassigned = $node->allocations()
->whereNull('server_id')
->pluck('id')
->toArray();
$updateIds = [];
foreach ($allocations as $allocation) {
if (!in_array($allocation, $unassigned)) {
continue;
}
$updateIds[] = $allocation;
}
if (!empty($updateIds)) {
Allocation::query()->whereIn('id', $updateIds)->update(['server_id' => $server->id]);
}
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Transformers\Api\Application;
use App\Models\Node;
use App\Models\Database;
use App\Models\DatabaseHost;
use League\Fractal\Resource\Item;
use League\Fractal\Resource\Collection;
use League\Fractal\Resource\NullResource;
use App\Services\Acl\Api\AdminAcl;
@@ -12,6 +14,7 @@ class DatabaseHostTransformer extends BaseTransformer
{
protected array $availableIncludes = [
'databases',
'node',
];
/**
@@ -54,4 +57,20 @@ class DatabaseHostTransformer extends BaseTransformer
return $this->collection($model->getRelation('databases'), $this->makeTransformer(ServerDatabaseTransformer::class), Database::RESOURCE_NAME);
}
/**
* Include the node associated with this host.
*
* @throws \App\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeNode(DatabaseHost $model): Item|NullResource
{
if (!$this->authorize(AdminAcl::RESOURCE_NODES)) {
return $this->null();
}
$model->loadMissing('node');
return $this->item($model->getRelation('node'), $this->makeTransformer(NodeTransformer::class), Node::RESOURCE_NAME);
}
}

View File

@@ -65,7 +65,9 @@ class ServerTransformer extends BaseTransformer
'io' => $server->io,
'cpu' => $server->cpu,
'threads' => $server->threads,
'oom_disabled' => $server->oom_disabled,
// This field is deprecated, please use "oom_killer".
'oom_disabled' => !$server->oom_killer,
'oom_killer' => $server->oom_killer,
],
'feature_limits' => [
'databases' => $server->database_limit,

View File

@@ -56,7 +56,9 @@ class ServerTransformer extends BaseClientTransformer
'io' => $server->io,
'cpu' => $server->cpu,
'threads' => $server->threads,
'oom_disabled' => $server->oom_disabled,
// This field is deprecated, please use "oom_killer".
'oom_disabled' => !$server->oom_killer,
'oom_killer' => $server->oom_killer,
],
'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)),
'docker_image' => $server->image,

View File

@@ -9,20 +9,20 @@
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-zip": "*",
"abdelhamiderrahmouni/filament-monaco-editor": "^0.2.0",
"abdelhamiderrahmouni/filament-monaco-editor": "0.2.1",
"aws/aws-sdk-php": "~3.288.1",
"chillerlan/php-qrcode": "^5.0",
"dedoc/scramble": "^0.9.0",
"chillerlan/php-qrcode": "^5.0.2",
"dedoc/scramble": "^0.10.0",
"doctrine/dbal": "~3.6.0",
"filament/filament": "^3.2",
"guzzlehttp/guzzle": "^7.5",
"guzzlehttp/guzzle": "^7.8.1",
"hashids/hashids": "~5.0.0",
"laracasts/utilities": "~3.2.2",
"laravel/framework": "^11.0",
"laravel/framework": "^11.7",
"laravel/helpers": "^1.7",
"laravel/sanctum": "^4.0",
"laravel/sanctum": "^4.0.2",
"laravel/tinker": "^2.9",
"laravel/ui": "^4.4",
"laravel/ui": "^4.5.1",
"lcobucci/jwt": "~4.3.0",
"league/flysystem-aws-s3-v3": "~3.12.2",
"league/flysystem-memory": "~3.10.3",
@@ -32,25 +32,25 @@
"prologue/alerts": "^1.2",
"ryangjchandler/blade-tabler-icons": "^2.3",
"s1lentium/iptools": "~1.2.0",
"spatie/laravel-fractal": "^6.1",
"spatie/laravel-query-builder": "^5.8",
"symfony/mailgun-mailer": "^7.0",
"symfony/postmark-mailer": "^7.0",
"symfony/yaml": "^7.0",
"webbingbrasil/filament-copyactions": "^3.0",
"spatie/laravel-fractal": "^6.2",
"spatie/laravel-query-builder": "^5.8.1",
"symfony/mailgun-mailer": "^7.0.7",
"symfony/postmark-mailer": "^7.0.7",
"symfony/yaml": "^7.0.7",
"webbingbrasil/filament-copyactions": "^3.0.1",
"webmozart/assert": "~1.11.0"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.0",
"fakerphp/faker": "^1.23",
"fakerphp/faker": "^1.23.1",
"itsgoingd/clockwork": "~5.1.12",
"larastan/larastan": "^2.8",
"laravel/pint": "^1.14",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^10.5",
"spatie/laravel-ignition": "^2.4"
"larastan/larastan": "^2.9.6",
"laravel/pint": "^1.15.3",
"laravel/sail": "^1.29.1",
"mockery/mockery": "^1.6.11",
"nunomaduro/collision": "^8.1.1",
"phpunit/phpunit": "^10.5.20",
"spatie/laravel-ignition": "^2.7"
},
"autoload": {
"files": [

709
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@ use Illuminate\Support\Facades\Facade;
return [
'name' => 'Pelican',
'name' => env('APP_NAME', 'Pelican'),
'version' => 'canary',
'version' => '1.0.0-beta2',
'exceptions' => [
'report_all' => env('APP_REPORT_ALL_EXCEPTIONS', false),

View File

@@ -46,6 +46,8 @@ return [
],
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'default' => [
'scheme' => env('REDIS_SCHEME', 'tcp'),
'path' => env('REDIS_PATH', '/run/redis/redis.sock'),

View File

@@ -163,4 +163,16 @@ return [
// Should an email be sent to a server owner whenever their server is reinstalled?
'send_reinstall_notification' => env('PANEL_SEND_REINSTALL_NOTIFICATION', true),
],
/*
|--------------------------------------------------------------------------
| FilamentPHP Settings
|--------------------------------------------------------------------------
|
| This section controls Filament configurations
*/
'filament' => [
'top-navigation' => env('FILAMENT_TOP_NAVIGATION', false),
],
];

View File

@@ -35,7 +35,7 @@ class ServerFactory extends Factory
'io' => 500,
'cpu' => 0,
'threads' => null,
'oom_disabled' => 0,
'oom_killer' => false,
'startup' => '/bin/bash echo "hello world"',
'image' => 'foo/bar:latest',
'allocation_limit' => null,

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->tinyInteger('oom_killer')->unsigned()->default(0)->after('oom_disabled');
});
DB::table('servers')->select(['id', 'oom_disabled'])->cursor()->each(function ($server) {
DB::table('servers')->where('id', $server->id)->update(['oom_killer' => !$server->oom_disabled]);
});
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('oom_disabled');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->tinyInteger('oom_disabled')->unsigned()->default(0)->after('oom_killer');
});
DB::table('servers')->select(['id', 'oom_killer'])->cursor()->each(function ($server) {
DB::table('servers')->where('id', $server->id)->update(['oom_disabled' => !$server->oom_killer]);
});
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('oom_killer');
});
}
};

View File

@@ -495,7 +495,7 @@ CREATE TABLE `servers` (
`io` int unsigned NOT NULL,
`cpu` int unsigned NOT NULL,
`threads` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`oom_disabled` tinyint unsigned NOT NULL DEFAULT '0',
`oom_killer` tinyint unsigned NOT NULL DEFAULT '0',
`allocation_id` int unsigned NOT NULL,
`egg_id` int unsigned NOT NULL,
`startup` text COLLATE utf8mb4_unicode_ci NOT NULL,
@@ -844,3 +844,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (197,'2024_03_12_15
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (198,'2024_03_14_055537_remove_locations_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (201,'2024_04_20_214441_add_egg_var_sort',3);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (203,'2024_04_14_002250_update_column_names',4);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (204,'2024_05_08_094823_rename_oom_disabled_column_to_oom_killer',1);

View File

@@ -17,7 +17,6 @@
<env name="MAIL_MAILER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_DRIVER" value="array"/>
</php>
<source>
<include>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
![image](https://github.com/pelican-dev/panel/assets/1296882/6c545ae3-24b9-4d30-af21-d08d2e53b673)
<img width="20%" src="https://raw.githubusercontent.com/pelican-dev/panel/main/public/pelican.svg" alt="logo">
# Pelican Panel

View File

@@ -203,8 +203,8 @@
</div>
<div class="form-group col-xs-12">
<div class="checkbox checkbox-primary no-margin-bottom">
<input type="checkbox" id="pOomDisabled" name="oom_disabled" value="0" {{ \App\Helpers\Utilities::checked('oom_disabled', 0) }} />
<label for="pOomDisabled" class="strong">Enable OOM Killer</label>
<input type="checkbox" id="pOomKiller" name="oom_killer" value="0" {{ \App\Helpers\Utilities::checked('oom_killer', 0) }} />
<label for="pOomKiller" class="strong">Enable OOM Killer</label>
</div>
<p class="small text-muted no-margin">Terminates the server if it breaches the memory limits. Enabling OOM killer may cause server processes to exit unexpectedly.</p>

View File

@@ -74,11 +74,11 @@
<label for="cpu" class="control-label">OOM Killer</label>
<div>
<div class="radio radio-danger radio-inline">
<input type="radio" id="pOomKillerEnabled" value="0" name="oom_disabled" @if(!$server->oom_disabled)checked @endif>
<input type="radio" id="pOomKillerEnabled" value="1" name="oom_killer" @if(!$server->oom_killer)checked @endif>
<label for="pOomKillerEnabled">Enabled</label>
</div>
<div class="radio radio-success radio-inline">
<input type="radio" id="pOomKillerDisabled" value="1" name="oom_disabled" @if($server->oom_disabled)checked @endif>
<input type="radio" id="pOomKillerDisabled" value="0" name="oom_killer" @if($server->oom_killer)checked @endif>
<label for="pOomKillerDisabled">Disabled</label>
</div>
<p class="text-muted small">

View File

@@ -1,37 +1,5 @@
<x-filament-panels::page>
<x-filament::tabs disabled>
<x-filament::tabs.item disabled>{{ trans('dashboard/index.overview') }} </x-filament::tabs.item>
<x-filament::tabs.item
icon="tabler-server-2"
>
Nodes
<x-slot name="badge">{{ $nodesCount }}</x-slot>
</x-filament::tabs.item>
<x-filament::tabs.item
icon="tabler-brand-docker"
>
Servers
<x-slot name="badge">{{ $serversCount }}</x-slot>
</x-filament::tabs.item>
<x-filament::tabs.item
icon="tabler-eggs"
>
Eggs
<x-slot name="badge">{{ $eggsCount }}</x-slot>
</x-filament::tabs.item>
<x-filament::tabs.item
icon="tabler-users"
>
Users
<x-slot name="badge">{{ $usersCount }}</x-slot>
</x-filament::tabs.item>
</x-filament::tabs>
<x-filament-panels::header
:actions="$this->getCachedHeaderActions()"
:breadcrumbs="filament()->hasBreadcrumbs() ? $this->getBreadcrumbs() : []"

View File

@@ -1,20 +0,0 @@
<!doctype html>
<html lang="en" data-theme="{{ config('scramble.theme', 'dark') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Pelican - {{ str($api ?? 'all')->title() }} API Docs</title>
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
</head>
<body style="height: 100vh; overflow-y: hidden">
<elements-api
apiDescriptionUrl="{{ route('scramble.docs.' . $api ?? 'all') }}"
tryItCredentialsPolicy="{{ config('scramble.ui.try_it_credentials_policy', 'include') }}"
router="hash"
@if(config('scramble.ui.hide_try_it')) hideTryIt="true" @endif
logo="/pelican.svg"
/>
</body>
</html>

View File

@@ -69,6 +69,8 @@ Route::prefix('/servers')->group(function () {
Route::post('/{server:id}/suspend', [Application\Servers\ServerManagementController::class, 'suspend'])->name('api.application.servers.suspend');
Route::post('/{server:id}/unsuspend', [Application\Servers\ServerManagementController::class, 'unsuspend'])->name('api.application.servers.unsuspend');
Route::post('/{server:id}/reinstall', [Application\Servers\ServerManagementController::class, 'reinstall'])->name('api.application.servers.reinstall');
Route::post('/{server:id}/transfer', [Application\Servers\ServerManagementController::class, 'startTransfer'])->name('api.application.servers.transfer');
Route::post('/{server:id}/transfer/cancel', [Application\Servers\ServerManagementController::class, 'cancelTransfer'])->name('api.application.servers.transfer.cancel');
Route::delete('/{server:id}', [Application\Servers\ServerController::class, 'delete']);
Route::delete('/{server:id}/{force?}', [Application\Servers\ServerController::class, 'delete']);
@@ -97,3 +99,22 @@ Route::prefix('/eggs')->group(function () {
Route::get('/', [Application\Eggs\EggController::class, 'index'])->name('api.application.eggs.eggs');
Route::get('/{egg:id}', [Application\Eggs\EggController::class, 'view'])->name('api.application.eggs.eggs.view');
});
/*
|--------------------------------------------------------------------------
| Database Host Controller Routes
|--------------------------------------------------------------------------
|
| Endpoint: /api/application/database-hosts
|
*/
Route::group(['prefix' => '/database-hosts'], function () {
Route::get('/', [Application\DatabaseHosts\DatabaseHostController::class, 'index'])->name('api.application.databasehosts');
Route::get('/{database_host:id}', [Application\DatabaseHosts\DatabaseHostController::class, 'view'])->name('api.application.databasehosts.view');
Route::post('/', [Application\DatabaseHosts\DatabaseHostController::class, 'store']);
Route::patch('/{database_host:id}', [Application\DatabaseHosts\DatabaseHostController::class, 'update']);
Route::delete('/{database_host:id}', [Application\DatabaseHosts\DatabaseHostController::class, 'delete']);
});

View File

@@ -1,51 +1,21 @@
<?php
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'api'], function () {
Scramble::extendOpenApi(fn (OpenApi $openApi) => $openApi->secure(SecurityScheme::http('bearer')));
Scramble::registerUiRoute(path: 'application', api: 'application');
Scramble::registerJsonSpecificationRoute(path: 'application.json', api: 'application');
Route::view('application', 'scramble::docs', ['api' => 'application'])->name('scramble.docs.api.application');
Route::view('client', 'scramble::docs', ['api' => 'client'])->name('scramble.docs.api.client');
Route::view('remote', 'scramble::docs', ['api' => 'remote'])->name('scramble.docs.api.remote');
Scramble::registerUiRoute(path: 'client', api: 'client');
Scramble::registerJsonSpecificationRoute(path: 'client.json', api: 'client');
Route::get('application.json', function (Dedoc\Scramble\Generator $generator) {
config()->set('scramble.api_path', 'api/application');
config()->set('scramble.info.description', '
These are the Application API endpoints for admins.
They let you interact with your Panel on a root basis.
');
return $generator();
})->name('scramble.docs.application');
Route::get('client.json', function (Dedoc\Scramble\Generator $generator) {
config()->set('scramble.api_path', 'api/client');
config()->set('scramble.info.description', '
These are the Client API endpoints for individual users.
They let your users interact with your Panel.
');
return $generator();
})->name('scramble.docs.client');
Route::get('remote.json', function (Dedoc\Scramble\Generator $generator) {
config()->set('scramble.api_path', 'api/remote');
config()->set('scramble.info.description', '
These are the Remote API endpoints for Wings.
They let Wings interact with your Panel.
');
return $generator();
})->name('scramble.docs.remote');
Scramble::registerUiRoute(path: 'remote', api: 'remote');
Scramble::registerJsonSpecificationRoute(path: 'remote.json', api: 'remote');
Route::get('', fn () => '
<li><a href="/docs/api/application">Application API for Admins</a></li>
<li><a href="/docs/api/client">Client API for Users</a></li>
<li><a href="/docs/api/remote">Daemon API for Wings</a></li>
');
})->middleware(config('scramble.middleware', [RestrictedDocsAccess::class]));
});

View File

@@ -57,7 +57,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
$response = $this->actingAs($user)->postJson("/api/client/servers/$server->uuid/schedules", []);
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
foreach (['name', 'minute', 'hour', 'day_of_month', 'day_of_week'] as $i => $field) {
foreach (['name', 'minute', 'hour', 'day_of_month', 'month', 'day_of_week'] as $i => $field) {
$response->assertJsonPath("errors.$i.code", 'ValidationException');
$response->assertJsonPath("errors.$i.meta.rule", 'required');
$response->assertJsonPath("errors.$i.meta.source_field", $field);
@@ -67,6 +67,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
->postJson("/api/client/servers/$server->uuid/schedules", [
'name' => 'Testing',
'is_active' => 'no',
'only_when_online' => 'false',
'minute' => '*',
'hour' => '*',
'day_of_month' => '*',

View File

@@ -114,7 +114,7 @@ class BuildModificationServiceTest extends IntegrationTestCase
$this->daemonServerRepository->expects('sync')->withNoArgs()->andReturnUndefined();
$response = $this->getService()->handle($server, [
'oom_disabled' => false,
'oom_killer' => false,
'memory' => 256,
'swap' => 128,
'io' => 600,
@@ -126,7 +126,7 @@ class BuildModificationServiceTest extends IntegrationTestCase
'allocation_limit' => 20,
]);
$this->assertFalse($response->oom_disabled);
$this->assertFalse($response->oom_killer);
$this->assertSame(256, $response->memory);
$this->assertSame(128, $response->swap);
$this->assertSame(600, $response->io);

View File

@@ -138,7 +138,7 @@ class ServerCreationServiceTest extends IntegrationTestCase
$this->assertSame($allocations[4]->id, $response->allocations[1]->id);
$this->assertFalse($response->isSuspended());
$this->assertTrue($response->oom_disabled);
$this->assertFalse($response->oom_killer);
$this->assertSame(0, $response->database_limit);
$this->assertSame(0, $response->allocation_limit);
$this->assertSame(0, $response->backup_limit);