Compare commits

..

No commits in common. "main" and "v1.0.0-beta21" have entirely different histories.

85 changed files with 1448 additions and 1414 deletions

View File

@ -18,17 +18,6 @@ class QueueWorkerServiceCommand extends Command
public function handle(): void public function handle(): void
{ {
if (@file_exists('/.dockerenv')) {
$result = Process::run('supervisorctl restart queue-worker');
if ($result->failed()) {
$this->error('Error restarting service: ' . $result->errorOutput());
return;
}
$this->line('Queue worker service file updated successfully.');
return;
}
$serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue'); $serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue');
$path = '/etc/systemd/system/' . $serviceName . '.service'; $path = '/etc/systemd/system/' . $serviceName . '.service';

View File

@ -24,7 +24,6 @@ class MakeNodeCommand extends Command
{--overallocateCpu= : Enter the amount of cpu to overallocate (% or -1 to overallocate the maximum).} {--overallocateCpu= : Enter the amount of cpu to overallocate (% or -1 to overallocate the maximum).}
{--uploadSize= : Enter the maximum upload filesize.} {--uploadSize= : Enter the maximum upload filesize.}
{--daemonListeningPort= : Enter the daemon listening port.} {--daemonListeningPort= : Enter the daemon listening port.}
{--daemonConnectingPort= : Enter the daemon connecting port.}
{--daemonSFTPPort= : Enter the daemon SFTP listening port.} {--daemonSFTPPort= : Enter the daemon SFTP listening port.}
{--daemonSFTPAlias= : Enter the daemon SFTP alias.} {--daemonSFTPAlias= : Enter the daemon SFTP alias.}
{--daemonBase= : Enter the base folder.}'; {--daemonBase= : Enter the base folder.}';
@ -58,7 +57,6 @@ class MakeNodeCommand extends Command
$data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(trans('commands.make_node.cpu_overallocate'), '-1'); $data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(trans('commands.make_node.cpu_overallocate'), '-1');
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask(trans('commands.make_node.upload_size'), '256'); $data['upload_size'] = $this->option('uploadSize') ?? $this->ask(trans('commands.make_node.upload_size'), '256');
$data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(trans('commands.make_node.daemonListen'), '8080'); $data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(trans('commands.make_node.daemonListen'), '8080');
$data['daemon_connect'] = $this->option('daemonConnectingPort') ?? $this->ask(trans('commands.make_node.daemonConnect'), '8080');
$data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(trans('commands.make_node.daemonSFTP'), '2022'); $data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(trans('commands.make_node.daemonSFTP'), '2022');
$data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(trans('commands.make_node.daemonSFTPAlias'), ''); $data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(trans('commands.make_node.daemonSFTPAlias'), '');
$data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(trans('commands.make_node.daemonBase'), '/var/lib/pelican/volumes'); $data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(trans('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');

View File

@ -7,6 +7,7 @@ use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand; use App\Console\Commands\Maintenance\PruneImagesCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand; use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Schedule\ProcessRunnableCommand; use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Jobs\NodeStatistics;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Webhook; use App\Models\Webhook;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
@ -43,6 +44,8 @@ class Kernel extends ConsoleKernel
$schedule->command(PruneImagesCommand::class)->daily(); $schedule->command(PruneImagesCommand::class)->daily();
$schedule->command(CheckEggUpdatesCommand::class)->hourly(); $schedule->command(CheckEggUpdatesCommand::class)->hourly();
$schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping();
if (config('backups.prune_age')) { if (config('backups.prune_age')) {
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted. // Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted.
$schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes(); $schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes();

View File

@ -88,7 +88,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
public function isStartable(): bool public function isStartable(): bool
{ {
return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Missing]); return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting]);
} }
public function isRestartable(): bool public function isRestartable(): bool
@ -97,16 +97,18 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
return true; return true;
} }
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Missing]); return !in_array($this, [ContainerStatus::Offline]);
} }
public function isStoppable(): bool public function isStoppable(): bool
{ {
return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline, ContainerStatus::Missing]); return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline]);
} }
public function isKillable(): bool public function isKillable(): bool
{ {
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited, ContainerStatus::Missing]); // [ContainerStatus::Restarting, ContainerStatus::Removing, ContainerStatus::Dead, ContainerStatus::Created]
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited]);
} }
} }

View File

@ -27,16 +27,8 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
}; };
} }
public function getColor(bool $hex = false): string public function getColor(): string
{ {
if ($hex) {
return match ($this) {
self::Normal, self::Installing, self::RestoringBackup => '#2563EB',
self::Suspended => '#D97706',
self::InstallFailed, self::ReinstallFailed => '#EF4444',
};
}
return match ($this) { return match ($this) {
self::Normal => 'primary', self::Normal => 'primary',
self::Installing => 'primary', self::Installing => 'primary',

View File

@ -0,0 +1,11 @@
<?php
namespace App\Events\Auth;
use App\Models\User;
use App\Events\Event;
class DirectLogin extends Event
{
public function __construct(public User $user, public bool $remember) {}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Events\Auth;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class FailedPasswordReset extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public string $ip, public string $email) {}
}

View File

@ -630,6 +630,7 @@ class Settings extends Page implements HasForms
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))), ->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))),
@ -640,6 +641,7 @@ class Settings extends Page implements HasForms
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))), ->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),

View File

@ -79,7 +79,7 @@ class ApiKeyResource extends Resource
TextColumn::make('user.username') TextColumn::make('user.username')
->label(trans('admin/apikey.table.created_by')) ->label(trans('admin/apikey.table.created_by'))
->icon('tabler-user') ->icon('tabler-user')
->url(fn (ApiKey $apiKey) => auth()->user()->can('update', $apiKey->user) ? EditUser::getUrl(['record' => $apiKey->user]) : null), ->url(fn (ApiKey $apiKey) => auth()->user()->can('update user', $apiKey->user) ? EditUser::getUrl(['record' => $apiKey->user]) : null),
]) ])
->actions([ ->actions([
DeleteAction::make(), DeleteAction::make(),

View File

@ -164,10 +164,8 @@ class DatabaseHostResource extends Resource
{ {
$query = parent::getEloquentQuery(); $query = parent::getEloquentQuery();
return $query->where(function (Builder $query) { return $query->whereHas('nodes', function (Builder $query) {
return $query->whereHas('nodes', function (Builder $query) { $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')); })->orDoesntHave('nodes');
})->orDoesntHave('nodes');
});
} }
} }

View File

@ -71,10 +71,10 @@ class DatabasesRelationManager extends RelationManager
]) ])
->actions([ ->actions([
DeleteAction::make() DeleteAction::make()
->authorize(fn (Database $database) => auth()->user()->can('delete', $database)), ->authorize(fn (Database $database) => auth()->user()->can('delete database', $database)),
ViewAction::make() ViewAction::make()
->color('primary') ->color('primary')
->hidden(fn () => !auth()->user()->can('viewAny', Database::class)), ->hidden(fn () => !auth()->user()->can('viewList database')),
]); ]);
} }
} }

View File

@ -76,7 +76,7 @@ class MountResource extends Resource
->badge() ->badge()
->icon(fn ($state) => $state ? 'tabler-writing-off' : 'tabler-writing') ->icon(fn ($state) => $state ? 'tabler-writing-off' : 'tabler-writing')
->color(fn ($state) => $state ? 'success' : 'warning') ->color(fn ($state) => $state ? 'success' : 'warning')
->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.read_only') : trans('admin/mount.toggles.writable')), ->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.read_only') : trans('admin/mount.toggles.writeable')),
]) ])
->actions([ ->actions([
ViewAction::make() ViewAction::make()
@ -176,10 +176,8 @@ class MountResource extends Resource
{ {
$query = parent::getEloquentQuery(); $query = parent::getEloquentQuery();
return $query->where(function (Builder $query) { return $query->whereHas('nodes', function (Builder $query) {
return $query->whereHas('nodes', function (Builder $query) { $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')); })->orDoesntHave('nodes');
})->orDoesntHave('nodes');
});
} }
} }

View File

@ -124,10 +124,15 @@ class CreateNode extends CreateRecord
'lg' => 1, 'lg' => 1,
]), ]),
TextInput::make('daemon_connect') TextInput::make('daemon_listen')
->columnSpan(1) ->columnSpan([
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port')) 'default' => 1,
->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help')) 'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('admin/node.port'))
->helperText(trans('admin/node.port_help'))
->minValue(1) ->minValue(1)
->maxValue(65535) ->maxValue(65535)
->default(8080) ->default(8080)
@ -188,21 +193,7 @@ class CreateNode extends CreateRecord
->afterStateUpdated(function ($state, Set $set) { ->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https'); $set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy'); $set('behind_proxy', $state === 'https_proxy');
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}), }),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
]), ]),
Step::make('advanced') Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings')) ->label(trans('admin/node.tabs.advanced_settings'))
@ -418,13 +409,4 @@ class CreateNode extends CreateRecord
{ {
return []; return [];
} }
protected function mutateFormDataBeforeCreate(array $data): array
{
if (!$data['behind_proxy']) {
$data['daemon_listen'] = $data['daemon_connect'];
}
return $data;
}
} }

View File

@ -181,10 +181,10 @@ class EditNode extends EditRecord
false => 'danger', false => 'danger',
]) ])
->columnSpan(1), ->columnSpan(1),
TextInput::make('daemon_connect') TextInput::make('daemon_listen')
->columnSpan(1) ->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port')) ->label(trans('admin/node.port'))
->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help')) ->helperText(trans('admin/node.port_help'))
->minValue(1) ->minValue(1)
->maxValue(65535) ->maxValue(65535)
->default(8080) ->default(8080)
@ -239,20 +239,7 @@ class EditNode extends EditRecord
->afterStateUpdated(function ($state, Set $set) { ->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https'); $set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy'); $set('behind_proxy', $state === 'https_proxy');
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}), }),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
]), ]),
Tab::make('adv') Tab::make('adv')
->label(trans('admin/node.tabs.advanced_settings')) ->label(trans('admin/node.tabs.advanced_settings'))
@ -640,15 +627,6 @@ class EditNode extends EditRecord
]; ];
} }
protected function mutateFormDataBeforeSave(array $data): array
{
if (!$data['behind_proxy']) {
$data['daemon_listen'] = $data['daemon_connect'];
}
return $data;
}
protected function afterSave(): void protected function afterSave(): void
{ {
$this->fillForm(); $this->fillForm();

View File

@ -97,7 +97,7 @@ class AllocationsRelationManager extends RelationManager
]) ])
->groupedBulkActions([ ->groupedBulkActions([
DeleteBulkAction::make() DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('update', $this->getOwnerRecord())), ->authorize(fn () => auth()->user()->can('update node')),
]); ]);
} }
} }

View File

@ -3,6 +3,7 @@
namespace App\Filament\Admin\Resources\NodeResource\Widgets; namespace App\Filament\Admin\Resources\NodeResource\Widgets;
use App\Models\Node; use App\Models\Node;
use Carbon\Carbon;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number; use Illuminate\Support\Number;
@ -15,34 +16,22 @@ class NodeCpuChart extends ChartWidget
public Node $node; public Node $node;
/**
* @var array<int, array{cpu: string, timestamp: string}>
*/
protected array $cpuHistory = [];
protected int $threads = 0;
protected function getData(): array protected function getData(): array
{ {
$sessionKey = "node_stats.{$this->node->id}"; $threads = $this->node->systemInformation()['cpu_count'] ?? 0;
$data = $this->node->statistics(); $cpu = collect(cache()->get("nodes.{$this->node->id}.cpu_percent"))
->slice(-10)
$this->threads = session("{$sessionKey}.threads", $this->node->systemInformation()['cpu_count'] ?? 0); ->map(fn ($value, $key) => [
'cpu' => round($value * $threads, 2),
$this->cpuHistory = session("{$sessionKey}.cpu_history", []); 'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
$this->cpuHistory[] = [ ])
'cpu' => round($data['cpu_percent'] * $this->threads, 2), ->all();
'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
];
$this->cpuHistory = array_slice($this->cpuHistory, -60);
session()->put("{$sessionKey}.cpu_history", $this->cpuHistory);
return [ return [
'datasets' => [ 'datasets' => [
[ [
'data' => array_column($this->cpuHistory, 'cpu'), 'data' => array_column($cpu, 'cpu'),
'backgroundColor' => [ 'backgroundColor' => [
'rgba(96, 165, 250, 0.3)', 'rgba(96, 165, 250, 0.3)',
], ],
@ -50,7 +39,7 @@ class NodeCpuChart extends ChartWidget
'fill' => true, 'fill' => true,
], ],
], ],
'labels' => array_column($this->cpuHistory, 'timestamp'), 'labels' => array_column($cpu, 'timestamp'),
'locale' => auth()->user()->language ?? 'en', 'locale' => auth()->user()->language ?? 'en',
]; ];
} }
@ -80,10 +69,10 @@ class NodeCpuChart extends ChartWidget
public function getHeading(): string public function getHeading(): string
{ {
$data = array_slice(end($this->cpuHistory), -60); $threads = $this->node->systemInformation()['cpu_count'] ?? 0;
$cpu = Number::format($data['cpu'], maxPrecision: 2, locale: auth()->user()->language); $cpu = Number::format(collect(cache()->get("nodes.{$this->node->id}.cpu_percent"))->last() * $threads, maxPrecision: 2, locale: auth()->user()->language);
$max = Number::format($this->threads * 100, locale: auth()->user()->language); $max = Number::format($threads * 100, locale: auth()->user()->language);
return trans('admin/node.cpu_chart', ['cpu' => $cpu, 'max' => $max]); return trans('admin/node.cpu_chart', ['cpu' => $cpu, 'max' => $max]);
} }

View File

