Merge branch 'main' into filament-v4

This commit is contained in:
notCharles 2025-07-19 16:45:20 -04:00
commit 90a3f38750
36 changed files with 667 additions and 240 deletions

View File

@ -275,9 +275,9 @@ class ListServers extends ListRecords
} else { } else {
return [ return [
ActionGroup::make($actions) ActionGroup::make($actions)
->icon(fn (Server $server) => $server->condition->getIcon()) ->icon('tabler-power')
->color(fn (Server $server) => $server->condition->getColor()) ->color('primary')
->tooltip(fn (Server $server) => $server->condition->getLabel()) ->tooltip('Power Actions')
->iconSize(IconSize::Large), ->iconSize(IconSize::Large),
]; ];
} }

View File

@ -0,0 +1,53 @@
<?php
namespace App\Filament\Components\Forms\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Get;
use Filament\Forms\Set;
class CronPresetAction extends Action
{
protected string $minute = '0';
protected string $hour = '0';
protected string $dayOfMonth = '*';
protected string $month = '*';
protected string $dayOfWeek = '*';
protected function setUp(): void
{
parent::setUp();
$this->disabled(fn (string $operation) => $operation === 'view');
$this->color(fn (Get $get) => $get('cron_minute') == $this->minute &&
$get('cron_hour') == $this->hour &&
$get('cron_day_of_month') == $this->dayOfMonth &&
$get('cron_month') == $this->month &&
$get('cron_day_of_week') == $this->dayOfWeek
? 'success' : 'primary');
$this->action(function (Set $set) {
$set('cron_minute', $this->minute);
$set('cron_hour', $this->hour);
$set('cron_day_of_month', $this->dayOfMonth);
$set('cron_month', $this->month);
$set('cron_day_of_week', $this->dayOfWeek);
});
}
public function cron(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): static
{
$this->minute = $minute;
$this->hour = $hour;
$this->dayOfMonth = $dayOfMonth;
$this->month = $month;
$this->dayOfWeek = $dayOfWeek;
return $this;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Components\Actions;
use App\Models\Permission;
use App\Models\Schedule;
use App\Models\Server;
use App\Services\Schedules\Sharing\ScheduleExporterService;
use Filament\Actions\Action;
use Filament\Facades\Filament;
class ExportScheduleAction extends Action
{
public static function getDefaultName(): ?string
{
return 'export';
}
protected function setUp(): void
{
parent::setUp();
/** @var Server $server */
$server = Filament::getTenant();
$this->label(trans('filament-actions::export.modal.actions.export.label'));
$this->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_READ, $server));
$this->action(fn (ScheduleExporterService $service, Schedule $schedule) => response()->streamDownload(function () use ($service, $schedule) {
echo $service->handle($schedule);
}, 'schedule-' . str($schedule->name)->kebab()->lower()->trim() . '.json'));
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Filament\Components\Actions;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Schedules\Sharing\ScheduleImporterService;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Illuminate\Support\Arr;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportScheduleAction extends Action
{
public static function getDefaultName(): ?string
{
return 'import';
}
protected function setUp(): void
{
parent::setUp();
/** @var Server $server */
$server = Filament::getTenant();
$this->label(trans('filament-actions::import.modal.actions.import.label'));
$this->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_CREATE, $server));
$this->form([
Tabs::make('Tabs')
->contained(false)
->tabs([
Tab::make(trans('admin/schedule.import.file'))
->icon('tabler-file-upload')
->schema([
FileUpload::make('files')
->label(trans('admin/schedule.model_label'))
->hint(trans('admin/schedule.import.schedule_help'))
->acceptedFileTypes(['application/json'])
->preserveFilenames()
->previewable(false)
->storeFiles(false)
->multiple(true),
]),
Tab::make(trans('admin/schedule.import.url'))
->icon('tabler-world-upload')
->schema([
Repeater::make('urls')
->label('')
->itemLabel(fn (array $state) => str($state['url'])->afterLast('/schedule-')->before('.json')->headline())
->hint(trans('admin/schedule.import.url_help'))
->addActionLabel(trans('admin/schedule.import.add_url'))
->grid(2)
->reorderable(false)
->addable(true)
->deletable(fn (array $state) => count($state) > 1)
->schema([
TextInput::make('url')
->live()
->label(trans('admin/schedule.import.url'))
->url()
->endsWith('.json')
->validationAttribute(trans('admin/schedule.import.url')),
]),
]),
]),
]);
$this->action(function (array $data, ScheduleImporterService $service) use ($server) {
$schedules = array_merge(collect($data['urls'])->flatten()->whereNotNull()->unique()->all(), Arr::wrap($data['files']));
if (empty($schedules)) {
return;
}
[$success, $failed] = [collect(), collect()];
foreach ($schedules as $schedule) {
if ($schedule instanceof TemporaryUploadedFile) {
$name = str($schedule->getClientOriginalName())->afterLast('schedule-')->before('.json')->headline();
$method = 'fromFile';
} else {
$schedule = str($schedule);
$schedule = $schedule->contains('github.com') ? $schedule->replaceFirst('blob', 'raw') : $schedule;
$name = $schedule->afterLast('/schedule-')->before('.json')->headline();
$method = 'fromUrl';
}
try {
$service->$method($schedule, $server);
$success->push($name);
} catch (Exception $exception) {
$failed->push($name);
report($exception);
}
}
if ($failed->count() > 0) {
Notification::make()
->title(trans('admin/schedule.import.import_failed'))
->body($failed->join(', '))
->danger()
->send();
}
if ($success->count() > 0) {
Notification::make()
->title(trans('admin/schedule.import.import_success'))
->body($success->join(', '))
->success()
->send();
}
});
}
}

View File