@ -3,6 +3,7 @@
namespace App\Filament\Admin\Resources\NodeResource\Widgets; namespace App\Filament\Admin\Resources\NodeResource\Widgets;
use App\Models\Node; use App\Models\Node;
use Carbon\Carbon;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number; use Illuminate\Support\Number;
@ -15,36 +16,19 @@ class NodeMemoryChart extends ChartWidget
public Node $node; public Node $node;
/**
* @var array<int, array{memory: string, timestamp: string}>
*/
protected array $memoryHistory = [];
protected int $totalMemory = 0;
protected function getData(): array protected function getData(): array
{ {
$sessionKey = "node_stats.{$this->node->id}"; $memUsed = collect(cache()->get("nodes.{$this->node->id}.memory_used"))->slice(-10)
->map(fn ($value, $key) => [
$data = $this->node->statistics(); 'memory' => round(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
$this->totalMemory = session("{$sessionKey}.total_memory", $data['memory_total']); ])
->all();
$this->memoryHistory = session("{$sessionKey}.memory_history", []);
$this->memoryHistory[] = [
'memory' => round(config('panel.use_binary_prefix')
? $data['memory_used'] / 1024 / 1024 / 1024
: $data['memory_used'] / 1000 / 1000 / 1000, 2),
'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
];
$this->memoryHistory = array_slice($this->memoryHistory, -60);
session()->put("{$sessionKey}.memory_history", $this->memoryHistory);
return [ return [
'datasets' => [ 'datasets' => [
[ [
'data' => array_column($this->memoryHistory, 'memory'), 'data' => array_column($memUsed, 'memory'),
'backgroundColor' => [ 'backgroundColor' => [
'rgba(96, 165, 250, 0.3)', 'rgba(96, 165, 250, 0.3)',
], ],
@ -52,7 +36,7 @@ class NodeMemoryChart extends ChartWidget
'fill' => true, 'fill' => true,
], ],
], ],
'labels' => array_column($this->memoryHistory, 'timestamp'), 'labels' => array_column($memUsed, 'timestamp'),
'locale' => auth()->user()->language ?? 'en', 'locale' => auth()->user()->language ?? 'en',
]; ];
} }
@ -82,15 +66,16 @@ class NodeMemoryChart extends ChartWidget
public function getHeading(): string public function getHeading(): string
{ {
$latestMemoryUsed = array_slice(end($this->memoryHistory), -60); $latestMemoryUsed = collect(cache()->get("nodes.{$this->node->id}.memory_used"))->last();
$totalMemory = collect(cache()->get("nodes.{$this->node->id}.memory_total"))->last();
$used = config('panel.use_binary_prefix') $used = config('panel.use_binary_prefix')
? Number::format($latestMemoryUsed['memory'], maxPrecision: 2, locale: auth()->user()->language) .' GiB' ? Number::format($latestMemoryUsed / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($latestMemoryUsed['memory'], maxPrecision: 2, locale: auth()->user()->language) . ' GB'; : Number::format($latestMemoryUsed / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
$total = config('panel.use_binary_prefix') $total = config('panel.use_binary_prefix')
? Number::format($this->totalMemory / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB' ? Number::format($totalMemory / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($this->totalMemory / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB'; : Number::format($totalMemory / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
return trans('admin/node.memory_chart', ['used' => $used, 'total' => $total]); return trans('admin/node.memory_chart', ['used' => $used, 'total' => $total]);
} }

View File

@ -147,8 +147,6 @@ class RoleResource extends Resource
*/ */
private static function makeSection(string $model, array $options): Section private static function makeSection(string $model, array $options): Section
{ {
$model = ucwords($model);
$icon = null; $icon = null;
if (class_exists('\App\Filament\Admin\Resources\\' . $model . 'Resource')) { if (class_exists('\App\Filament\Admin\Resources\\' . $model . 'Resource')) {

View File

@ -79,6 +79,8 @@ class ServerResource extends Resource
{ {
$query = parent::getEloquentQuery(); $query = parent::getEloquentQuery();
return $query->whereIn('node_id', auth()->user()->accessibleNodes()->pluck('id')); return $query->whereHas('node', function (Builder $query) {
$query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'));
});
} }
} }

View File

@ -144,7 +144,6 @@ class CreateServer extends CreateRecord
->relationship('user', 'username') ->relationship('user', 'username')
->searchable(['username', 'email']) ->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)") ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)")
->createOptionAction(fn (Action $action) => $action->authorize(fn () => auth()->user()->can('create', User::class)))
->createOptionForm([ ->createOptionForm([
TextInput::make('username') TextInput::make('username')
->label(trans('admin/user.username')) ->label(trans('admin/user.username'))
@ -206,7 +205,6 @@ class CreateServer extends CreateRecord
->where('node_id', $get('node_id')) ->where('node_id', $get('node_id'))
->whereNull('server_id'), ->whereNull('server_id'),
) )
->createOptionAction(fn (Action $action) => $action->authorize(fn (Get $get) => auth()->user()->can('create', Node::find($get('node_id')))))
->createOptionForm(function (Get $get) { ->createOptionForm(function (Get $get) {
$getPage = $get; $getPage = $get;

View File

@ -686,7 +686,7 @@ class EditServer extends EditRecord
ServerResource::getMountCheckboxList($get), ServerResource::getMountCheckboxList($get),
]), ]),
Tab::make(trans('admin/server.databases')) Tab::make(trans('admin/server.databases'))
->hidden(fn () => !auth()->user()->can('viewAny', Database::class)) ->hidden(fn () => !auth()->user()->can('viewList database'))
->icon('tabler-database') ->icon('tabler-database')
->columns(4) ->columns(4)
->schema([ ->schema([
@ -710,7 +710,7 @@ class EditServer extends EditRecord
->hintAction( ->hintAction(
Action::make('Delete') Action::make('Delete')
->label(trans('filament-actions::delete.single.modal.actions.delete.label')) ->label(trans('filament-actions::delete.single.modal.actions.delete.label'))
->authorize(fn (Database $database) => auth()->user()->can('delete', $database)) ->authorize(fn (Database $database) => auth()->user()->can('delete database', $database))
->color('danger') ->color('danger')
->icon('tabler-trash') ->icon('tabler-trash')
->requiresConfirmation() ->requiresConfirmation()
@ -763,7 +763,7 @@ class EditServer extends EditRecord
->columnSpan(4), ->columnSpan(4),
FormActions::make([ FormActions::make([
Action::make('createDatabase') Action::make('createDatabase')
->authorize(fn () => auth()->user()->can('create', Database::class)) ->authorize(fn () => auth()->user()->can('create database'))
->disabled(fn () => DatabaseHost::query()->count() < 1) ->disabled(fn () => DatabaseHost::query()->count() < 1)
->label(fn () => DatabaseHost::query()->count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database')) ->label(fn () => DatabaseHost::query()->count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database'))
->color(fn () => DatabaseHost::query()->count() < 1 ? 'danger' : 'primary') ->color(fn () => DatabaseHost::query()->count() < 1 ? 'danger' : 'primary')
@ -1065,7 +1065,7 @@ class EditServer extends EditRecord
} }
}) })
->hidden(fn () => $canForceDelete) ->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete', $server)), ->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('ForceDelete') Actions\Action::make('ForceDelete')
->color('danger') ->color('danger')
->label(trans('filament-actions::force-delete.single.label')) ->label(trans('filament-actions::force-delete.single.label'))
@ -1082,7 +1082,7 @@ class EditServer extends EditRecord
} }
}) })
->visible(fn () => $canForceDelete) ->visible(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete', $server)), ->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console') Actions\Action::make('console')
->label(trans('admin/server.console')) ->label(trans('admin/server.console'))
->icon('tabler-terminal') ->icon('tabler-terminal')

View File

@ -68,13 +68,13 @@ class ListServers extends ListRecords
->searchable(), ->searchable(),
SelectColumn::make('allocation_id') SelectColumn::make('allocation_id')
->label(trans('admin/server.primary_allocation')) ->label(trans('admin/server.primary_allocation'))
->hidden(!auth()->user()->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty) ->hidden(!auth()->user()->can('update server'))
->options(fn (Server $server) => $server->allocations->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address])) ->options(fn (Server $server) => $server->allocations->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->sortable(), ->sortable(),
TextColumn::make('allocation_id_readonly') TextColumn::make('allocation_id_readonly')
->label(trans('admin/server.primary_allocation')) ->label(trans('admin/server.primary_allocation'))
->hidden(auth()->user()->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty) ->hidden(auth()->user()->can('update server'))
->state(fn (Server $server) => $server->allocation->address), ->state(fn (Server $server) => $server->allocation->address),
TextColumn::make('image')->hidden(), TextColumn::make('image')->hidden(),
TextColumn::make('backups_count') TextColumn::make('backups_count')

View File

@ -9,13 +9,12 @@ use App\Filament\Server\Pages\Console;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository; use App\Repositories\Daemon\DaemonPowerRepository;
use AymanAlhattami\FilamentContextMenu\Columns\ContextMenuTextColumn;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Components\Tab; use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Columns\ColumnGroup;
use Filament\Tables\Columns\Column;
use Filament\Tables\Columns\Layout\Stack; use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@ -39,73 +38,121 @@ class ListServers extends ListRecords
$this->daemonPowerRepository = new DaemonPowerRepository(); $this->daemonPowerRepository = new DaemonPowerRepository();
} }
/** @return Stack[] */ public function table(Table $table): Table
protected function gridColumns(): array
{ {
return [ $baseQuery = auth()->user()->accessibleServers();
Stack::make([
ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
];
}
/** @return Column[] */ $menuOptions = function (Server $server) {
protected function tableColumns(): array $status = $server->retrieveStatus();
{
return [ return [
TextColumn::make('condition') Action::make('start')
->label('Status') ->color('primary')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->visible(fn () => $status->isStartable())
->dispatch('powerAction', ['server' => $server, 'action' => 'start'])
->icon('tabler-player-play-filled'),
Action::make('restart')
->color('gray')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->visible(fn () => $status->isRestartable())
->dispatch('powerAction', ['server' => $server, 'action' => 'restart'])
->icon('tabler-refresh'),
Action::make('stop')
->color('danger')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $status->isStoppable())
->dispatch('powerAction', ['server' => $server, 'action' => 'stop'])
->icon('tabler-player-stop-filled'),
Action::make('kill')
->color('danger')
->tooltip('This can result in data corruption and/or data loss!')
->dispatch('powerAction', ['server' => $server, 'action' => 'kill'])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $status->isKillable())
->icon('tabler-alert-square'),
];
};
$viewOne = [
ContextMenuTextColumn::make('condition')
->label('')
->default('unknown')
->wrap()
->badge() ->badge()
->alignCenter()
->tooltip(fn (Server $server) => $server->formatResource('uptime', type: ServerResourceType::Time)) ->tooltip(fn (Server $server) => $server->formatResource('uptime', type: ServerResourceType::Time))
->icon(fn (Server $server) => $server->condition->getIcon()) ->icon(fn (Server $server) => $server->condition->getIcon())
->color(fn (Server $server) => $server->condition->getColor()), ->color(fn (Server $server) => $server->condition->getColor())
TextColumn::make('name') ->contextMenuActions($menuOptions)
->label('Server') ->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
->description(fn (Server $server) => $server->description) ];
->grow()
->searchable(), $viewTwo = [
TextColumn::make('allocation.address') ContextMenuTextColumn::make('name')
->label('')
->size('md')
->searchable()
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
ContextMenuTextColumn::make('allocation.address')
->label('') ->label('')
->badge() ->badge()
->visibleFrom('md') ->copyable(request()->isSecure())
->copyable(request()->isSecure()), ->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
];
$viewThree = [
TextColumn::make('cpuUsage') TextColumn::make('cpuUsage')
->label('Resources') ->label('')
->icon('tabler-cpu') ->icon('tabler-cpu')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('cpu', limit: true, type: ServerResourceType::Percentage, precision: 0)) ->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('cpu', limit: true, type: ServerResourceType::Percentage, precision: 0))
->state(fn (Server $server) => $server->formatResource('cpu_absolute', type: ServerResourceType::Percentage)) ->state(fn (Server $server) => $server->formatResource('cpu_absolute', type: ServerResourceType::Percentage))
->color(fn (Server $server) => $this->getResourceColor($server, 'cpu')), ->color(fn (Server $server) => $this->getResourceColor($server, 'cpu')),
TextColumn::make('memoryUsage') TextColumn::make('memoryUsage')
->label('') ->label('')
->icon('tabler-device-desktop-analytics') ->icon('tabler-memory')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('memory', limit: true)) ->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('memory', limit: true))
->state(fn (Server $server) => $server->formatResource('memory_bytes')) ->state(fn (Server $server) => $server->formatResource('memory_bytes'))
->color(fn (Server $server) => $this->getResourceColor($server, 'memory')), ->color(fn (Server $server) => $this->getResourceColor($server, 'memory')),
TextColumn::make('diskUsage') TextColumn::make('diskUsage')
->label('') ->label('')
->icon('tabler-device-sd-card') ->icon('tabler-device-floppy')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('disk', limit: true)) ->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('disk', limit: true))
->state(fn (Server $server) => $server->formatResource('disk_bytes')) ->state(fn (Server $server) => $server->formatResource('disk_bytes'))
->color(fn (Server $server) => $this->getResourceColor($server, 'disk')), ->color(fn (Server $server) => $this->getResourceColor($server, 'disk')),
]; ];
}
public function table(Table $table): Table
{
$baseQuery = auth()->user()->accessibleServers();
$usingGrid = (auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid';
return $table return $table
->paginated(false) ->paginated(false)
->query(fn () => $baseQuery) ->query(fn () => $baseQuery)
->poll('15s') ->poll('15s')
->columns($usingGrid ? $this->gridColumns() : $this->tableColumns()) ->columns(
->recordUrl(!$usingGrid ? (fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server)) : null) (auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid'
->actions(!$usingGrid ? ActionGroup::make(static::getPowerActions()) : []) ? [
->actionsAlignment(Alignment::Center->value) Stack::make([
->contentGrid($usingGrid ? ['default' => 1, 'md' => 2] : null) ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
]
: [
ColumnGroup::make('Status')
->label('Status')
->columns($viewOne),
ColumnGroup::make('Server')
->label('Servers')
->columns($viewTwo),
ColumnGroup::make('Resources')
->label('Resources')
->columns($viewThree),
]
)
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->contentGrid([
'default' => 1,
'md' => 2,
])
->emptyStateIcon('tabler-brand-docker') ->emptyStateIcon('tabler-brand-docker')
->emptyStateDescription('') ->emptyStateDescription('')
->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!') ->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
@ -148,33 +195,36 @@ class ListServers extends ListRecords
]; ];
} }
protected function getResourceColor(Server $server, string $resource): ?string public function getResourceColor(Server $server, string $resource): ?string
{ {
$current = null; $current = null;
$limit = null; $limit = null;
switch ($resource) { switch ($resource) {
case 'cpu': case 'cpu':
$current = $server->retrieveResources()['cpu_absolute'] ?? 0; $current = $server->resources()['cpu_absolute'] ?? 0;
$limit = $server->cpu; $limit = $server->cpu;
if ($server->cpu === 0) { if ($server->cpu === 0) {
return null; return null;
} }
break; break;
case 'memory': case 'memory':
$current = $server->retrieveResources()['memory_bytes'] ?? 0; $current = $server->resources()['memory_bytes'] ?? 0;
$limit = $server->memory * 2 ** 20; $limit = $server->memory * 2 ** 20;
if ($server->memory === 0) { if ($server->memory === 0) {
return null; return null;
} }
break; break;
case 'disk': case 'disk':
$current = $server->retrieveResources()['disk_bytes'] ?? 0; $current = $server->resources()['disk_bytes'] ?? 0;
$limit = $server->disk * 2 ** 20; $limit = $server->disk * 2 ** 20;
if ($server->disk === 0) { if ($server->disk === 0) {
return null; return null;
} }
break; break;
default: default:
return null; return null;
} }
@ -188,6 +238,7 @@ class ListServers extends ListRecords
} }
return null; return null;
} }
#[On('powerAction')] #[On('powerAction')]
@ -202,8 +253,6 @@ class ListServers extends ListRecords
->success() ->success()
->send(); ->send();
cache()->forget("servers.$server->uuid.status");
$this->redirect(self::getUrl(['activeTab' => $this->activeTab])); $this->redirect(self::getUrl(['activeTab' => $this->activeTab]));
} catch (ConnectionException) { } catch (ConnectionException) {
Notification::make() Notification::make()
@ -212,36 +261,4 @@ class ListServers extends ListRecords
->send(); ->send();
} }
} }
/** @return Action[] */
public static function getPowerActions(): array
{
return [
Action::make('start')
->color('primary')
->icon('tabler-player-play-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isStartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'start']),
Action::make('restart')
->color('gray')
->icon('tabler-reload')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isRestartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'restart']),
Action::make('stop')
->color('danger')
->icon('tabler-player-stop-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isStoppable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'stop']),
Action::make('kill')
->color('danger')
->icon('tabler-alert-square')
->tooltip('This can result in data corruption and/or data loss!')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isKillable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'kill']),
];
}
} }

View File

@ -26,7 +26,7 @@ class RotateDatabasePasswordAction extends Action
$this->icon('tabler-refresh'); $this->icon('tabler-refresh');
$this->authorize(fn (Database $database) => auth()->user()->can('update', $database)); $this->authorize(fn (Database $database) => auth()->user()->can('update database', $database));
$this->modalHeading(trans('admin/databasehost.rotate_password')); $this->modalHeading(trans('admin/databasehost.rotate_password'));

View File

@ -6,5 +6,5 @@ use Filament\Tables\Columns\Column;
class ServerEntryColumn extends Column class ServerEntryColumn extends Column
{ {
protected string $view = 'livewire.columns.server-entry-column'; protected string $view = 'tables.columns.server-entry-column';
} }

View File

@ -32,7 +32,6 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Auth\EditProfile as BaseEditProfile; use Filament\Pages\Auth\EditProfile as BaseEditProfile;
use Filament\Support\Colors\Color; use Filament\Support\Colors\Color;
@ -289,8 +288,6 @@ class EditProfile extends BaseEditProfile
); );
Activity::event('user:api-key.create') Activity::event('user:api-key.create')
->actor($user)
->subject($user)
->subject($token->accessToken) ->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier) ->property('identifier', $token->accessToken->identifier)
->log(); ->log();
@ -403,38 +400,30 @@ class EditProfile extends BaseEditProfile
}) })
->reactive() ->reactive()
->default('monospace') ->default('monospace')
->afterStateUpdated(fn ($state, Set $set) => $set('font_preview', $state)), ->afterStateUpdated(fn ($state, callable $set) => $set('font_preview', $state)),
Placeholder::make('font_preview') Placeholder::make('font_preview')
->label(trans('profile.font_preview')) ->label(trans('profile.font_preview'))
->columnSpan(2) ->columnSpan(2)
->content(function (Get $get) { ->content(function (Get $get) {
$fontName = $get('console_font') ?? 'monospace'; $fontName = $get('console_font') ?? 'monospace';
$fontSize = $get('console_font_size') . 'px'; $fontSize = $get('console_font_size') . 'px';
$style = <<<CSS $fontUrl = asset("storage/fonts/{$fontName}.ttf");
.preview-text {
font-family: $fontName;
font-size: $fontSize;
margin-top: 10px;
display: block;
}
CSS;
if ($fontName !== 'monospace') {
$fontUrl = asset("storage/fonts/$fontName.ttf");
$style = <<<CSS
@font-face {
font-family: $fontName;
src: url("$fontUrl");
}
$style
CSS;
}
return new HtmlString(<<<HTML return new HtmlString(<<<HTML
<style> <style>
{$style} @font-face {
</style> font-family: "CustomPreviewFont";
<span class="preview-text">The quick blue pelican jumps over the lazy pterodactyl. :)</span> src: url("$fontUrl");
HTML); }
.preview-text {
font-family: "CustomPreviewFont";
font-size: $fontSize;
margin-top: 10px;
display: block;
}
</style>
<span class="preview-text">The quick blue pelican jumps over the lazy pterodactyl. :)</span>
HTML);
}), }),
TextInput::make('console_graph_period') TextInput::make('console_graph_period')
->label(trans('profile.graph_period')) ->label(trans('profile.graph_period'))

View File

@ -2,10 +2,8 @@
namespace App\Filament\Pages\Auth; namespace App\Filament\Pages\Auth;
use App\Events\Auth\ProvidedAuthenticationToken;
use App\Extensions\Captcha\Providers\CaptchaProvider; use App\Extensions\Captcha\Providers\CaptchaProvider;
use App\Extensions\OAuth\Providers\OAuthProvider; use App\Extensions\OAuth\Providers\OAuthProvider;
use App\Facades\Activity;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions;
@ -56,37 +54,14 @@ class Login extends BaseLogin
if ($token === null) { if ($token === null) {
$this->verifyTwoFactor = true; $this->verifyTwoFactor = true;
Activity::event('auth:checkpoint')
->withRequestMetadata()
->subject($user)
->log();
return null; return null;
} }
$isValidToken = false; $isValidToken = $this->google2FA->verifyKey(
if (strlen($token) === $this->google2FA->getOneTimePasswordLength()) { $user->totp_secret,
$isValidToken = $this->google2FA->verifyKey( $token,
$user->totp_secret, Config::integer('panel.auth.2fa.window'),
$token, );
Config::integer('panel.auth.2fa.window'),
);
if ($isValidToken) {
event(new ProvidedAuthenticationToken($user));
}
} else {
foreach ($user->recoveryTokens as $recoveryToken) {
if (password_verify($token, $recoveryToken->token)) {
$isValidToken = true;
$recoveryToken->delete();
event(new ProvidedAuthenticationToken($user, true));
break;
}
}
}
if (!$isValidToken) { if (!$isValidToken) {
// Buffer to prevent bruteforce // Buffer to prevent bruteforce
@ -133,9 +108,7 @@ class Login extends BaseLogin
{ {
return TextInput::make('2fa') return TextInput::make('2fa')
->label(trans('auth.two-factor-code')) ->label(trans('auth.two-factor-code'))
->hintIcon('tabler-question-mark') ->hidden(fn () => !$this->verifyTwoFactor)
->hintIconTooltip(trans('auth.two-factor-hint'))
->visible(fn () => $this->verifyTwoFactor)
->required() ->required()
->live(); ->live();
} }

View File

@ -2,27 +2,43 @@
namespace App\Filament\Server\Components; namespace App\Filament\Server\Components;
use Closure;
use Filament\Support\Concerns\EvaluatesClosures;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
class SmallStatBlock extends Stat class SmallStatBlock extends Stat
{ {
use EvaluatesClosures; protected string|Htmlable $label;
protected bool|Closure $copyOnClick = false; protected $value;
public function copyOnClick(bool|Closure $copyOnClick = true): static public function label(string|Htmlable $label): static
{ {
$this->copyOnClick = $copyOnClick; $this->label = $label;
return $this; return $this;
} }
public function shouldCopyOnClick(): bool public function value($value): static
{ {
return $this->evaluate($this->copyOnClick); $this->value = $value;
return $this;
}
public function getLabel(): string|Htmlable
{
return $this->label;
}
public function getValue()
{
return value($this->value);
}
public function toHtml(): string
{
return $this->render()->render();
} }
public function render(): View public function render(): View

View File

@ -0,0 +1,48 @@
<?php
namespace App\Filament\Server\Components;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
class StatBlock extends Stat
{
protected string|Htmlable $label;
protected $value;
public function label(string|Htmlable $label): static
{
$this->label = $label;
return $this;
}
public function value($value): static
{
$this->value = $value;
return $this;
}
public function getLabel(): string|Htmlable
{
return $this->label;
}
public function getValue()
{
return value($this->value);
}
public function toHtml(): string
{
return $this->render()->render();
}
public function render(): View
{
return view('filament.components.server-data-block', $this->data());
}
}

View File

@ -2,8 +2,6 @@
namespace App\Filament\Server\Resources; namespace App\Filament\Server\Resources;
use App\Filament\Admin\Resources\UserResource\Pages\EditUser;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\ActivityResource\Pages; use App\Filament\Server\Resources\ActivityResource\Pages;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Permission; use App\Models\Permission;
@ -11,20 +9,9 @@ use App\Models\Role;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString;
class ActivityResource extends Resource class ActivityResource extends Resource
{ {
@ -38,101 +25,12 @@ class ActivityResource extends Resource
protected static ?string $navigationIcon = 'tabler-stack'; protected static ?string $navigationIcon = 'tabler-stack';
public static function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->paginated([25, 50])
->defaultPaginationPageOption(25)
->columns([
TextColumn::make('event')
->html()
->description(fn ($state) => $state)
->icon(fn (ActivityLog $activityLog) => $activityLog->getIcon())
->formatStateUsing(fn (ActivityLog $activityLog) => $activityLog->getLabel()),
TextColumn::make('user')
->state(function (ActivityLog $activityLog) use ($server) {
if (!$activityLog->actor instanceof User) {
return $activityLog->actor_id === null ? 'System' : 'Deleted user';
}
$user = $activityLog->actor->username;
// Only show the email if the actor is the server owner/ a subuser or if the viewing user is an admin
if (auth()->user()->isAdmin() || $server->owner_id === $activityLog->actor->id || $server->subusers->where('user_id', $activityLog->actor->id)->first()) {
$user .= " ({$activityLog->actor->email})";
}
return $user;
})
->tooltip(fn (ActivityLog $activityLog) => auth()->user()->can('seeIps activityLog') ? $activityLog->ip : '')
->url(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update', $activityLog->actor) ? EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin') : '')
->grow(false),
DateTimeColumn::make('timestamp')
->since()
->sortable()
->grow(false),
])
->defaultSort('timestamp', 'desc')
->actions([
ViewAction::make()
//->visible(fn (ActivityLog $activityLog) => $activityLog->hasAdditionalMetadata())
->form([
Placeholder::make('event')
->content(fn (ActivityLog $activityLog) => new HtmlString($activityLog->getLabel())),
TextInput::make('user')
->formatStateUsing(function (ActivityLog $activityLog) use ($server) {
if (!$activityLog->actor instanceof User) {
return $activityLog->actor_id === null ? 'System' : 'Deleted user';
}
$user = $activityLog->actor->username;
// Only show the email if the actor is the server owner/ a subuser or if the viewing user is an admin
if (auth()->user()->isAdmin() || $server->owner_id === $activityLog->actor->id || $server->subusers->where('user_id', $activityLog->actor->id)->first()) {
$user .= " ({$activityLog->actor->email})";
}
if (auth()->user()->can('seeIps activityLog')) {
$user .= " - $activityLog->ip";
}
return $user;
})
->hintAction(
Action::make('edit')
->label(trans('filament-actions::edit.single.label'))
->icon('tabler-edit')
->visible(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update', $activityLog->actor))
->url(fn (ActivityLog $activityLog) => EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin'))
),
DateTimePicker::make('timestamp'),
KeyValue::make('properties')
->label('Metadata')
->formatStateUsing(fn ($state) => Arr::dot($state)),
]),
])
->filters([
SelectFilter::make('event')
->options(fn (Table $table) => $table->getQuery()->pluck('event', 'event')->unique()->sort())
->searchable()
->preload(),
]);
}
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_ACTIVITY_READ, Filament::getTenant());
}
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
return ActivityLog::whereHas('subjects', fn (Builder $query) => $query->where('subject_id', $server->id)->where('subject_type', $server->getMorphClass())) return ActivityLog::whereHas('subjects', fn (Builder $query) => $query->where('subject_id', $server->id))
->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS) ->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS)
->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) { ->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) {
// We could do this with a query and a lot of joins, but that gets pretty // We could do this with a query and a lot of joins, but that gets pretty
@ -153,6 +51,11 @@ class ActivityResource extends Resource
}); });
} }
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_ACTIVITY_READ, Filament::getTenant());
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -2,13 +2,114 @@
namespace App\Filament\Server\Resources\ActivityResource\Pages; namespace App\Filament\Server\Resources\ActivityResource\Pages;
use App\Filament\Admin\Resources\UserResource\Pages\EditUser;
use App\Filament\Server\Resources\ActivityResource; use App\Filament\Server\Resources\ActivityResource;
use App\Models\ActivityLog;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\Server;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString;
class ListActivities extends ListRecords class ListActivities extends ListRecords
{ {
protected static string $resource = ActivityResource::class; protected static string $resource = ActivityResource::class;
public function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->paginated([25, 50])
->defaultPaginationPageOption(25)
->columns([
TextColumn::make('event')
->html()
->description(fn ($state) => $state)
->icon(fn (ActivityLog $activityLog) => $activityLog->getIcon())
->formatStateUsing(fn (ActivityLog $activityLog) => $activityLog->getLabel()),
TextColumn::make('user')
->state(function (ActivityLog $activityLog) use ($server) {
if (!$activityLog->actor instanceof User) {
return $activityLog->actor_id === null ? 'System' : 'Deleted user';
}
$user = $activityLog->actor->username;
// Only show the email if the actor is the server owner/ a subuser or if the viewing user is an admin
if (auth()->user()->isAdmin() || $server->owner_id === $activityLog->actor->id || $server->subusers->where('user_id', $activityLog->actor->id)->first()) {
$user .= " ({$activityLog->actor->email})";
}
return $user;
})
->tooltip(fn (ActivityLog $activityLog) => auth()->user()->can('seeIps activityLog') ? $activityLog->ip : '')
->url(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update user') ? EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin') : '')
->grow(false),
DateTimeColumn::make('timestamp')
->since()
->sortable()
->grow(false),
])
->defaultSort('timestamp', 'desc')
->actions([
ViewAction::make()
//->visible(fn (ActivityLog $activityLog) => $activityLog->hasAdditionalMetadata())
->form([
Placeholder::make('event')
->content(fn (ActivityLog $activityLog) => new HtmlString($activityLog->getLabel())),
TextInput::make('user')
->formatStateUsing(function (ActivityLog $activityLog) use ($server) {
if (!$activityLog->actor instanceof User) {
return $activityLog->actor_id === null ? 'System' : 'Deleted user';
}
$user = $activityLog->actor->username;
// Only show the email if the actor is the server owner/ a subuser or if the viewing user is an admin
if (auth()->user()->isAdmin() || $server->owner_id === $activityLog->actor->id || $server->subusers->where('user_id', $activityLog->actor->id)->first()) {
$user .= " ({$activityLog->actor->email})";
}
if (auth()->user()->can('seeIps activityLog')) {
$user .= " - $activityLog->ip";
}
return $user;
})
->hintAction(
Action::make('edit')
->label(trans('filament-actions::edit.single.label'))
->icon('tabler-edit')
->visible(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && auth()->user()->can('update user'))
->url(fn (ActivityLog $activityLog) => EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin'))
),
DateTimePicker::make('timestamp'),
KeyValue::make('properties')
->label('Metadata')
->formatStateUsing(fn ($state) => Arr::dot($state)),
]),
])
->filters([
SelectFilter::make('event')
->options(fn (Table $table) => $table->getQuery()->pluck('event', 'event')->unique()->sort())
->searchable()
->preload(),
]);
}
public function getBreadcrumbs(): array public function getBreadcrumbs(): array
{ {
return []; return [];

View File

@ -2,18 +2,12 @@
namespace App\Filament\Server\Resources; namespace App\Filament\Server\Resources;
use App\Facades\Activity;
use App\Filament\Server\Resources\AllocationResource\Pages; use App\Filament\Server\Resources\AllocationResource\Pages;
use App\Models\Allocation; use App\Models\Allocation;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\DetachAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class AllocationResource extends Resource class AllocationResource extends Resource
@ -28,61 +22,6 @@ class AllocationResource extends Resource
protected static ?string $navigationIcon = 'tabler-network'; protected static ?string $navigationIcon = 'tabler-network';
public static function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->columns([
TextColumn::make('ip')
->label('Address')
->formatStateUsing(fn (Allocation $allocation) => $allocation->alias),
TextColumn::make('alias')
->hidden(),
TextColumn::make('port'),
TextInputColumn::make('notes')
->visibleFrom('sm')
->disabled(fn () => !auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->label('Notes')
->placeholder('No Notes'),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled',
default => 'tabler-star',
})
->color(fn ($state) => match ($state) {
true => 'warning',
default => 'gray',
})
->action(function (Allocation $allocation) use ($server) {
if (auth()->user()->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server)) {
return $server->update(['allocation_id' => $allocation->id]);
}
})
->default(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->label('Primary'),
])
->actions([
DetachAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, $server))
->label('Delete')
->icon('tabler-trash')
->hidden(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->action(function (Allocation $allocation) {
Allocation::query()->where('id', $allocation->id)->update([
'notes' => null,
'server_id' => null,
]);
Activity::event('server:allocation.delete')
->subject($allocation)
->property('allocation', $allocation->address)
->log();
}),
]);
}
// TODO: find better way handle server conflict state // TODO: find better way handle server conflict state
public static function canAccess(): bool public static function canAccess(): bool
{ {

View File

@ -4,24 +4,85 @@ namespace App\Filament\Server\Resources\AllocationResource\Pages;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Server\Resources\AllocationResource; use App\Filament\Server\Resources\AllocationResource;
use App\Models\Allocation;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Services\Allocations\FindAssignableAllocationService; use App\Services\Allocations\FindAssignableAllocationService;
use Filament\Actions\Action; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DetachAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
class ListAllocations extends ListRecords class ListAllocations extends ListRecords
{ {
protected static string $resource = AllocationResource::class; protected static string $resource = AllocationResource::class;
public function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->columns([
TextColumn::make('ip')
->label('Address')
->formatStateUsing(fn (Allocation $allocation) => $allocation->alias),
TextColumn::make('alias')
->hidden(),
TextColumn::make('port'),
TextInputColumn::make('notes')
->visibleFrom('sm')
->disabled(fn () => !auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->label('Notes')
->placeholder('No Notes'),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled',
default => 'tabler-star',
})
->color(fn ($state) => match ($state) {
true => 'warning',
default => 'gray',
})
->action(function (Allocation $allocation) use ($server) {
if (auth()->user()->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server)) {
return $server->update(['allocation_id' => $allocation->id]);
}
})
->default(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->label('Primary'),
])
->actions([
DetachAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, $server))
->label('Delete')
->icon('tabler-trash')
->hidden(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->action(function (Allocation $allocation) {
Allocation::query()->where('id', $allocation->id)->update([
'notes' => null,
'server_id' => null,
]);
Activity::event('server:allocation.delete')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
}),
]);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
return [ return [
Action::make('addAllocation') Actions\Action::make('addAllocation')
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, $server))
->label(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'Allocation limit reached' : 'Add Allocation') ->label(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'Allocation limit reached' : 'Add Allocation')
->hidden(fn () => !config('panel.client_features.allocations.enabled')) ->hidden(fn () => !config('panel.client_features.allocations.enabled'))
@ -32,7 +93,7 @@ class ListAllocations extends ListRecords
Activity::event('server:allocation.create') Activity::event('server:allocation.create')
->subject($allocation) ->subject($allocation)
->property('allocation', $allocation->address) ->property('allocation', $allocation->toString())
->log(); ->log();
}), }),
]; ];