@ -144,8 +144,12 @@ class Console extends Page
#[On('console-status')] #[On('console-status')]
public function receivedConsoleUpdate(?string $state = null): void public function receivedConsoleUpdate(?string $state = null): void
{ {
/** @var Server $server */
$server = Filament::getTenant();
if ($state) { if ($state) {
$this->status = ContainerStatus::from($state); $this->status = ContainerStatus::from($state);
cache()->put("servers.$server->uuid.status", $this->status, now()->addSeconds(15));
} }
$this->headerActions($this->getHeaderActions()); $this->headerActions($this->getHeaderActions());

View File

@ -7,6 +7,7 @@ use App\Filament\Server\Resources\ScheduleResource\Pages\CreateSchedule;
use App\Filament\Server\Resources\ScheduleResource\Pages\ViewSchedule; use App\Filament\Server\Resources\ScheduleResource\Pages\ViewSchedule;
use App\Filament\Server\Resources\ScheduleResource\Pages\EditSchedule; use App\Filament\Server\Resources\ScheduleResource\Pages\EditSchedule;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Components\Forms\Actions\CronPresetAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn; use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager; use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager;
use App\Helpers\Utilities; use App\Helpers\Utilities;
@ -19,7 +20,6 @@ use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable; use App\Traits\Filament\CanModifyTable;
use Carbon\Carbon; use Carbon\Carbon;
use Exception; use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
@ -29,19 +29,21 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Support\Enums\Operation; use Filament\Schemas\Schema;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Filament\Schemas\Schema; use Illuminate\Support\HtmlString;
use Throwable; use Throwable;
class ScheduleResource extends Resource class ScheduleResource extends Resource
@ -85,44 +87,20 @@ class ScheduleResource extends Resource
{ {
return $schema return $schema
->columns([ ->columns([
'default' => 4, 'default' => 1,
'lg' => 5, 'lg' => 2,
]) ])
->components([ ->components([
TextInput::make('name') TextInput::make('name')
->columnSpan([
'default' => 4,
'md' => 3,
'lg' => 4,
])
->label('Schedule Name') ->label('Schedule Name')
->placeholder('A human readable identifier for this schedule.') ->placeholder('A human readable identifier for this schedule.')
->autocomplete(false) ->autocomplete(false)
->required(), ->required(),
// TODO conditional ->hiddenOn, ->visibleOn appear broken?
// ToggleButtons::make('Status')
// ->hiddenOn(Operation::Create)
// ->formatStateUsing(fn (Schedule $schedule) => !$schedule->is_active ? 'inactive' : ($schedule->is_processing ? 'processing' : 'active'))
// ->options(fn (Schedule $schedule) => !$schedule->is_active ? ['inactive' => 'Inactive'] : ($schedule->is_processing ? ['processing' => 'Processing'] : ['active' => 'Active']))
// ->colors([
// 'inactive' => 'danger',
// 'processing' => 'warning',
// 'active' => 'success',
// ])
// ->columnSpan([
// 'default' => 4,
// 'md' => 1,
// 'lg' => 1,
// ]),
Toggle::make('only_when_online') Toggle::make('only_when_online')
->label('Only when Server is Online?') ->label('Only when Server is Online?')
->hintIconTooltip('Only execute this schedule when the server is in a running state.') ->hintIconTooltip('Only execute this schedule when the server is in a running state.')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->inline(false) ->inline(false)
->columnSpan([
'default' => 2,
'lg' => 3,
])
->required() ->required()
->default(1), ->default(1),
Toggle::make('is_active') Toggle::make('is_active')
@ -130,97 +108,41 @@ class ScheduleResource extends Resource
->hintIconTooltip('This schedule will be executed automatically if enabled.') ->hintIconTooltip('This schedule will be executed automatically if enabled.')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->inline(false) ->inline(false)
->columnSpan([ ->hiddenOn('view')
'default' => 2,
'lg' => 2,
])
->required() ->required()
->default(1), ->default(1),
TextInput::make('cron_minute') ToggleButtons::make('Status')
->columnSpan([ ->formatStateUsing(fn (Schedule $schedule) => !$schedule->is_active ? 'inactive' : ($schedule->is_processing ? 'processing' : 'active'))
'default' => 2, ->options(fn (Schedule $schedule) => !$schedule->is_active ? ['inactive' => 'Inactive'] : ($schedule->is_processing ? ['processing' => 'Processing'] : ['active' => 'Active']))
'lg' => 1, ->colors([
'inactive' => 'danger',
'processing' => 'warning',
'active' => 'success',
]) ])
->label('Minute') ->visibleOn('view'),
->default('*/5') Section::make('Cron')
->required(), ->description(fn (Get $get) => new HtmlString('Please keep in mind that the cron inputs below always assume UTC.<br>Next run in your timezone (' . auth()->user()->timezone . '): <b>'. Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(auth()->user()->timezone) . '</b>'))
TextInput::make('cron_hour')
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Hour')
->default('*')
->required(),
TextInput::make('cron_day_of_month')
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Day of Month')
->default('*')
->required(),
TextInput::make('cron_month')
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Month')
->default('*')
->required(),
TextInput::make('cron_day_of_week')
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Day of Week')
->default('*')
->required(),
Section::make('Presets')
->hiddenOn('view')
->columns(1)
->columnSpanFull()
->schema([ ->schema([
Actions::make([ Actions::make([
Action::make('hourly') CronPresetAction::make('hourly')
->disabled(fn (string $operation) => $operation === 'view') ->cron('0', '*', '*', '*', '*'),
->action(function (Set $set) { CronPresetAction::make('daily')
$set('cron_minute', '0'); ->cron('0', '0', '*', '*', '*'),
$set('cron_hour', '*'); CronPresetAction::make('weekly_monday')
$set('cron_day_of_month', '*'); ->label('Weekly (Monday)')
$set('cron_month', '*'); ->cron('0', '0', '*', '*', '1'),
$set('cron_day_of_week', '*'); CronPresetAction::make('weekly_sunday')
}), ->label('Weekly (Sunday)')
Action::make('daily') ->cron('0', '0', '*', '*', '0'),
->disabled(fn (string $operation) => $operation === 'view') CronPresetAction::make('monthly')
->action(function (Set $set) { ->cron('0', '0', '1', '*', '*'),
$set('cron_minute', '0'); CronPresetAction::make('every_x_minutes')
$set('cron_hour', '0'); ->color(fn (Get $get) => str($get('cron_minute'))->startsWith('*/')
$set('cron_day_of_month', '*'); && $get('cron_hour') == '*'
$set('cron_month', '*'); && $get('cron_day_of_month') == '*'
$set('cron_day_of_week', '*'); && $get('cron_month') == '*'
}), && $get('cron_day_of_week') == '*' ? 'success' : 'primary')
Action::make('weekly') ->form([
->disabled(fn (string $operation) => $operation === 'view')
->action(function (Set $set) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', '0');
}),
Action::make('monthly')
->disabled(fn (string $operation) => $operation === 'view')
->action(function (Set $set) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '1');
$set('cron_month', '*');
$set('cron_day_of_week', '0');
}),
Action::make('every_x_minutes')
->disabled(fn (string $operation) => $operation === 'view')
->schema([
TextInput::make('x') TextInput::make('x')
->label('') ->label('')
->numeric() ->numeric()
@ -236,9 +158,13 @@ class ScheduleResource extends Resource
$set('cron_month', '*'); $set('cron_month', '*');
$set('cron_day_of_week', '*'); $set('cron_day_of_week', '*');
}), }),
Action::make('every_x_hours') CronPresetAction::make('every_x_hours')
->disabled(fn (string $operation) => $operation === 'view') ->color(fn (Get $get) => $get('cron_minute') == '0'
->schema([ && str($get('cron_hour'))->startsWith('*/')
&& $get('cron_day_of_month') == '*'
&& $get('cron_month') == '*'
&& $get('cron_day_of_week') == '*' ? 'success' : 'primary')
->form([
TextInput::make('x') TextInput::make('x')
->label('') ->label('')
->numeric() ->numeric()
@ -254,9 +180,13 @@ class ScheduleResource extends Resource
$set('cron_month', '*'); $set('cron_month', '*');
$set('cron_day_of_week', '*'); $set('cron_day_of_week', '*');
}), }),
Action::make('every_x_days') CronPresetAction::make('every_x_days')
->disabled(fn (string $operation) => $operation === 'view') ->color(fn (Get $get) => $get('cron_minute') == '0'
->schema([ && $get('cron_hour') == '0'
&& str($get('cron_day_of_month'))->startsWith('*/')
&& $get('cron_month') == '*'
&& $get('cron_day_of_week') == '*' ? 'success' : 'primary')
->form([
TextInput::make('x') TextInput::make('x')
->label('') ->label('')
->numeric() ->numeric()
@ -272,9 +202,13 @@ class ScheduleResource extends Resource
$set('cron_month', '*'); $set('cron_month', '*');
$set('cron_day_of_week', '*'); $set('cron_day_of_week', '*');
}), }),
Action::make('every_x_months') CronPresetAction::make('every_x_months')
->disabled(fn (string $operation) => $operation === 'view') ->color(fn (Get $get) => $get('cron_minute') == '0'
->schema([ && $get('cron_hour') == '0'
&& $get('cron_day_of_month') == '1'
&& str($get('cron_month'))->startsWith('*/')
&& $get('cron_day_of_week') == '*' ? 'success' : 'primary')
->form([
TextInput::make('x') TextInput::make('x')
->label('') ->label('')
->numeric() ->numeric()
@ -290,9 +224,13 @@ class ScheduleResource extends Resource
$set('cron_month', '*/' . $data['x']); $set('cron_month', '*/' . $data['x']);
$set('cron_day_of_week', '*'); $set('cron_day_of_week', '*');
}), }),
Action::make('every_x_day_of_week') CronPresetAction::make('every_x_day_of_week')
->disabled(fn (string $operation) => $operation === 'view') ->color(fn (Get $get) => $get('cron_minute') == '0'
->schema([ && $get('cron_hour') == '0'
&& $get('cron_day_of_month') == '*'
&& $get('cron_month') == '*'
&& $get('cron_day_of_week') != '*' ? 'success' : 'primary')
->form([
Select::make('x') Select::make('x')
->label('') ->label('')
->prefix('Every') ->prefix('Every')
@ -315,7 +253,43 @@ class ScheduleResource extends Resource
$set('cron_month', '*'); $set('cron_month', '*');
$set('cron_day_of_week', $data['x']); $set('cron_day_of_week', $data['x']);
}), }),
]), ])
->hiddenOn('view'),
Group::make([
TextInput::make('cron_minute')
->label('Minute')
->default('*/5')
->required()
->live(),
TextInput::make('cron_hour')
->label('Hour')
->default('*')
->required()
->live(),
TextInput::make('cron_day_of_month')
->label('Day of Month')
->default('*')
->required()
->live(),
TextInput::make('cron_month')
->label('Month')
->default('*')
->required()
->live(),
TextInput::make('cron_day_of_week')
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Day of Week')
->default('*')
->required()
->live(),
])
->columns([
'default' => 4,
'lg' => 5,
]),
]), ]),
]); ]);
} }