View File

@ -2,37 +2,13 @@
namespace App\Filament\Server\Resources; namespace App\Filament\Server\Resources;
use App\Enums\BackupStatus;
use App\Enums\ServerState;
use App\Facades\Activity;
use App\Filament\Server\Resources\BackupResource\Pages; use App\Filament\Server\Resources\BackupResource\Pages;
use App\Http\Controllers\Api\Client\Servers\BackupController;
use App\Models\Backup; use App\Models\Backup;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DownloadLinkService;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Services\Backups\DeleteBackupService;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
class BackupResource extends Resource class BackupResource extends Resource
{ {
@ -68,138 +44,8 @@ class BackupResource extends Resource
return null; return null;
} }
return $count >= $limit ? 'danger' : ($count >= $limit * self::WARNING_THRESHOLD ? 'warning' : 'success'); return $count >= $limit ? 'danger'
} : ($count >= $limit * self::WARNING_THRESHOLD ? 'warning' : 'success');
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->label('Name')
->columnSpanFull(),
TextArea::make('ignored')
->columnSpanFull()
->label('Ignored Files & Directories'),
Toggle::make('is_locked')
->label('Lock?')
->helperText('Prevents this backup from being deleted until explicitly unlocked.'),
]);
}
public static function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->columns([
TextColumn::make('name')
->searchable(),
BytesColumn::make('bytes')
->label('Size'),
DateTimeColumn::make('created_at')
->label('Created')
->since()
->sortable(),
TextColumn::make('status')
->label('Status')
->badge(),
IconColumn::make('is_locked')
->visibleFrom('md')
->label('Lock Status')
->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'),
])
->actions([
ActionGroup::make([
Action::make('lock')
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? 'Lock' : 'Unlock')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download')
->color('primary')
->icon('tabler-download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore')
->color('success')
->icon('tabler-folder-up')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_RESTORE, $server))
->form([
Placeholder::make('')
->helperText('Your server will be stopped. You will not be able to control the power state, access the file manager, or create additional backups until this process is completed.'),
Checkbox::make('truncate')
->label('Delete all files before restoring backup?'),
])
->action(function (Backup $backup, $data, DaemonBackupRepository $daemonRepository, DownloadLinkService $downloadLinkService) use ($server) {
if (!is_null($server->status)) {
return Notification::make()
->danger()
->title('Backup Restore Failed')
->body('This server is not currently in a state that allows for a backup to be restored.')
->send();
}
if (!$backup->is_successful && is_null($backup->completed_at)) {
return Notification::make()
->danger()
->title('Backup Restore Failed')
->body('This backup cannot be restored at this time: not completed or failed.')
->send();
}
$log = Activity::event('server:backup.restore')
->subject($backup)
->property(['name' => $backup->name, 'truncate' => $data['truncate']]);
$log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) {
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow daemon to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $downloadLinkService->handle($backup, auth()->user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => ServerState::RestoringBackup]);
$daemonRepository->setServer($server)->restore($backup, $url ?? null, $data['truncate']);
});
return Notification::make()
->title('Restoring Backup')
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete')
->disabled(fn (Backup $backup) => $backup->is_locked)
->modalDescription(fn (Backup $backup) => 'Do you wish to delete ' . $backup->name . '?')
->modalSubmitActionLabel('Delete Backup')
->action(function (Backup $backup, DeleteBackupService $deleteBackupService) {
try {
$deleteBackupService->handle($backup);
} catch (ConnectionException) {
Notification::make()
->title('Could not delete backup')
->body('Connection to node failed')
->danger()
->send();
return;
}
Activity::event('server:backup.delete')
->subject($backup)
->property(['name' => $backup->name, 'failed' => !$backup->is_successful])
->log();
})
->visible(fn (Backup $backup) => $backup->status !== BackupStatus::InProgress),
]),
]);
} }
// TODO: find better way handle server conflict state // TODO: find better way handle server conflict state

View File

@ -2,28 +2,165 @@
namespace App\Filament\Server\Resources\BackupResource\Pages; namespace App\Filament\Server\Resources\BackupResource\Pages;
use App\Enums\BackupStatus;
use App\Enums\ServerState;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Server\Resources\BackupResource; use App\Filament\Server\Resources\BackupResource;
use App\Http\Controllers\Api\Client\Servers\BackupController;
use App\Models\Backup;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DownloadLinkService;
use App\Services\Backups\InitiateBackupService; use App\Services\Backups\InitiateBackupService;
use Filament\Actions\CreateAction; use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
class ListBackups extends ListRecords class ListBackups extends ListRecords
{ {
protected static string $resource = BackupResource::class; protected static string $resource = BackupResource::class;
protected static bool $canCreateAnother = false;
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->label('Name')
->columnSpanFull(),
TextArea::make('ignored')
->columnSpanFull()
->label('Ignored Files & Directories'),
Toggle::make('is_locked')
->label('Lock?')
->helperText('Prevents this backup from being deleted until explicitly unlocked.'),
]);
}
public function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->columns([
TextColumn::make('name')
->searchable(),
BytesColumn::make('bytes')
->label('Size'),
DateTimeColumn::make('created_at')
->label('Created')
->since()
->sortable(),
TextColumn::make('status')
->label('Status')
->badge(),
IconColumn::make('is_locked')
->visibleFrom('md')
->label('Lock Status')
->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'),
])
->actions([
ActionGroup::make([
Action::make('lock')
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? 'Lock' : 'Unlock')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download')
->color('primary')
->icon('tabler-download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore')
->color('success')
->icon('tabler-folder-up')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_RESTORE, $server))
->form([
Placeholder::make('')
->helperText('Your server will be stopped. You will not be able to control the power state, access the file manager, or create additional backups until this process is completed.'),
Checkbox::make('truncate')
->label('Delete all files before restoring backup?'),
])
->action(function (Backup $backup, $data, DaemonBackupRepository $daemonRepository, DownloadLinkService $downloadLinkService) use ($server) {
if (!is_null($server->status)) {
return Notification::make()
->danger()
->title('Backup Restore Failed')
->body('This server is not currently in a state that allows for a backup to be restored.')
->send();
}
if (!$backup->is_successful && is_null($backup->completed_at)) { //TODO Change to Notifications
return Notification::make()
->danger()
->title('Backup Restore Failed')
->body('This backup cannot be restored at this time: not completed or failed.')
->send();
}
$log = Activity::event('server:backup.restore')
->subject($backup)
->property(['name' => $backup->name, 'truncate' => $data['truncate']]);
$log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) {
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow daemon to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $downloadLinkService->handle($backup, auth()->user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => ServerState::RestoringBackup]);
$daemonRepository->setServer($server)->restore($backup, $url ?? null, $data['truncate']);
});
return Notification::make()
->title('Restoring Backup')
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete')
->disabled(fn (Backup $backup) => $backup->is_locked)
->modalDescription(fn (Backup $backup) => 'Do you wish to delete, ' . $backup->name . '?')
->modalSubmitActionLabel('Delete Backup')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->delete($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status !== BackupStatus::InProgress),
]),
]);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
return [ return [
CreateAction::make() Actions\CreateAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_CREATE, $server))
->label(fn () => $server->backups()->count() >= $server->backup_limit ? 'Backup limit reached' : 'Create Backup') ->label(fn () => $server->backups()->count() >= $server->backup_limit ? 'Backup limit reached' : 'Create Backup')
->disabled(fn () => $server->backups()->count() >= $server->backup_limit) ->disabled(fn () => $server->backups()->count() >= $server->backup_limit)

View File

@ -2,23 +2,13 @@
namespace App\Filament\Server\Resources; namespace App\Filament\Server\Resources;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\DatabaseResource\Pages; use App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Models\Database; use App\Models\Database;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Services\Databases\DatabaseManagementService;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class DatabaseResource extends Resource class DatabaseResource extends Resource
{ {
@ -52,65 +42,9 @@ class DatabaseResource extends Resource
return null; return null;
} }
return $count >= $limit ? 'danger' : ($count >= $limit * self::WARNING_THRESHOLD ? 'warning' : 'success'); return $count >= $limit
} ? 'danger'
: ($count >= $limit * self::WARNING_THRESHOLD ? 'warning' : 'success');
public static function form(Form $form): Form
{
/** @var Server $server */
$server = Filament::getTenant();
return $form
->schema([
TextInput::make('host')
->formatStateUsing(fn (Database $database) => $database->address())
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('database')
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('username')
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('password')
->password()->revealable()
->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hintAction(
RotateDatabasePasswordAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_DATABASE_UPDATE, $server))
)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote')
->label('Connections From'),
TextInput::make('max_connections')
->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'),
TextInput::make('jdbc')
->label('JDBC Connection String')
->password()->revealable()
->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull()
->formatStateUsing(fn (Database $database) => $database->jdbc),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('host')
->state(fn (Database $database) => $database->address())
->badge(),
TextColumn::make('database'),
TextColumn::make('username'),
TextColumn::make('remote'),
DateTimeColumn::make('created_at')
->sortable(),
])
->actions([
ViewAction::make()
->modalHeading(fn (Database $database) => 'Viewing ' . $database->database),
DeleteAction::make()
->using(fn (Database $database, DatabaseManagementService $service) => $service->delete($database)),
]);
} }
// TODO: find better way handle server conflict state // TODO: find better way handle server conflict state

View File

@ -2,8 +2,12 @@
namespace App\Filament\Server\Resources\DatabaseResource\Pages; namespace App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\DatabaseResource; use App\Filament\Server\Resources\DatabaseResource;
use App\Models\Database;
use App\Models\DatabaseHost; use App\Models\DatabaseHost;
use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Services\Databases\DatabaseManagementService; use App\Services\Databases\DatabaseManagementService;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
@ -11,12 +15,76 @@ use Filament\Facades\Filament;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class ListDatabases extends ListRecords class ListDatabases extends ListRecords
{ {
protected static string $resource = DatabaseResource::class; protected static string $resource = DatabaseResource::class;
public function form(Form $form): Form
{
/** @var Server $server */
$server = Filament::getTenant();
return $form
->schema([
TextInput::make('host')
->formatStateUsing(fn (Database $database) => $database->address())
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('database')
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('username')
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
TextInput::make('password')
->password()->revealable()
->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hintAction(
RotateDatabasePasswordAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_DATABASE_UPDATE, $server))
)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote')
->label('Connections From'),
TextInput::make('max_connections')
->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'),
TextInput::make('jdbc')
->label('JDBC Connection String')
->password()->revealable()
->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull()
->formatStateUsing(fn (Database $database) => $database->jdbc),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('host')
->state(fn (Database $database) => $database->address())
->badge(),
TextColumn::make('database'),
TextColumn::make('username'),
TextColumn::make('remote'),
DateTimeColumn::make('created_at')
->sortable(),
])
->actions([
ViewAction::make()
->modalHeading(fn (Database $database) => 'Viewing ' . $database->database),
DeleteAction::make()
->using(fn (Database $database, DatabaseManagementService $service) => $service->delete($database)),
]);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
/** @var Server $server */ /** @var Server $server */

View File

@ -314,9 +314,9 @@ class ListFiles extends ListRecords
->label('') ->label('')
->icon('tabler-trash') ->icon('tabler-trash')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(fn (File $file) => trans('filament-actions::delete.single.modal.heading', ['label' => $file->name . ' ' . ($file->is_directory ? 'folder' : 'file')])) ->modalDescription(fn (File $file) => $file->name)
->modalHeading('Delete file?')
->action(function (File $file) { ->action(function (File $file) {
$this->deselectAllTableRecords();
$this->getDaemonFileRepository()->deleteFiles($this->path, [$file->name]); $this->getDaemonFileRepository()->deleteFiles($this->path, [$file->name]);
Activity::event('server:file.delete') Activity::event('server:file.delete')

View File

@ -2,8 +2,6 @@
namespace App\Filament\Server\Resources; namespace App\Filament\Server\Resources;
use App\Facades\Activity;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\ScheduleResource\Pages; use App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager; use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager;
use App\Helpers\Utilities; use App\Helpers\Utilities;
@ -25,12 +23,6 @@ use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ScheduleResource extends Resource class ScheduleResource extends Resource
@ -311,44 +303,6 @@ class ScheduleResource extends Resource
]); ]);
} }
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('cron')
->state(fn (Schedule $schedule) => $schedule->cron_minute . ' ' . $schedule->cron_hour . ' ' . $schedule->cron_day_of_month . ' ' . $schedule->cron_month . ' ' . $schedule->cron_day_of_week),
TextColumn::make('status')
->state(fn (Schedule $schedule) => !$schedule->is_active ? 'Inactive' : ($schedule->is_processing ? 'Processing' : 'Active')),
IconColumn::make('only_when_online')
->boolean()
->sortable(),
DateTimeColumn::make('last_run_at')
->label('Last run')
->placeholder('Never')
->since()
->sortable(),
DateTimeColumn::make('next_run_at')
->label('Next run')
->placeholder('Never')
->since()
->sortable()
->state(fn (Schedule $schedule) => $schedule->is_active ? $schedule->next_run_at : null),
])
->actions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make()
->after(function (Schedule $schedule) {
Activity::event('server:schedule.delete')
->subject($schedule)
->property('name', $schedule->name)
->log();
}),
]);
}
public static function getRelations(): array public static function getRelations(): array
{ {
return [ return [

View File

@ -2,19 +2,65 @@
namespace App\Filament\Server\Resources\ScheduleResource\Pages; namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Facades\Activity;
use App\Filament\Server\Resources\ScheduleResource; use App\Filament\Server\Resources\ScheduleResource;
use Filament\Actions\CreateAction; use App\Models\Schedule;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListSchedules extends ListRecords class ListSchedules extends ListRecords
{ {
protected static string $resource = ScheduleResource::class; protected static string $resource = ScheduleResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('cron')
->state(fn (Schedule $schedule) => $schedule->cron_minute . ' ' . $schedule->cron_hour . ' ' . $schedule->cron_day_of_month . ' ' . $schedule->cron_month . ' ' . $schedule->cron_day_of_week),
TextColumn::make('status')
->state(fn (Schedule $schedule) => !$schedule->is_active ? 'Inactive' : ($schedule->is_processing ? 'Processing' : 'Active')),
IconColumn::make('only_when_online')
->boolean()
->sortable(),
DateTimeColumn::make('last_run_at')
->label('Last run')
->placeholder('Never')
->since()
->sortable(),
DateTimeColumn::make('next_run_at')
->label('Next run')
->placeholder('Never')
->since()
->sortable()
->state(fn (Schedule $schedule) => $schedule->is_active ? $schedule->next_run_at : null),
])
->actions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make()
->after(function (Schedule $schedule) {
Activity::event('server:schedule.delete')
->subject($schedule)
->property('name', $schedule->name)
->log();
}),
]);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
CreateAction::make() Actions\CreateAction::make()->label('New Schedule'),
->label('New Schedule'),
]; ];
} }

View File

@ -7,8 +7,7 @@ use App\Filament\Server\Resources\ScheduleResource;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Schedule; use App\Models\Schedule;
use App\Services\Schedules\ProcessScheduleService; use App\Services\Schedules\ProcessScheduleService;
use Filament\Actions\Action; use Filament\Actions;
use Filament\Actions\EditAction;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
@ -19,7 +18,7 @@ class ViewSchedule extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Action::make('runNow') Actions\Action::make('runNow')
->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant())) ->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant()))
->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? 'No tasks' : ($schedule->is_processing ? 'Processing' : 'Run now')) ->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? 'No tasks' : ($schedule->is_processing ? 'Processing' : 'Run now'))
->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing ? 'warning' : 'primary') ->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing ? 'warning' : 'primary')
@ -34,7 +33,7 @@ class ViewSchedule extends ViewRecord
$this->fillForm(); $this->fillForm();
}), }),
EditAction::make(), Actions\EditAction::make(),
]; ];
} }

View File