View File

@ -3,6 +3,7 @@
namespace App\Filament\Server\Resources\ScheduleResource\Pages; namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Components\Actions\ExportScheduleAction;
use App\Filament\Server\Resources\ScheduleResource; use App\Filament\Server\Resources\ScheduleResource;
use App\Models\Schedule; use App\Models\Schedule;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -52,6 +53,7 @@ class EditSchedule extends EditRecord
->property('name', $record->name) ->property('name', $record->name)
->log(); ->log();
}), }),
ExportScheduleAction::make(),
$this->getSaveFormAction()->formId('form')->label('Save'), $this->getSaveFormAction()->formId('form')->label('Save'),
$this->getCancelFormAction()->formId('form'), $this->getCancelFormAction()->formId('form'),
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Filament\Server\Resources\ScheduleResource\Pages; namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Components\Actions\ImportScheduleAction;
use App\Filament\Server\Resources\ScheduleResource; use App\Filament\Server\Resources\ScheduleResource;
use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeHeaderWidgets;
@ -23,6 +24,7 @@ class ListSchedules extends ListRecords
return [ return [
CreateAction::make() CreateAction::make()
->label('New Schedule'), ->label('New Schedule'),
ImportScheduleAction::make(),
]; ];
} }

View File

@ -38,7 +38,7 @@ class DatabaseHostController extends ApplicationApiController
*/ */
public function index(GetDatabaseHostRequest $request): array public function index(GetDatabaseHostRequest $request): array
{ {
$databases = QueryBuilder::for(DatabaseHost::query()) $databases = QueryBuilder::for(DatabaseHost::class)
->allowedFilters(['name', 'host']) ->allowedFilters(['name', 'host'])
->allowedSorts(['id', 'name', 'host']) ->allowedSorts(['id', 'name', 'host'])
->paginate($request->query('per_page') ?? 10); ->paginate($request->query('per_page') ?? 10);

View File

@ -16,6 +16,12 @@ use App\Http\Requests\Api\Application\Mounts\StoreMountRequest;
use App\Http\Requests\Api\Application\Mounts\DeleteMountRequest; use App\Http\Requests\Api\Application\Mounts\DeleteMountRequest;
use App\Http\Requests\Api\Application\Mounts\UpdateMountRequest; use App\Http\Requests\Api\Application\Mounts\UpdateMountRequest;
use App\Exceptions\Service\HasActiveServersException; use App\Exceptions\Service\HasActiveServersException;
use App\Http\Requests\Api\Application\Eggs\GetEggsRequest;
use App\Http\Requests\Api\Application\Nodes\GetNodesRequest;
use App\Http\Requests\Api\Application\Servers\GetServerRequest;
use App\Transformers\Api\Application\EggTransformer;
use App\Transformers\Api\Application\NodeTransformer;
use App\Transformers\Api\Application\ServerTransformer;
class MountController extends ApplicationApiController class MountController extends ApplicationApiController
{ {
@ -28,7 +34,7 @@ class MountController extends ApplicationApiController
*/ */
public function index(GetMountRequest $request): array public function index(GetMountRequest $request): array
{ {
$mounts = QueryBuilder::for(Mount::query()) $mounts = QueryBuilder::for(Mount::class)
->allowedFilters(['uuid', 'name']) ->allowedFilters(['uuid', 'name'])
->allowedSorts(['id', 'uuid']) ->allowedSorts(['id', 'uuid'])
->paginate($request->query('per_page') ?? 50); ->paginate($request->query('per_page') ?? 50);
@ -115,6 +121,42 @@ class MountController extends ApplicationApiController
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }
/**
* List assigned eggs
*
* @return array<array-key, mixed>
*/
public function getEggs(GetEggsRequest $request, Mount $mount): array
{
return $this->fractal->collection($mount->eggs)
->transformWith($this->getTransformer(EggTransformer::class))
->toArray();
}
/**
* List assigned nodes
*
* @return array<array-key, mixed>
*/
public function getNodes(GetNodesRequest $request, Mount $mount): array
{
return $this->fractal->collection($mount->nodes)
->transformWith($this->getTransformer(NodeTransformer::class))
->toArray();
}
/**
* List assigned servers
*
* @return array<array-key, mixed>
*/
public function getServers(GetServerRequest $request, Mount $mount): array
{
return $this->fractal->collection($mount->servers)
->transformWith($this->getTransformer(ServerTransformer::class))
->toArray();
}
/** /**
* Assign eggs to mount * Assign eggs to mount
* *
@ -125,13 +167,11 @@ class MountController extends ApplicationApiController
public function addEggs(Request $request, Mount $mount): array public function addEggs(Request $request, Mount $mount): array
{ {
$validatedData = $request->validate([ $validatedData = $request->validate([
'eggs' => 'required|exists:eggs,id', 'eggs' => 'required|array|exists:eggs,id',
'eggs.*' => 'integer',
]); ]);
$eggs = $validatedData['eggs'] ?? []; $mount->eggs()->attach($validatedData['eggs']);
if (count($eggs) > 0) {
$mount->eggs()->attach($eggs);
}
return $this->fractal->item($mount) return $this->fractal->item($mount)
->transformWith($this->getTransformer(MountTransformer::class)) ->transformWith($this->getTransformer(MountTransformer::class))
@ -139,7 +179,7 @@ class MountController extends ApplicationApiController
} }
/** /**
* Assign mounts to mount * Assign nodes to mount
* *
* Adds nodes to the mount's many-to-many relation. * Adds nodes to the mount's many-to-many relation.
* *
@ -147,12 +187,33 @@ class MountController extends ApplicationApiController
*/ */
public function addNodes(Request $request, Mount $mount): array public function addNodes(Request $request, Mount $mount): array
{ {
$data = $request->validate(['nodes' => 'required|exists:nodes,id']); $validatedData = $request->validate([
'nodes' => 'required|array|exists:nodes,id',
'nodes.*' => 'integer',
]);
$nodes = $data['nodes'] ?? []; $mount->nodes()->attach($validatedData['nodes']);
if (count($nodes) > 0) {
$mount->nodes()->attach($nodes); return $this->fractal->item($mount)
} ->transformWith($this->getTransformer(MountTransformer::class))
->toArray();
}
/**
* Assign servers to mount
*
* Adds servers to the mount's many-to-many relation.
*
* @return array<array-key, mixed>
*/
public function addServers(Request $request, Mount $mount): array
{
$validatedData = $request->validate([
'servers' => 'required|array|exists:servers,id',
'servers.*' => 'integer',
]);
$mount->servers()->attach($validatedData['servers']);
return $this->fractal->item($mount) return $this->fractal->item($mount)
->transformWith($this->getTransformer(MountTransformer::class)) ->transformWith($this->getTransformer(MountTransformer::class))
@ -182,4 +243,16 @@ class MountController extends ApplicationApiController
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }
/**
* Unassign server from mount
*
* Deletes a server from the mount's many-to-many relation.
*/
public function deleteServer(Mount $mount, int $server_id): JsonResponse
{
$mount->servers()->detach($server_id);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
} }

View File

@ -42,7 +42,7 @@ class NodeController extends ApplicationApiController
*/ */
public function index(GetNodesRequest $request): array public function index(GetNodesRequest $request): array
{ {
$nodes = QueryBuilder::for(Node::query()) $nodes = QueryBuilder::for(Node::class)
->allowedFilters(['uuid', 'name', 'fqdn', 'daemon_token_id']) ->allowedFilters(['uuid', 'name', 'fqdn', 'daemon_token_id'])
->allowedSorts(['id', 'uuid', 'memory', 'disk', 'cpu']) ->allowedSorts(['id', 'uuid', 'memory', 'disk', 'cpu'])
->paginate($request->query('per_page') ?? 50); ->paginate($request->query('per_page') ?? 50);

View File

@ -27,7 +27,7 @@ class RoleController extends ApplicationApiController
*/ */
public function index(GetRoleRequest $request): array public function index(GetRoleRequest $request): array
{ {
$roles = QueryBuilder::for(Role::query()) $roles = QueryBuilder::for(Role::class)
->allowedFilters(['id', 'name']) ->allowedFilters(['id', 'name'])
->allowedSorts(['id', 'name']) ->allowedSorts(['id', 'name'])
->paginate($request->query('per_page') ?? 10); ->paginate($request->query('per_page') ?? 10);

View File

@ -43,7 +43,7 @@ class ServerController extends ApplicationApiController
*/ */
public function index(GetServersRequest $request): array public function index(GetServersRequest $request): array
{ {
$servers = QueryBuilder::for(Server::query()) $servers = QueryBuilder::for(Server::class)
->allowedFilters(['uuid', 'uuid_short', 'name', 'description', 'image', 'external_id']) ->allowedFilters(['uuid', 'uuid_short', 'name', 'description', 'image', 'external_id'])
->allowedSorts(['id', 'uuid']) ->allowedSorts(['id', 'uuid'])
->paginate($request->query('per_page') ?? 50); ->paginate($request->query('per_page') ?? 50);

View File

@ -43,7 +43,7 @@ class UserController extends ApplicationApiController
*/ */
public function index(GetUsersRequest $request): array public function index(GetUsersRequest $request): array
{ {
$users = QueryBuilder::for(User::query()) $users = QueryBuilder::for(User::class)
->allowedFilters(['email', 'uuid', 'username', 'external_id']) ->allowedFilters(['email', 'uuid', 'username', 'external_id'])
->allowedSorts(['id', 'uuid']) ->allowedSorts(['id', 'uuid'])
->paginate($request->query('per_page') ?? 50); ->paginate($request->query('per_page') ?? 50);

View File

@ -11,6 +11,8 @@ use App\Models\Filters\MultiFieldServerFilter;
use App\Transformers\Api\Client\ServerTransformer; use App\Transformers\Api\Client\ServerTransformer;
use App\Http\Requests\Api\Client\GetServersRequest; use App\Http\Requests\Api\Client\GetServersRequest;
use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\Group;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
#[Group('Base')] #[Group('Base')]
class ClientController extends ClientApiController class ClientController extends ClientApiController
@ -36,10 +38,11 @@ class ClientController extends ClientApiController
$user = $request->user(); $user = $request->user();
$transformer = $this->getTransformer(ServerTransformer::class); $transformer = $this->getTransformer(ServerTransformer::class);
/** @var Builder<Model> $query */
$query = Server::query()->with($this->getIncludesForTransformer($transformer, ['node']));
// Start the query builder and ensure we eager load any requested relationships from the request. // Start the query builder and ensure we eager load any requested relationships from the request.
$builder = QueryBuilder::for( $builder = QueryBuilder::for($query)->allowedFilters([
Server::query()->with($this->getIncludesForTransformer($transformer, ['node']))
)->allowedFilters([
'uuid', 'uuid',
'name', 'name',
'description', 'description',

View File

@ -143,15 +143,7 @@ class SubuserController extends ClientApiController
*/ */
protected function getDefaultPermissions(Request $request): array protected function getDefaultPermissions(Request $request): array
{ {
$allowed = Permission::permissions() $allowed = Permission::permissionKeys()->all();
->map(function ($value, $prefix) {
return array_map(function ($value) use ($prefix) {
return "$prefix.$value";
}, array_keys($value['keys']));
})
->flatten()
->all();
$cleaned = array_intersect($request->input('permissions') ?? [], $allowed); $cleaned = array_intersect($request->input('permissions') ?? [], $allowed);
return array_unique(array_merge($cleaned, [Permission::ACTION_WEBSOCKET_CONNECT])); return array_unique(array_merge($cleaned, [Permission::ACTION_WEBSOCKET_CONNECT]));

View File

@ -55,6 +55,7 @@ class StoreServerRequest extends ApplicationApiRequest
// Automatic deployment rules // Automatic deployment rules
'deploy' => 'sometimes|required|array', 'deploy' => 'sometimes|required|array',
// Locations are deprecated, use tags
'deploy.locations' => 'sometimes|array', 'deploy.locations' => 'sometimes|array',
'deploy.locations.*' => 'required_with:deploy.locations|integer|min:1', 'deploy.locations.*' => 'required_with:deploy.locations|integer|min:1',
'deploy.tags' => 'array', 'deploy.tags' => 'array',
@ -176,7 +177,6 @@ class StoreServerRequest extends ApplicationApiRequest
$object->setDedicated($this->input('deploy.dedicated_ip', false)); $object->setDedicated($this->input('deploy.dedicated_ip', false));
$object->setTags($this->input('deploy.tags', $this->input('deploy.locations', []))); $object->setTags($this->input('deploy.tags', $this->input('deploy.locations', [])));
$object->setPorts($this->input('deploy.port_range', [])); $object->setPorts($this->input('deploy.port_range', []));
$object->setNode($this->input('deploy.node_id'));
return $object; return $object;
} }

View File

@ -22,7 +22,8 @@ class SendPowerRequest extends ClientApiRequest
return Permission::ACTION_CONTROL_RESTART; return Permission::ACTION_CONTROL_RESTART;
} }
return '__invalid'; // Fallback for invalid signals
return Permission::ACTION_WEBSOCKET_CONNECT;
} }
/** /**

View File

@ -8,6 +8,10 @@ use Illuminate\Auth\Events\Login;
class AuthenticationListener class AuthenticationListener
{ {
private const PROTECTED_FIELDS = [
'password', 'token', 'secret',
];
/** /**
* Handles an authentication event by logging the user and information about * Handles an authentication event by logging the user and information about
* the request. * the request.
@ -22,7 +26,9 @@ class AuthenticationListener
if ($event instanceof Failed) { if ($event instanceof Failed) {
foreach ($event->credentials as $key => $value) { foreach ($event->credentials as $key => $value) {
$activity = $activity->property($key, $value); if (!in_array($key, self::PROTECTED_FIELDS, true)) {
$activity = $activity->property($key, $value);
}
} }
} }

View File

@ -2,12 +2,8 @@
namespace App\Models\Objects; namespace App\Models\Objects;
use App\Models\Node;
class DeploymentObject class DeploymentObject
{ {
private ?Node $node = null;
private bool $dedicated = false; private bool $dedicated = false;
/** @var string[] */ /** @var string[] */
@ -16,18 +12,6 @@ class DeploymentObject
/** @var array<int|string> */ /** @var array<int|string> */
private array $ports = []; private array $ports = [];
public function getNode(): ?Node
{
return $this->node;
}
public function setNode(Node $node): self
{
$this->node = $node;
return $this;
}
public function isDedicated(): bool public function isDedicated(): bool
{ {
return $this->dedicated; return $this->dedicated;

View File

@ -211,4 +211,11 @@ class Permission extends Model implements Validatable
return collect($permissions); return collect($permissions);
} }
public static function permissionKeys(): Collection
{
return static::permissions()
->map(fn ($value, $prefix) => array_map(fn ($value) => "$prefix.$value", array_keys($value['keys'])))
->flatten();
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Policies; namespace App\Policies;
use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
@ -21,15 +22,17 @@ class ServerPolicy
return null; return null;
} }
// Owner has full server permissions if (Permission::permissionKeys()->contains($ability)) {
if ($server->owner_id === $user->id) { // Owner has full server permissions
return true; if ($server->owner_id === $user->id) {
} return true;
}
$subuser = $server->subusers->where('user_id', $user->id)->first(); $subuser = $server->subusers->where('user_id', $user->id)->first();
// If the user is a subuser check their permissions // If the user is a subuser check their permissions
if ($subuser && in_array($ability, $subuser->permissions)) { if ($subuser && in_array($ability, $subuser->permissions)) {
return true; return true;
}
} }
// Make sure user can target node of the server // Make sure user can target node of the server

View File

@ -0,0 +1,39 @@
<?php
namespace App\Services\Schedules\Sharing;
use App\Models\Schedule;
use App\Models\Task;
class ScheduleExporterService
{
public function handle(Schedule|int $schedule): string
{
if (!$schedule instanceof Schedule) {
$schedule = Schedule::findOrFail($schedule);
}
$data = [
'name' => $schedule->name,
'is_active' => $schedule->is_active,
'only_when_online' => $schedule->only_when_online,
'cron_minute' => $schedule->cron_minute,
'cron_hour' => $schedule->cron_hour,
'cron_day_of_month' => $schedule->cron_day_of_month,
'cron_month' => $schedule->cron_month,
'cron_day_of_week' => $schedule->cron_day_of_week,
'tasks' => $schedule->tasks->map(function (Task $task) {
return [
'sequence_id' => $task->sequence_id,
'action' => $task->action,
'payload' => $task->payload,
'time_offset' => $task->time_offset,
'continue_on_failure' => $task->continue_on_failure,
];
}),
];
return json_encode($data, JSON_PRETTY_PRINT);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Services\Schedules\Sharing;
use App\Exceptions\Service\InvalidFileUploadException;
use App\Helpers\Utilities;
use Illuminate\Support\Arr;
use Illuminate\Http\UploadedFile;
use App\Models\Schedule;
use App\Models\Server;
use App\Models\Task;
use Illuminate\Database\ConnectionInterface;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class ScheduleImporterService
{
public function __construct(protected ConnectionInterface $connection) {}
public function fromFile(UploadedFile $file, Server $server): Schedule
{
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new InvalidFileUploadException('The selected file was not uploaded successfully');
}
try {
$parsed = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new InvalidFileUploadException('Could not read JSON file: ' . $exception->getMessage());
}
return $this->connection->transaction(function () use ($server, $parsed) {
$minute = Arr::get($parsed, 'cron_minute', '0');
$hour = Arr::get($parsed, 'cron_hour', '0');
$dayOfMonth = Arr::get($parsed, 'cron_day_of_month', '*');
$month = Arr::get($parsed, 'cron_month', '*');
$dayOfWeek = Arr::get($parsed, 'cron_day_of_week', '*');
$schedule = Schedule::create([
'server_id' => $server->id,
'name' => Arr::get($parsed, 'name'),
'is_active' => Arr::get($parsed, 'is_active'),
'only_when_online' => Arr::get($parsed, 'only_when_online'),
'cron_minute' => $minute,
'cron_hour' => $hour,
'cron_day_of_month' => $dayOfMonth,
'cron_month' => $month,
'cron_day_of_week' => $dayOfWeek,
'next_run_at' => Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek),
]);
foreach (Arr::get($parsed, 'tasks', []) as $task) {
Task::create([
'schedule_id' => $schedule->id,
'sequence_id' => Arr::get($task, 'sequence_id'),
'action' => Arr::get($task, 'action'),
'payload' => Arr::get($task, 'payload'),
'time_offset' => Arr::get($task, 'time_offset'),
'continue_on_failure' => Arr::get($task, 'continue_on_failure'),
]);
}
return $schedule;
});
}
public function fromUrl(string $url, Server $server): Schedule
{
$info = pathinfo($url);
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
$tmpPath = $tmpDir->path($info['basename']);
if (!file_put_contents($tmpPath, file_get_contents($url))) {
throw new InvalidFileUploadException('Could not write temporary file.');
}
return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], 'application/json'), $server);
}
}

View File

@ -72,19 +72,35 @@ class ServerCreationService
$data['image'] = $data['image'] ?? collect($egg->docker_images)->first(); $data['image'] = $data['image'] ?? collect($egg->docker_images)->first();
$data['startup'] = $data['startup'] ?? $egg->startup; $data['startup'] = $data['startup'] ?? $egg->startup;
// If a deployment object has been passed we need to get the allocation // If a deployment object has been passed we need to get the allocation and node that the server should use.
// that the server should use, and assign the node from that allocation.
if ($deployment) { if ($deployment) {
$allocation = $this->configureDeployment($data, $deployment); $nodes = $this->findViableNodesService->handle(
Arr::get($data, 'memory', 0),
Arr::get($data, 'disk', 0),
Arr::get($data, 'cpu', 0),
$deployment->getTags(),
)->pluck('id');
if ($nodes->isEmpty()) {
throw new NoViableNodeException(trans('exceptions.deployment.no_viable_nodes'));
}
$ports = $deployment->getPorts();
if (!empty($ports)) {
$allocation = $this->allocationSelectionService->setDedicated($deployment->isDedicated())
->setNodes($nodes->toArray())
->setPorts($ports)
->handle();
if ($allocation) {
$data['allocation_id'] = $allocation->id; $data['allocation_id'] = $allocation->id;
// Auto-configure the node based on the selected allocation
// if no node was defined.
$data['node_id'] = $allocation->node_id; $data['node_id'] = $allocation->node_id;
} }
$data['node_id'] ??= $deployment->getNode()->id;
if (empty($data['node_id'])) {
$data['node_id'] = $nodes->first();
}
} }
Assert::false(empty($data['node_id']), 'Expected a non-empty node_id in server creation data.'); Assert::false(empty($data['node_id']), 'Expected a non-empty node_id in server creation data.');
$eggVariableData = $this->validatorService $eggVariableData = $this->validatorService
@ -123,39 +139,6 @@ class ServerCreationService
return $server; return $server;
} }
/**
* Gets an allocation to use for automatic deployment.
*
* @param array{memory?: ?int, disk?: ?int, cpu?: ?int, tags?: ?string[]} $data
*
* @throws DisplayException
* @throws NoViableAllocationException
*/
private function configureDeployment(array $data, DeploymentObject $deployment): ?Allocation
{
$nodes = $this->findViableNodesService->handle(
Arr::get($data, 'memory', 0),
Arr::get($data, 'disk', 0),
Arr::get($data, 'cpu', 0),
$deployment->getTags(),
);
$availableNodes = $nodes->pluck('id');
if ($availableNodes->isEmpty()) {
throw new NoViableNodeException(trans('exceptions.deployment.no_viable_nodes'));
}
if (!$deployment->getPorts()) {
return null;
}
return $this->allocationSelectionService->setDedicated($deployment->isDedicated())
->setNodes($availableNodes->toArray())
->setPorts($deployment->getPorts())
->handle();
}
/** /**
* Store the server in the database and return the model. * Store the server in the database and return the model.
* *

View File

@ -36,7 +36,7 @@ class VariableValidatorService
$data = $rules = $customAttributes = []; $data = $rules = $customAttributes = [];
foreach ($variables as $variable) { foreach ($variables as $variable) {
$data['environment'][$variable->env_variable] = array_get($fields, $variable->env_variable); $data['environment'][$variable->env_variable] = $fields[$variable->env_variable] ?? $variable->default_value;
$rules['environment.' . $variable->env_variable] = $variable->rules; $rules['environment.' . $variable->env_variable] = $variable->rules;
$customAttributes['environment.' . $variable->env_variable] = trans('validation.internal.variable_value', ['env' => $variable->name]); $customAttributes['environment.' . $variable->env_variable] = trans('validation.internal.variable_value', ['env' => $variable->name]);
} }

View File

@ -9,15 +9,16 @@ use Ramsey\Uuid\Uuid;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Illuminate\Contracts\Auth\PasswordBroker;
use App\Notifications\AccountCreated; use App\Notifications\AccountCreated;
use Filament\Facades\Filament;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Password;
class UserCreationService class UserCreationService
{ {
public function __construct( public function __construct(
private readonly ConnectionInterface $connection, private readonly ConnectionInterface $connection,
private readonly Hasher $hasher, private readonly Hasher $hasher,
private readonly PasswordBroker $passwordBroker,
) {} ) {}
/** /**
@ -53,7 +54,9 @@ class UserCreationService
} }
if (isset($generateResetToken)) { if (isset($generateResetToken)) {
$token = $this->passwordBroker->createToken($user); /** @var PasswordBroker $broker */
$broker = Password::broker(Filament::getPanel('app')->getAuthPasswordBroker());
$token = $broker->createToken($user);
} }
$this->connection->commit(); $this->connection->commit();

View File

@ -16,7 +16,7 @@
"doctrine/dbal": "~3.6.0", "doctrine/dbal": "~3.6.0",
"filament/filament": "~4.0", "filament/filament": "~4.0",
"guzzlehttp/guzzle": "^7.9", "guzzlehttp/guzzle": "^7.9",
"laravel/framework": "^12.19", "laravel/framework": "^12.20",
"laravel/helpers": "^1.7", "laravel/helpers": "^1.7",
"laravel/sanctum": "^4.1", "laravel/sanctum": "^4.1",
"laravel/socialite": "^5.21", "laravel/socialite": "^5.21",

10
composer.lock generated
View File

@ -14241,16 +14241,16 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.1.17", "version": "2.1.18",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan.git", "url": "https://github.com/phpstan/phpstan.git",
"reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ee1f390b7a70cdf74a2b737e554f68afea885db7",
"reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -14295,7 +14295,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-05-21T20:55:28+00:00" "time": "2025-07-17T17:22:31+00:00"
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",

View File

@ -0,0 +1,29 @@
<?php
use App\Models\ActivityLog;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::transaction(function () {
$logs = ActivityLog::where('event', 'auth:fail')->get();
foreach ($logs as $log) {
$log->update(['properties' => collect($log->properties)->except(['password'])->toArray()]);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Not needed
}
};

View File

@ -0,0 +1,15 @@
<?php
return [
'model_label' => 'Schedule',
'model_label_plural' => 'Schedule',
'import' => [
'file' => 'File',
'url' => 'URL',
'schedule_help' => 'This should be the raw .json file ( schedule-daily-restart.json )',
'url_help' => 'URLs must point directly to the raw .json file',
'add_url' => 'New URL',
'import_failed' => 'Import Failed',
'import_success' => 'Import Success',
],
];

View File

@ -90,7 +90,11 @@
terminal.open(document.getElementById('terminal')); terminal.open(document.getElementById('terminal'));
fitAddon.fit(); //Fit on first load fitAddon.fit(); // Fixes SPA issues.
window.addEventListener('load', () => {
fitAddon.fit();
});
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
fitAddon.fit(); fitAddon.fit();

View File

@ -13,7 +13,7 @@
:icon="$server->condition->getIcon()" :icon="$server->condition->getIcon()"
:color="$server->condition->getColor()" :color="$server->condition->getColor()"
:tooltip="$server->condition->getLabel()" :tooltip="$server->condition->getLabel()"
size="xl" size="lg"
/> />
<h2 class="text-xl font-bold"> <h2 class="text-xl font-bold">
{{ $server->name }} {{ $server->name }}
@ -21,6 +21,15 @@
({{ $server->formatResource('uptime', type: \App\Enums\ServerResourceType::Time) }}) ({{ $server->formatResource('uptime', type: \App\Enums\ServerResourceType::Time) }})
</span> </span>
</h2> </h2>
<div class="end-0" x-on:click.stop>
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-b-lg overflow-hidden p-1">
<x-filament-tables::actions
:actions="\App\Filament\App\Resources\ServerResource\Pages\ListServers::getPowerActions(view: 'grid')"
:alignment="\Filament\Support\Enums\Alignment::Center"
:record="$server"
/>
</div>
</div>
</div> </div>
<div class="flex justify-between text-center items-center gap-4"> <div class="flex justify-between text-center items-center gap-4">

View File

@ -133,16 +133,21 @@ Route::prefix('/database-hosts')->group(function () {
Route::prefix('mounts')->group(function () { Route::prefix('mounts')->group(function () {
Route::get('/', [Application\Mounts\MountController::class, 'index'])->name('api.application.mounts'); Route::get('/', [Application\Mounts\MountController::class, 'index'])->name('api.application.mounts');
Route::get('/{mount:id}', [Application\Mounts\MountController::class, 'view'])->name('api.application.mounts.view'); Route::get('/{mount:id}', [Application\Mounts\MountController::class, 'view'])->name('api.application.mounts.view');
Route::get('/{mount:id}/eggs', [Application\Mounts\MountController::class, 'getEggs']);
Route::get('/{mount:id}/nodes', [Application\Mounts\MountController::class, 'getNodes']);
Route::get('/{mount:id}/servers', [Application\Mounts\MountController::class, 'getServers']);
Route::post('/', [Application\Mounts\MountController::class, 'store']); Route::post('/', [Application\Mounts\MountController::class, 'store']);
Route::post('/{mount:id}/eggs', [Application\Mounts\MountController::class, 'addEggs'])->name('api.application.mounts.eggs'); Route::post('/{mount:id}/eggs', [Application\Mounts\MountController::class, 'addEggs'])->name('api.application.mounts.eggs');
Route::post('/{mount:id}/nodes', [Application\Mounts\MountController::class, 'addNodes'])->name('api.application.mounts.nodes'); Route::post('/{mount:id}/nodes', [Application\Mounts\MountController::class, 'addNodes'])->name('api.application.mounts.nodes');
Route::post('/{mount:id}/servers', [Application\Mounts\MountController::class, 'addServers'])->name('api.application.mounts.servers');
Route::patch('/{mount:id}', [Application\Mounts\MountController::class, 'update']); Route::patch('/{mount:id}', [Application\Mounts\MountController::class, 'update']);
Route::delete('/{mount:id}', [Application\Mounts\MountController::class, 'delete']); Route::delete('/{mount:id}', [Application\Mounts\MountController::class, 'delete']);
Route::delete('/{mount:id}/eggs/{egg_id}', [Application\Mounts\MountController::class, 'deleteEgg']); Route::delete('/{mount:id}/eggs/{egg_id}', [Application\Mounts\MountController::class, 'deleteEgg']);
Route::delete('/{mount:id}/nodes/{node_id}', [Application\Mounts\MountController::class, 'deleteNode']); Route::delete('/{mount:id}/nodes/{node_id}', [Application\Mounts\MountController::class, 'deleteNode']);
Route::delete('/{mount:id}/servers/{server_id}', [Application\Mounts\MountController::class, 'deleteServer']);
}); });
/* /*

View File

@ -116,6 +116,7 @@ class ServerCreationServiceTest extends IntegrationTestCase
$this->assertInstanceOf(Server::class, $response); $this->assertInstanceOf(Server::class, $response);
$this->assertNotNull($response->uuid); $this->assertNotNull($response->uuid);
$this->assertSame($response->uuid_short, substr($response->uuid, 0, 8)); $this->assertSame($response->uuid_short, substr($response->uuid, 0, 8));
$this->assertSame($node->id, $response->node_id);
$this->assertSame($egg->id, $response->egg_id); $this->assertSame($egg->id, $response->egg_id);
$this->assertCount(2, $response->variables); $this->assertCount(2, $response->variables);
$this->assertSame('123', $response->variables()->firstWhere('env_variable', 'BUNGEE_VERSION')->server_value); $this->assertSame('123', $response->variables()->firstWhere('env_variable', 'BUNGEE_VERSION')->server_value);
@ -153,7 +154,7 @@ class ServerCreationServiceTest extends IntegrationTestCase
/** @var \App\Models\Node $node */ /** @var \App\Models\Node $node */
$node = Node::factory()->create(); $node = Node::factory()->create();
$deployment = (new DeploymentObject())->setNode($node); $deployment = new DeploymentObject();
$egg = $this->cloneEggAndVariables($this->bungeecord); $egg = $this->cloneEggAndVariables($this->bungeecord);
// We want to make sure that the validator service runs as an admin, and not as a regular // We want to make sure that the validator service runs as an admin, and not as a regular
@ -204,6 +205,7 @@ class ServerCreationServiceTest extends IntegrationTestCase
$this->assertInstanceOf(Server::class, $response); $this->assertInstanceOf(Server::class, $response);
$this->assertNotNull($response->uuid); $this->assertNotNull($response->uuid);
$this->assertSame($response->uuid_short, substr($response->uuid, 0, 8)); $this->assertSame($response->uuid_short, substr($response->uuid, 0, 8));
$this->assertSame($node->id, $response->node_id);
$this->assertSame($egg->id, $response->egg_id); $this->assertSame($egg->id, $response->egg_id);
$this->assertCount(2, $response->variables); $this->assertCount(2, $response->variables);
$this->assertSame('123', $response->variables()->firstWhere('env_variable', 'BUNGEE_VERSION')->server_value); $this->assertSame('123', $response->variables()->firstWhere('env_variable', 'BUNGEE_VERSION')->server_value);

View File

@ -34,6 +34,7 @@ class VariableValidatorServiceTest extends IntegrationTestCase
try { try {
$this->getService()->handle($egg->id, [ $this->getService()->handle($egg->id, [
'BUNGEE_VERSION' => '1.2.3', 'BUNGEE_VERSION' => '1.2.3',
'SERVER_JARFILE' => '',
]); ]);
$this->fail('This statement should not be reached.'); $this->fail('This statement should not be reached.');