@ -6,10 +6,8 @@ use App\Enums\ContainerStatus;
use App\Filament\Server\Components\SmallStatBlock; use App\Filament\Server\Components\SmallStatBlock;
use App\Models\Server; use App\Models\Server;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Filament\Notifications\Notification;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Illuminate\Support\Number; use Illuminate\Support\Number;
use Livewire\Attributes\On;
class ServerOverview extends StatsOverviewWidget class ServerOverview extends StatsOverviewWidget
{ {
@ -21,10 +19,14 @@ class ServerOverview extends StatsOverviewWidget
{ {
return [ return [
SmallStatBlock::make('Name', $this->server->name) SmallStatBlock::make('Name', $this->server->name)
->copyOnClick(fn () => request()->isSecure()), ->extraAttributes([
'class' => 'overflow-x-auto',
]),
SmallStatBlock::make('Status', $this->status()), SmallStatBlock::make('Status', $this->status()),
SmallStatBlock::make('Address', $this->server->allocation->address) SmallStatBlock::make('Address', $this->server->allocation->address)
->copyOnClick(fn () => request()->isSecure()), ->extraAttributes([
'class' => 'overflow-x-auto',
]),
SmallStatBlock::make('CPU', $this->cpuUsage()), SmallStatBlock::make('CPU', $this->cpuUsage()),
SmallStatBlock::make('Memory', $this->memoryUsage()), SmallStatBlock::make('Memory', $this->memoryUsage()),
SmallStatBlock::make('Disk', $this->diskUsage()), SmallStatBlock::make('Disk', $this->diskUsage()),
@ -91,16 +93,4 @@ class ServerOverview extends StatsOverviewWidget
return $used . ($this->server->disk > 0 ? ' / ' . $total : ' / ∞'); return $used . ($this->server->disk > 0 ? ' / ' . $total : ' / ∞');
} }
#[On('copyClick')]
public function copyClick(string $value): void
{
$this->js("window.navigator.clipboard.writeText('{$value}');");
Notification::make()
->title('Copied to clipboard')
->body($value)
->success()
->send();
}
} }

View File

@ -62,7 +62,7 @@ class NetworkAllocationController extends ClientApiController
if ($original !== $allocation->notes) { if ($original !== $allocation->notes) {
Activity::event('server:allocation.notes') Activity::event('server:allocation.notes')
->subject($allocation) ->subject($allocation)
->property(['allocation' => $allocation->address, 'old' => $original, 'new' => $allocation->notes]) ->property(['allocation' => $allocation->toString(), 'old' => $original, 'new' => $allocation->notes])
->log(); ->log();
} }
@ -87,7 +87,7 @@ class NetworkAllocationController extends ClientApiController
Activity::event('server:allocation.primary') Activity::event('server:allocation.primary')
->subject($allocation) ->subject($allocation)
->property('allocation', $allocation->address) ->property('allocation', $allocation->toString())
->log(); ->log();
return $this->fractal->item($allocation) return $this->fractal->item($allocation)
@ -114,7 +114,7 @@ class NetworkAllocationController extends ClientApiController
Activity::event('server:allocation.create') Activity::event('server:allocation.create')
->subject($allocation) ->subject($allocation)
->property('allocation', $allocation->address) ->property('allocation', $allocation->toString())
->log(); ->log();
return $this->fractal->item($allocation) return $this->fractal->item($allocation)
@ -148,7 +148,7 @@ class NetworkAllocationController extends ClientApiController
Activity::event('server:allocation.delete') Activity::event('server:allocation.delete')
->subject($allocation) ->subject($allocation)
->property('allocation', $allocation->address) ->property('allocation', $allocation->toString())
->log(); ->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Client\Servers;
use App\Facades\Activity; use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController; use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Settings\DescriptionServerRequest;
use App\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest; use App\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
use App\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; use App\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
use App\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest; use App\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
@ -35,36 +34,24 @@ class SettingsController extends ClientApiController
public function rename(RenameServerRequest $request, Server $server): JsonResponse public function rename(RenameServerRequest $request, Server $server): JsonResponse
{ {
$name = $request->input('name'); $name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description;
$server->update(['name' => $name]); if ($server->name !== $name) {
if ($server->wasChanged('name')) {
Activity::event('server:settings.rename') Activity::event('server:settings.rename')
->property(['old' => $server->getOriginal('name'), 'new' => $name]) ->property(['old' => $server->name, 'new' => $name])
->log(); ->log();
$server->name = $name;
} }
return new JsonResponse([], Response::HTTP_NO_CONTENT); if ($server->description !== $description && config('panel.editable_server_descriptions')) {
}
/**
* Update server description
*/
public function description(DescriptionServerRequest $request, Server $server): JsonResponse
{
if (!config('panel.editable_server_descriptions')) {
return new JsonResponse([], Response::HTTP_FORBIDDEN);
}
$description = $request->input('description');
$server->update(['description' => $description ?? '']);
if ($server->wasChanged('description')) {
Activity::event('server:settings.description') Activity::event('server:settings.description')
->property(['old' => $server->getOriginal('description'), 'new' => $description]) ->property(['old' => $server->description, 'new' => $description])
->log(); ->log();
$server->description = $description;
} }
$server->save();
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
@ -93,7 +80,7 @@ class SettingsController extends ClientApiController
*/ */
public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse
{ {
if (!in_array($server->image, $server->egg->docker_images)) { if (!in_array($server->image, array_values($server->egg->docker_images))) {
throw new BadRequestHttpException('This server\'s Docker image has been manually set by an administrator and cannot be updated.'); throw new BadRequestHttpException('This server\'s Docker image has been manually set by an administrator and cannot be updated.');
} }

View File

@ -1,30 +0,0 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Settings;
use App\Contracts\Http\ClientPermissionsRequest;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class DescriptionServerRequest extends ClientApiRequest implements ClientPermissionsRequest
{
/**
* Returns the permissions string indicating which permission should be used to
* validate that the authenticated user has permission to perform this action against
* the given resource (server).
*/
public function permission(): string
{
return Permission::ACTION_SETTINGS_DESCRIPTION;
}
/**
* The rules to apply when validating this request.
*/
public function rules(): array
{
return [
'description' => 'string|nullable',
];
}
}

View File

@ -26,6 +26,7 @@ class RenameServerRequest extends ClientApiRequest implements ClientPermissionsR
{ {
return [ return [
'name' => Server::getRules()['name'], 'name' => Server::getRules()['name'],
'description' => 'string|nullable',
]; ];
} }
} }

View File

@ -58,7 +58,7 @@ abstract class SubuserRequest extends ClientApiRequest
$server = $this->route()->parameter('server'); $server = $this->route()->parameter('server');
// If we are an admin or the server owner, no need to perform these checks. // If we are an admin or the server owner, no need to perform these checks.
if ($user->can('update', $server) || $user->id === $server->owner_id) { if ($user->can('update server', $server) || $user->id === $server->owner_id) {
return; return;
} }

View File

@ -0,0 +1,35 @@
<?php
namespace App\Jobs;
use App\Models\Node;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NodeStatistics implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
foreach (Node::all() as $node) {
$stats = $node->statistics();
$timestamp = now()->getTimestamp();
foreach ($stats as $key => $value) {
$cacheKey = "nodes.{$node->id}.$key";
$data = cache()->get($cacheKey, []);
// Add current timestamp and value to the data array
$data[$timestamp] = $value;
// Update the cache with the new data, expires in 1 minute
cache()->put($cacheKey, $data, now()->addMinute());
}
}
}
}

View File

@ -4,7 +4,7 @@ namespace App\Listeners\Auth;
use App\Facades\Activity; use App\Facades\Activity;
use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login; use App\Events\Auth\DirectLogin;
class AuthenticationListener class AuthenticationListener
{ {
@ -12,10 +12,9 @@ class AuthenticationListener
* 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.
*/ */
public function handle(Failed|Login $event): void public function handle(Failed|DirectLogin $event): void
{ {
$activity = Activity::withRequestMetadata(); $activity = Activity::withRequestMetadata();
if ($event->user) { if ($event->user) {
$activity = $activity->subject($event->user); $activity = $activity->subject($event->user);
} }

View File

@ -2,14 +2,22 @@
namespace App\Listeners\Auth; namespace App\Listeners\Auth;
use Illuminate\Http\Request;
use App\Facades\Activity; use App\Facades\Activity;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
class PasswordResetListener class PasswordResetListener
{ {
protected Request $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function handle(PasswordReset $event): void public function handle(PasswordReset $event): void
{ {
Activity::event('auth:password-reset') Activity::event('event:password-reset')
->withRequestMetadata() ->withRequestMetadata()
->subject($event->user) ->subject($event->user)
->log(); ->log();

View File

@ -1,64 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Server;
use Illuminate\View\View;
use Livewire\Component;
class ServerEntry extends Component
{
public Server $server;
public function render(): View
{
return view('livewire.server-entry');
}
public function placeholder(): string
{
return <<<'HTML'
<div class="relative">
<div
class="absolute left-0 top-1 bottom-0 w-1 rounded-lg"
style="background-color: #D97706;">
</div>
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
<div class="flex items-center mb-5 gap-2">
<x-filament::loading-indicator class="h-5 w-5" />
<h2 class="text-xl font-bold">
{{ $server->name }}
</h2>
</div>
<div class="flex justify-between text-center">
<div>
<p class="text-sm dark:text-gray-400">CPU</p>
<p class="text-md font-semibold">{{ Number::format(0, precision: 2, locale: auth()->user()->language ?? 'en') . '%' }}</p>
<hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('cpu', type: \App\Enums\ServerResourceType::Percentage, limit: true) }}</p>
</div>
<div>
<p class="text-sm dark:text-gray-400">Memory</p>
<p class="text-md font-semibold">{{ convert_bytes_to_readable(0, decimals: 2) }}</p>
<hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('memory', limit: true) }}</p>
</div>
<div>
<p class="text-sm dark:text-gray-400">Disk</p>
<p class="text-md font-semibold">{{ convert_bytes_to_readable(0, decimals: 2) }}</p>
<hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('disk', limit: true) }}</p>
</div>
<div class="hidden sm:block">
<p class="text-sm dark:text-gray-400">Network</p>
<hr class="p-0.5">
<p class="text-md font-semibold">{{ $server->allocation->address }} </p>
</div>
</div>
</div>
</div>
HTML;
}
}

View File

@ -121,6 +121,11 @@ class Allocation extends Model
); );
} }
public function toString(): string
{
return $this->address;
}
/** /**
* Gets information for the server associated with this allocation. * Gets information for the server associated with this allocation.
*/ */

View File

@ -38,7 +38,6 @@ use Symfony\Component\Yaml\Yaml;
* @property string $daemon_token_id * @property string $daemon_token_id
* @property string $daemon_token * @property string $daemon_token
* @property int $daemon_listen * @property int $daemon_listen
* @property int $daemon_connect
* @property int $daemon_sftp * @property int $daemon_sftp
* @property string|null $daemon_sftp_alias * @property string|null $daemon_sftp_alias
* @property string $daemon_base * @property string $daemon_base
@ -84,7 +83,7 @@ class Node extends Model implements Validatable
'memory', 'memory_overallocate', 'disk', 'memory', 'memory_overallocate', 'disk',
'disk_overallocate', 'cpu', 'cpu_overallocate', 'disk_overallocate', 'cpu', 'cpu_overallocate',
'upload_size', 'daemon_base', 'upload_size', 'daemon_base',
'daemon_sftp', 'daemon_sftp_alias', 'daemon_listen', 'daemon_connect', 'daemon_sftp', 'daemon_sftp_alias', 'daemon_listen',
'description', 'maintenance_mode', 'tags', 'description', 'maintenance_mode', 'tags',
]; ];
@ -106,7 +105,6 @@ class Node extends Model implements Validatable
'daemon_sftp' => ['required', 'numeric', 'between:1,65535'], 'daemon_sftp' => ['required', 'numeric', 'between:1,65535'],
'daemon_sftp_alias' => ['nullable', 'string'], 'daemon_sftp_alias' => ['nullable', 'string'],
'daemon_listen' => ['required', 'numeric', 'between:1,65535'], 'daemon_listen' => ['required', 'numeric', 'between:1,65535'],
'daemon_connect' => ['required', 'numeric', 'between:1,65535'],
'maintenance_mode' => ['boolean'], 'maintenance_mode' => ['boolean'],
'upload_size' => ['int', 'between:1,1024'], 'upload_size' => ['int', 'between:1,1024'],
'tags' => ['array'], 'tags' => ['array'],
@ -127,7 +125,6 @@ class Node extends Model implements Validatable
'daemon_base' => '/var/lib/pelican/volumes', 'daemon_base' => '/var/lib/pelican/volumes',
'daemon_sftp' => 2022, 'daemon_sftp' => 2022,
'daemon_listen' => 8080, 'daemon_listen' => 8080,
'daemon_connect' => 8080,
'maintenance_mode' => false, 'maintenance_mode' => false,
'tags' => '[]', 'tags' => '[]',
]; ];
@ -139,7 +136,6 @@ class Node extends Model implements Validatable
'disk' => 'integer', 'disk' => 'integer',
'cpu' => 'integer', 'cpu' => 'integer',
'daemon_listen' => 'integer', 'daemon_listen' => 'integer',
'daemon_connect' => 'integer',
'daemon_sftp' => 'integer', 'daemon_sftp' => 'integer',
'daemon_token' => 'encrypted', 'daemon_token' => 'encrypted',
'behind_proxy' => 'boolean', 'behind_proxy' => 'boolean',
@ -175,7 +171,7 @@ class Node extends Model implements Validatable
*/ */
public function getConnectionAddress(): string public function getConnectionAddress(): string
{ {
return "$this->scheme://$this->fqdn:$this->daemon_connect"; return "$this->scheme://$this->fqdn:$this->daemon_listen";
} }
/** /**
@ -333,6 +329,26 @@ class Node extends Model implements Validatable
}); });
} }
/**
* @return array<array-key, mixed>
*/
public function serverStatuses(): array
{
$statuses = [];
try {
$statuses = Http::daemon($this)->connectTimeout(1)->timeout(1)->get('/api/servers')->json() ?? [];
} catch (Exception $exception) {
report($exception);
}
foreach ($statuses as $status) {
$uuid = fluent($status)->get('configuration.uuid');
cache()->remember("servers.$uuid.container.status", now()->addMinute(), fn () => fluent($status)->get('state'));
}
return $statuses;
}
/** @return array{ /** @return array{
* memory_total: int, memory_used: int, * memory_total: int, memory_used: int,
* swap_total: int, swap_used: int, * swap_total: int, swap_used: int,

View File

@ -97,8 +97,6 @@ class Permission extends Model implements Validatable
public const ACTION_SETTINGS_RENAME = 'settings.rename'; public const ACTION_SETTINGS_RENAME = 'settings.rename';
public const ACTION_SETTINGS_DESCRIPTION = 'settings.description';
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
public const ACTION_ACTIVITY_READ = 'activity.read'; public const ACTION_ACTIVITY_READ = 'activity.read';
@ -178,7 +176,7 @@ class Permission extends Model implements Validatable
[ [
'name' => 'settings', 'name' => 'settings',
'icon' => 'tabler-settings', 'icon' => 'tabler-settings',
'permissions' => ['rename', 'description', 'reinstall'], 'permissions' => ['rename', 'reinstall'],
], ],
[ [
'name' => 'activity', 'name' => 'activity',

View File

@ -435,24 +435,25 @@ class Server extends Model implements Validatable
public function retrieveStatus(): ContainerStatus public function retrieveStatus(): ContainerStatus
{ {
return cache()->remember("servers.$this->uuid.status", now()->addSeconds(15), function () { $status = cache()->get("servers.$this->uuid.container.status");
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$details = app(DaemonServerRepository::class)->setServer($this)->getDetails();
return ContainerStatus::tryFrom(Arr::get($details, 'state')) ?? ContainerStatus::Missing; if ($status === null) {
}); $this->node->serverStatuses();
$status = cache()->get("servers.$this->uuid.container.status");
}
return ContainerStatus::tryFrom($status) ?? ContainerStatus::Missing;
} }
/** /**
* @return array<mixed> * @return array<mixed>
*/ */
public function retrieveResources(): array public function resources(): array
{ {
return cache()->remember("servers.$this->uuid.resources", now()->addSeconds(15), function () { return cache()->remember("resources:$this->uuid", now()->addSeconds(15), function () {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$details = app(DaemonServerRepository::class)->setServer($this)->getDetails(); return Arr::get(app(DaemonServerRepository::class)->setServer($this)->getDetails(), 'utilization', []);
return Arr::get($details, 'utilization', []);
}); });
} }
@ -460,7 +461,7 @@ class Server extends Model implements Validatable
{ {
$resourceAmount = $this->{$resourceKey} ?? 0; $resourceAmount = $this->{$resourceKey} ?? 0;
if (!$limit) { if (!$limit) {
$resourceAmount = $this->retrieveResources()[$resourceKey] ?? 0; $resourceAmount = $this->resources()[$resourceKey] ?? 0;
} }
if ($type === ServerResourceType::Time) { if ($type === ServerResourceType::Time) {
@ -493,7 +494,7 @@ class Server extends Model implements Validatable
public function condition(): Attribute public function condition(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->status ?? $this->retrieveStatus(), get: fn () => $this->isSuspended() ? ServerState::Suspended : $this->status ?? $this->retrieveStatus(),
); );
} }
} }

View File

@ -265,13 +265,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/ */
public function accessibleServers(): Builder public function accessibleServers(): Builder
{ {
if ($this->canned('viewAny', Server::class)) { if ($this->canned('viewList server')) {
return Server::select('servers.*') return Server::query();
->leftJoin('subusers', 'subusers.server_id', '=', 'servers.id')
->where(function (Builder $builder) {
$builder->where('servers.owner_id', $this->id)->orWhere('subusers.user_id', $this->id)->orWhereIn('servers.node_id', $this->accessibleNodes()->pluck('id'));
})
->distinct('servers.id');
} }
return $this->directAccessibleServers(); return $this->directAccessibleServers();
@ -283,7 +278,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/ */
public function directAccessibleServers(): Builder public function directAccessibleServers(): Builder
{ {
return Server::select('servers.*') return Server::query()
->select('servers.*')
->leftJoin('subusers', 'subusers.server_id', '=', 'servers.id') ->leftJoin('subusers', 'subusers.server_id', '=', 'servers.id')
->where(function (Builder $builder) { ->where(function (Builder $builder) {
$builder->where('servers.owner_id', $this->id)->orWhere('subusers.user_id', $this->id); $builder->where('servers.owner_id', $this->id)->orWhere('subusers.user_id', $this->id);
@ -318,12 +314,12 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool protected function checkPermission(Server $server, string $permission = ''): bool
{ {
if ($this->canned('update', $server) || $server->owner_id === $this->id) { if ($this->canned('update server', $server) || $server->owner_id === $this->id) {
return true; return true;
} }
// If the user only has "view" permissions allow viewing the console // If the user only has "view" permissions allow viewing the console
if ($permission === Permission::ACTION_WEBSOCKET_CONNECT && $this->canned('view', $server)) { if ($permission === Permission::ACTION_WEBSOCKET_CONNECT && $this->canned('view server', $server)) {
return true; return true;
} }
@ -438,7 +434,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function canAccessTenant(Model $tenant): bool public function canAccessTenant(Model $tenant): bool
{ {
if ($tenant instanceof Server) { if ($tenant instanceof Server) {
if ($this->canned('view', $tenant) || $tenant->owner_id === $this->id) { if ($this->canned('view server', $tenant) || $tenant->owner_id === $this->id) {
return true; return true;
} }

View File

@ -30,7 +30,7 @@ class AccountCreated extends Notification implements ShouldQueue
->line('Email: ' . $notifiable->email); ->line('Email: ' . $notifiable->email);
if (!is_null($this->token)) { if (!is_null($this->token)) {
return $message->action('Setup Your Account', Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable)); return $message->action('Setup Your Account', Filament::getResetPasswordUrl($this->token, $notifiable));
} }
return $message; return $message;

View File

@ -21,6 +21,11 @@ class ServerPolicy
return null; return null;
} }
// Make sure user can target node of the server
if (!$user->canTarget($server->node)) {
return false;
}
// Owner has full server permissions // Owner has full server permissions
if ($server->owner_id === $user->id) { if ($server->owner_id === $user->id) {
return true; return true;
@ -32,11 +37,6 @@ class ServerPolicy
return true; return true;
} }
// Make sure user can target node of the server
if (!$user->canTarget($server->node)) {
return false;
}
// Return null to let default policies take over // Return null to let default policies take over
return null; return null;
} }

View File

@ -64,7 +64,7 @@ class ServerPanelProvider extends PanelProvider
->navigationItems([ ->navigationItems([
NavigationItem::make('Open in Admin') NavigationItem::make('Open in Admin')
->url(fn () => EditServer::getUrl(['record' => Filament::getTenant()], panel: 'admin')) ->url(fn () => EditServer::getUrl(['record' => Filament::getTenant()], panel: 'admin'))
->visible(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin')) && auth()->user()->can('view server', Filament::getTenant())) ->visible(fn () => auth()->user()->can('view server', Filament::getTenant()))
->icon('tabler-arrow-back') ->icon('tabler-arrow-back')
->sort(99), ->sort(99),
]) ])

View File

@ -19,7 +19,7 @@ class DaemonServerRepository extends DaemonRepository
public function getDetails(): array public function getDetails(): array
{ {
try { try {
return $this->getHttpClient()->connectTimeout(1)->timeout(1)->get("/api/servers/{$this->server->uuid}")->throw()->json(); return $this->getHttpClient()->get("/api/servers/{$this->server->uuid}")->throw()->json();
} catch (RequestException $exception) { } catch (RequestException $exception) {
$cfId = $exception->response->header('Cf-Ray'); $cfId = $exception->response->header('Cf-Ray');
$cfCache = $exception->response->header('Cf-Cache-Status'); $cfCache = $exception->response->header('Cf-Cache-Status');

View File

@ -6,11 +6,12 @@ use App\Extensions\Filesystem\S3Filesystem;
use Aws\S3\S3Client; use Aws\S3\S3Client;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use App\Models\Backup; use App\Models\Backup;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use App\Extensions\Backups\BackupManager; use App\Extensions\Backups\BackupManager;
use App\Repositories\Daemon\DaemonBackupRepository; use App\Repositories\Daemon\DaemonBackupRepository;
use App\Exceptions\Service\Backup\BackupLockedException; use App\Exceptions\Service\Backup\BackupLockedException;
use Exception; use Illuminate\Http\Client\ConnectionException;
class DeleteBackupService class DeleteBackupService
{ {
@ -47,10 +48,12 @@ class DeleteBackupService
$this->connection->transaction(function () use ($backup) { $this->connection->transaction(function () use ($backup) {
try { try {
$this->daemonBackupRepository->setServer($backup->server)->delete($backup); $this->daemonBackupRepository->setServer($backup->server)->delete($backup);
} catch (Exception $exception) { } catch (ConnectionException $exception) {
$previous = $exception->getPrevious();
// Don't fail the request if the Daemon responds with a 404, just assume the backup // Don't fail the request if the Daemon responds with a 404, just assume the backup
// doesn't actually exist and remove its reference from the Panel as well. // doesn't actually exist and remove its reference from the Panel as well.
if ($exception->getCode() !== Response::HTTP_NOT_FOUND) { if (!$previous instanceof ClientException || $previous->getResponse()->getStatusCode() !== Response::HTTP_NOT_FOUND) {
throw $exception; throw $exception;
} }
} }

View File

@ -16,8 +16,8 @@ class GetUserPermissionsService
*/ */
public function handle(Server $server, User $user): array public function handle(Server $server, User $user): array
{ {
if ($user->isAdmin() && ($user->can('view', $server) || $user->can('update', $server))) { if ($user->isAdmin() && ($user->can('view server', $server) || $user->can('update server', $server))) {
$permissions = $user->can('update', $server) ? ['*'] : ['websocket.connect', 'backup.read']; $permissions = $user->can('update server', $server) ? ['*'] : ['websocket.connect', 'backup.read'];
$permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.install';

View File

@ -47,7 +47,7 @@ class TransferServerService
// Check if the node is viable for the transfer. // Check if the node is viable for the transfer.
$node = Node::query() $node = Node::query()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_connect', 'nodes.memory', 'nodes.disk', 'nodes.cpu', 'nodes.memory_overallocate', 'nodes.disk_overallocate', 'nodes.cpu_overallocate']) ->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_listen', 'nodes.memory', 'nodes.disk', 'nodes.cpu', 'nodes.memory_overallocate', 'nodes.disk_overallocate', 'nodes.cpu_overallocate'])
->withSum('servers', 'disk') ->withSum('servers', 'disk')
->withSum('servers', 'memory') ->withSum('servers', 'memory')
->withSum('servers', 'cpu') ->withSum('servers', 'cpu')

View File

@ -2,20 +2,54 @@
namespace App\Traits; namespace App\Traits;
use Illuminate\Support\Env; use Exception;
use RuntimeException;
trait EnvironmentWriterTrait trait EnvironmentWriterTrait
{ {
/**
* Escapes an environment value by looking for any characters that could
* reasonably cause environment parsing issues. Those values are then wrapped
* in quotes before being returned.
*/
public function escapeEnvironmentValue(string $value): string
{
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
return sprintf('"%s"', addcslashes($value, '\\"'));
}
return $value;
}
/** /**
* Update the .env file for the application using the passed in values. * Update the .env file for the application using the passed in values.
* *
* @param array<string, mixed> $values * @param array<string, mixed> $values
* *
* @throws RuntimeException * @throws Exception
*/ */
public function writeToEnvironment(array $values = []): void public function writeToEnvironment(array $values = []): void
{ {
Env::writeVariables($values, base_path('.env'), true); $path = base_path('.env');
if (!file_exists($path)) {
throw new Exception('Cannot locate .env file, was this software installed correctly?');
}
$saveContents = file_get_contents($path);
if ($saveContents === false) {
$saveContents = '';
}
collect($values)->each(function ($value, $key) use (&$saveContents) {
$key = strtoupper($key);
$saveValue = sprintf('%s=%s', $key, $this->escapeEnvironmentValue($value ?? ''));
if (preg_match_all('/^' . $key . '=(.*)$/m', $saveContents) < 1) {
$saveContents = $saveContents . PHP_EOL . $saveValue;
} else {
$saveContents = preg_replace('/^' . $key . '=(.*)$/m', $saveValue, $saveContents);
}
});
file_put_contents($path, $saveContents);
} }
} }

View File

@ -1,7 +1,6 @@
{ {
"name": "pelican-dev/panel", "name": "pelican-dev/panel",
"description": "The free, open-source game management panel. Supporting Minecraft, Spigot, BungeeCord, and SRCDS servers.", "description": "The free, open-source game management panel. Supporting Minecraft, Spigot, BungeeCord, and SRCDS servers.",
"license": "AGPL-3.0-only",
"require": { "require": {
"php": "^8.2 || ^8.3 || ^8.4", "php": "^8.2 || ^8.3 || ^8.4",
"ext-intl": "*", "ext-intl": "*",
@ -10,17 +9,18 @@
"ext-pdo": "*", "ext-pdo": "*",
"ext-zip": "*", "ext-zip": "*",
"abdelhamiderrahmouni/filament-monaco-editor": "^0.2.5", "abdelhamiderrahmouni/filament-monaco-editor": "^0.2.5",
"aws/aws-sdk-php": "^3.344", "aws/aws-sdk-php": "^3.343",
"aymanalhattami/filament-context-menu": "^1.0",
"calebporzio/sushi": "^2.5", "calebporzio/sushi": "^2.5",
"chillerlan/php-qrcode": "^5.0.2", "chillerlan/php-qrcode": "^5.0.2",
"dedoc/scramble": "^0.12.10", "dedoc/scramble": "^0.12.10",
"doctrine/dbal": "~3.6.0", "doctrine/dbal": "~3.6.0",
"filament/filament": "^3.3", "filament/filament": "^3.3",
"guzzlehttp/guzzle": "^7.9", "guzzlehttp/guzzle": "^7.9",
"laravel/framework": "^12.18", "laravel/framework": "^12.13",
"laravel/helpers": "^1.7", "laravel/helpers": "^1.7",
"laravel/sanctum": "^4.1", "laravel/sanctum": "^4.1",
"laravel/socialite": "^5.21", "laravel/socialite": "^5.20",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"laravel/ui": "^4.6", "laravel/ui": "^4.6",
"lcobucci/jwt": "~4.3.0", "lcobucci/jwt": "~4.3.0",
@ -37,7 +37,7 @@
"spatie/laravel-data": "^4.15", "spatie/laravel-data": "^4.15",
"spatie/laravel-fractal": "^6.3", "spatie/laravel-fractal": "^6.3",
"spatie/laravel-health": "^1.34", "spatie/laravel-health": "^1.34",
"spatie/laravel-permission": "^6.19", "spatie/laravel-permission": "^6.17",
"spatie/laravel-query-builder": "^6.3", "spatie/laravel-query-builder": "^6.3",
"spatie/temporary-directory": "^2.3", "spatie/temporary-directory": "^2.3",
"symfony/http-client": "^7.2", "symfony/http-client": "^7.2",
@ -50,7 +50,7 @@
"require-dev": { "require-dev": {
"barryvdh/laravel-ide-helper": "^3.5", "barryvdh/laravel-ide-helper": "^3.5",
"fakerphp/faker": "^1.23.1", "fakerphp/faker": "^1.23.1",
"larastan/larastan": "^3.4", "larastan/larastan": "3.x-dev#5bd1c40edb43a727584081e74e9a1a2a201ea2ee",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.15.3", "laravel/pint": "^1.15.3",
"laravel/sail": "^1.41", "laravel/sail": "^1.41",

826
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,6 @@ class NodeFactory extends Factory
'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH), 'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH),
'daemon_token' => Str::random(Node::DAEMON_TOKEN_LENGTH), 'daemon_token' => Str::random(Node::DAEMON_TOKEN_LENGTH),
'daemon_listen' => 8080, 'daemon_listen' => 8080,
'daemon_connect' => 8080,
'daemon_sftp' => 2022, 'daemon_sftp' => 2022,
'daemon_base' => '/var/lib/panel/volumes', 'daemon_base' => '/var/lib/panel/volumes',
'maintenance_mode' => false, 'maintenance_mode' => false,

View File

@ -4,7 +4,7 @@
"version": "PLCN_v1", "version": "PLCN_v1",
"update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/rust\/egg-rust.json" "update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/rust\/egg-rust.json"
}, },
"exported_at": "2025-06-06T11:57:17+00:00", "exported_at": "2025-03-18T12:36:48+00:00",
"name": "Rust", "name": "Rust",
"author": "panel@example.com", "author": "panel@example.com",
"uuid": "bace2dfb-209c-452a-9459-7d6f340b07ae", "uuid": "bace2dfb-209c-452a-9459-7d6f340b07ae",
@ -20,7 +20,7 @@
"ghcr.io\/parkervcp\/games:rust": "ghcr.io\/parkervcp\/games:rust" "ghcr.io\/parkervcp\/games:rust": "ghcr.io\/parkervcp\/games:rust"
}, },
"file_denylist": [], "file_denylist": [],
"startup": ".\/RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.queryport {{QUERY_PORT}} +server.identity \"rust\" +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{SERVER_HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.logoimage \\\"{{SERVER_LOGO}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +server.saveinterval {{SAVEINTERVAL}} +app.port {{APP_PORT}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"{{WORLD_SEED}}\\\"\" || printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}", "startup": ".\/RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.queryport {{QUERY_PORT}} +server.identity \"rust\" +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.logoimage \\\"{{SERVER_LOGO}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +server.saveinterval {{SAVEINTERVAL}} +app.port {{APP_PORT}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"{{WORLD_SEED}}\\\"\" || printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}",
"config": { "config": {
"files": "{}", "files": "{}",
"startup": "{\r\n \"done\": \"Server startup complete\"\r\n}", "startup": "{\r\n \"done\": \"Server startup complete\"\r\n}",
@ -38,7 +38,7 @@
{ {
"name": "Server Name", "name": "Server Name",
"description": "The name of your server in the public server list.", "description": "The name of your server in the public server list.",
"env_variable": "SERVER_HOSTNAME", "env_variable": "HOSTNAME",
"default_value": "A Rust Server", "default_value": "A Rust Server",
"user_viewable": true, "user_viewable": true,
"user_editable": true, "user_editable": true,

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('nodes', function (Blueprint $table) {
$table->smallInteger('daemon_connect')->unsigned()->default(8080);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('nodes', function (Blueprint $table) {
$table->dropColumn('daemon_connect');
});
}
};

View File

@ -45,10 +45,6 @@ return [
'port' => 'Port', 'port' => 'Port',
'ports' => 'Ports', 'ports' => 'Ports',
'port_help' => 'If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.', 'port_help' => 'If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.',
'connect_port' => 'Connection Port',
'connect_port_help' => 'Connections to wings will use this port. If you are using a reverse proxy this can differ from the listen port. When using Cloudflare proxy you should use 8443.',
'listen_port' => 'Listening Port',
'listen_port_help' => 'Wings will listen on this port.',
'display_name' => 'Display Name', 'display_name' => 'Display Name',
'ssl' => 'Communicate over SSL', 'ssl' => 'Communicate over SSL',
'panel_on_ssl' => 'Your Panel is using a secure SSL connection,<br>so your Daemon must too.', 'panel_on_ssl' => 'Your Panel is using a secure SSL connection,<br>so your Daemon must too.',

View File

@ -16,7 +16,6 @@ return [
'failed' => 'These credentials do not match our records.', 'failed' => 'These credentials do not match our records.',
'failed-two-factor' => 'Incorrect 2FA Code', 'failed-two-factor' => 'Incorrect 2FA Code',
'two-factor-code' => 'Two Factor Code', 'two-factor-code' => 'Two Factor Code',
'two-factor-hint' => 'You may use backup codes if you lost access to your device.',
'password' => 'The provided password is incorrect.', 'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'2fa_must_be_enabled' => 'The administrator has required that 2-Factor Authentication must be enabled for your account in order to use the Panel.', '2fa_must_be_enabled' => 'The administrator has required that 2-Factor Authentication must be enabled for your account in order to use the Panel.',

View File

@ -36,7 +36,6 @@ return [
'cpu_overallocate' => 'Enter the amount of cpu to over allocate by, -1 will disable checking and 0 will prevent creating new server', 'cpu_overallocate' => 'Enter the amount of cpu to over allocate by, -1 will disable checking and 0 will prevent creating new server',
'upload_size' => "'Enter the maximum filesize upload", 'upload_size' => "'Enter the maximum filesize upload",
'daemonListen' => 'Enter the daemon listening port', 'daemonListen' => 'Enter the daemon listening port',
'daemonConnect' => 'Enter the daemon connecting port (can be same as listen port)',
'daemonSFTP' => 'Enter the daemon SFTP listening port', 'daemonSFTP' => 'Enter the daemon SFTP listening port',
'daemonSFTPAlias' => 'Enter the daemon SFTP alias (can be empty)', 'daemonSFTPAlias' => 'Enter the daemon SFTP alias (can be empty)',
'daemonBase' => 'Enter the base folder', 'daemonBase' => 'Enter the base folder',

View File

@ -16,8 +16,7 @@ return [
'startup_update' => 'Allows a user to modify the startup variables for the server.', 'startup_update' => 'Allows a user to modify the startup variables for the server.',
'startup_docker_image' => 'Allows a user to modify the Docker image used when running the server.', 'startup_docker_image' => 'Allows a user to modify the Docker image used when running the server.',
'settings_reinstall' => 'Allows a user to trigger a reinstall of this server.', 'settings_reinstall' => 'Allows a user to trigger a reinstall of this server.',
'settings_rename' => 'Allows a user to rename this server.', 'settings_rename' => 'Allows a user to rename this server and change the description of it.',
'settings_description' => 'Allows a user to change the description of this server.',
'activity_read' => 'Allows a user to view the activity logs for the server.', 'activity_read' => 'Allows a user to view the activity logs for the server.',
'websocket_*' => 'Allows a user access to the websocket for this server.', 'websocket_*' => 'Allows a user access to the websocket for this server.',
'control_console' => 'Allows a user to send data to the server console.', 'control_console' => 'Allows a user to send data to the server console.',

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@
$userFontSize = auth()->user()->getCustomization()['console_font_size'] ?? 14; $userFontSize = auth()->user()->getCustomization()['console_font_size'] ?? 14;
$userRows = auth()->user()->getCustomization()['console_rows'] ?? 30; $userRows = auth()->user()->getCustomization()['console_rows'] ?? 30;
@endphp @endphp
@if($userFont !== "monospace") @if($userFont)
<link rel="preload" href="{{ asset("storage/fonts/{$userFont}.ttf") }}" as="font" crossorigin> <link rel="preload" href="{{ asset("storage/fonts/{$userFont}.ttf") }}" as="font" crossorigin>
<style> <style>
@font-face { @font-face {

View File

@ -0,0 +1,10 @@
<div class="fi-wi-stats-overview-stat relative rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="grid grid-flow-row">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">
{{ $getLabel() }}
</span>
<div class="text-xl font-semibold text-gray-950 dark:text-white">
{{ $getValue() }}
</div>
</div>
</div>

View File

@ -1,14 +1,9 @@
<div class="fi-small-stat-block grid grid-flow-row w-full p-3 rounded-lg bg-white shadow-sm overflow-hidden overflow-x-auto ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"> <div
@if ($shouldCopyOnClick()) class="grid grid-flow-row w-full p-3 rounded-lg bg-white shadow-sm overflow-hidden ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<span class="cursor-pointer" wire:click="copyClick('{{ $getValue() }}')">
@else
<span> <span>
@endif
<span class="text-md font-medium text-gray-500 dark:text-gray-400"> <span class="text-md font-medium text-gray-500 dark:text-gray-400">
{{ $getLabel() }} {{ $getLabel() }}
</span> </span>
<span class="text-md font-semibold"> <span class="text-md font-semibold">{{ $getValue() }}</span>
{{ $getValue() }}
</span>
</span> </span>
</div> </div>

View File

@ -176,5 +176,5 @@
x-text="monacoPlaceholderText"></div> x-text="monacoPlaceholderText"></div>
</div> </div>
</div> </div>
</div>
</x-dynamic-component> </x-dynamic-component>

View File

@ -1,8 +0,0 @@
@php
/** @var \App\Models\Server $server */
$server = $getRecord();
@endphp
<div class="w-full">
@livewire('server-entry', ['server' => $server, 'lazy' => true], key($server->id))
</div>

View File

@ -1,11 +1,25 @@
<div wire:poll.15s class="relative"> @php
<a href="{{ \App\Filament\Server\Pages\Console::getUrl(panel: 'server', tenant: $server) }}" wire:navigate> use App\Enums\ServerResourceType;
/** @var \App\Models\Server $server */
$server = $getRecord();
@endphp
<head>
<style>
hr {
border-color: #9ca3af;
}
</style>
</head>
<div class="w-full">
<div class="relative">
<div <div
class="absolute left-0 top-1 bottom-0 w-1 rounded-lg" class="absolute left-0 top-1 bottom-0 w-1 rounded-lg"
style="background-color: {{ $server->condition->getColor(true) }};"> style="background-color: {{ $server->condition->getColor(true) }};">
</div> </div>
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3"> <div class="flex-1 bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
<div class="flex items-center mb-5 gap-2"> <div class="flex items-center mb-5 gap-2">
<x-filament::icon-button <x-filament::icon-button
:icon="$server->condition->getIcon()" :icon="$server->condition->getIcon()"
@ -15,16 +29,16 @@
/> />
<h2 class="text-xl font-bold"> <h2 class="text-xl font-bold">
{{ $server->name }} {{ $server->name }}
<span class="dark:text-gray-400">({{ $server->formatResource('uptime', type: \App\Enums\ServerResourceType::Time) }})</span> <span class="dark:text-gray-400">({{ $server->formatResource('uptime', type: ServerResourceType::Time) }})</span>
</h2> </h2>
</div> </div>
<div class="flex justify-between text-center"> <div class="flex justify-between text-center">
<div> <div>
<p class="text-sm dark:text-gray-400">CPU</p> <p class="text-sm dark:text-gray-400">CPU</p>
<p class="text-md font-semibold">{{ $server->formatResource('cpu_absolute', type: \App\Enums\ServerResourceType::Percentage) }}</p> <p class="text-md font-semibold">{{ $server->formatResource('cpu_absolute', type: ServerResourceType::Percentage) }}</p>
<hr class="p-0.5"> <hr class="p-0.5">
<p class="text-xs dark:text-gray-400">{{ $server->formatResource('cpu', type: \App\Enums\ServerResourceType::Percentage, limit: true) }}</p> <p class="text-xs dark:text-gray-400">{{ $server->formatResource('cpu', type: ServerResourceType::Percentage, limit: true) }}</p>
</div> </div>
<div> <div>
<p class="text-sm dark:text-gray-400">Memory</p> <p class="text-sm dark:text-gray-400">Memory</p>
@ -45,11 +59,5 @@
</div> </div>
</div> </div>
</div> </div>
</a> </div>
<x-filament-tables::actions
:actions="\App\Filament\App\Resources\ServerResource\Pages\ListServers::getPowerActions()"
:alignment="\Filament\Support\Enums\Alignment::Center"
:record="$server"
/>
</div> </div>

View File

@ -129,7 +129,6 @@ Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, Authe
Route::prefix('/settings')->group(function () { Route::prefix('/settings')->group(function () {
Route::post('/rename', [Client\Servers\SettingsController::class, 'rename']); Route::post('/rename', [Client\Servers\SettingsController::class, 'rename']);
Route::post('/description', [Client\Servers\SettingsController::class, 'description']);
Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall']); Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall']);
Route::put('/docker-image', [Client\Servers\SettingsController::class, 'dockerImage']); Route::put('/docker-image', [Client\Servers\SettingsController::class, 'dockerImage']);
}); });

0
storage/app/packs/.githold Executable file
View File

View File

@ -21,9 +21,11 @@ class SettingsControllerTest extends ClientApiIntegrationTestCase
/** @var \App\Models\Server $server */ /** @var \App\Models\Server $server */
[$user, $server] = $this->generateTestAccount($permissions); [$user, $server] = $this->generateTestAccount($permissions);
$originalName = $server->name; $originalName = $server->name;
$originalDescription = $server->description;
$response = $this->actingAs($user)->postJson("/api/client/servers/$server->uuid/settings/rename", [ $response = $this->actingAs($user)->postJson("/api/client/servers/$server->uuid/settings/rename", [
'name' => '', 'name' => '',
'description' => '',
]); ]);
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
@ -31,15 +33,18 @@ class SettingsControllerTest extends ClientApiIntegrationTestCase
$server = $server->refresh(); $server = $server->refresh();
$this->assertSame($originalName, $server->name); $this->assertSame($originalName, $server->name);
$this->assertSame($originalDescription, $server->description);
$this->actingAs($user) $this->actingAs($user)
->postJson("/api/client/servers/$server->uuid/settings/rename", [ ->postJson("/api/client/servers/$server->uuid/settings/rename", [
'name' => 'Test Server Name', 'name' => 'Test Server Name',
'description' => 'This is a test server.',
]) ])
->assertStatus(Response::HTTP_NO_CONTENT); ->assertStatus(Response::HTTP_NO_CONTENT);
$server = $server->refresh(); $server = $server->refresh();
$this->assertSame('Test Server Name', $server->name); $this->assertSame('Test Server Name', $server->name);
$this->assertSame('This is a test server.', $server->description);
} }
/** /**

View File

@ -2,8 +2,10 @@
namespace App\Tests\Integration\Services\Backups; namespace App\Tests\Integration\Services\Backups;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use App\Models\Backup; use App\Models\Backup;
use GuzzleHttp\Exception\ClientException;
use App\Extensions\Backups\BackupManager; use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem; use App\Extensions\Filesystem\S3Filesystem;
use App\Services\Backups\DeleteBackupService; use App\Services\Backups\DeleteBackupService;
@ -52,7 +54,7 @@ class DeleteBackupServiceTest extends IntegrationTestCase
$backup = Backup::factory()->create(['server_id' => $server->id]); $backup = Backup::factory()->create(['server_id' => $server->id]);
$mock = $this->mock(DaemonBackupRepository::class); $mock = $this->mock(DaemonBackupRepository::class);
$mock->expects('setServer->delete')->with($backup)->andThrow(new ConnectionException(code: 404)); $mock->expects('setServer->delete')->with($backup)->andThrow(new ConnectionException(previous: new ClientException('', new Request('DELETE', '/'), new Response(404))));
$this->app->make(DeleteBackupService::class)->handle($backup); $this->app->make(DeleteBackupService::class)->handle($backup);
@ -67,7 +69,7 @@ class DeleteBackupServiceTest extends IntegrationTestCase
$backup = Backup::factory()->create(['server_id' => $server->id]); $backup = Backup::factory()->create(['server_id' => $server->id]);
$mock = $this->mock(DaemonBackupRepository::class); $mock = $this->mock(DaemonBackupRepository::class);
$mock->expects('setServer->delete')->with($backup)->andThrow(new ConnectionException(code: 500)); $mock->expects('setServer->delete')->with($backup)->andThrow(new ConnectionException(previous: new ClientException('', new Request('DELETE', '/'), new Response(500))));
$this->expectException(ConnectionException::class); $this->expectException(ConnectionException::class);

View File

@ -0,0 +1,43 @@
<?php
namespace App\Tests\Unit\Helpers;
use App\Tests\TestCase;
use App\Traits\EnvironmentWriterTrait;
use PHPUnit\Framework\Attributes\DataProvider;
class EnvironmentWriterTraitTest extends TestCase
{
#[DataProvider('variableDataProvider')]
public function test_variable_is_escaped_properly($input, $expected): void
{
$output = (new FooClass())->escapeEnvironmentValue($input);
$this->assertSame($expected, $output);
}
public static function variableDataProvider(): array
{
return [
['foo', 'foo'],
['abc123', 'abc123'],
['val"ue', '"val\"ue"'],
['val\'ue', '"val\'ue"'],
['my test value', '"my test value"'],
['mysql_p@assword', '"mysql_p@assword"'],
['mysql_p#assword', '"mysql_p#assword"'],
['mysql p@$$word', '"mysql p@$$word"'],
['mysql p%word', '"mysql p%word"'],
['mysql p#word', '"mysql p#word"'],
['abc_@#test', '"abc_@#test"'],
['test 123 $$$', '"test 123 $$$"'],
['#password%', '"#password%"'],
['$pass ', '"$pass "'],
];
}
}
class FooClass
{
use EnvironmentWriterTrait;
}