Compare commits

...

60 Commits

Author SHA1 Message Date
github-actions[bot]
a5a4e01c76 ci(release): bump version 2025-11-09 17:31:45 +00:00
Boy132
5c3b0919aa Fix allocations by admins aren't locked by default (#1879) 2025-11-09 18:29:46 +01:00
Charles
f4ee33fa4f Hide new allocation action if server has 0 allocations. (#1878) 2025-11-09 12:11:14 -05:00
Charles
d8368c4cec Do no use stock notifications on actions (#1877) 2025-11-09 12:08:25 -05:00
Charles
aa35d7d001 Fix creating mounts (#1876) 2025-11-09 11:14:44 -05:00
JoanFo
3c25b43b46 Repair webhooks once again (#1815)
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-11-09 09:35:00 -05:00
Charles
0891db5342 Reimplement Drag & Drop for file uploading 🎉 (#1858) 2025-11-09 09:24:12 -05:00
exefer
172436e012 Fix typo in failed upload message (#1874) 2025-11-09 12:58:56 +00:00
Charles
2b5403a4da Replace current panel log viewer with new and improved log viewer (#1834) 2025-11-08 19:31:51 -05:00
Charles
a30c45fbbe Add session key to use last used node, instead of latest created node (#1869)
Co-authored-by: Lance Pioch <git@lance.sh>
2025-11-08 17:09:41 -05:00
Copilot
b06df23823 Add bulk IP update action for node allocations (#1845)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: notAreYouScared <1757840+notAreYouScared@users.noreply.github.com>
Co-authored-by: Charles <charles@pelican.dev>
2025-11-08 16:53:12 -05:00
exefer
1ff965611e Fix typo in DNS help text (#1868)
Authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-11-08 22:40:23 +01:00
Boy132
cec141889a Allow admins to "lock" allocations (#1811) 2025-11-08 21:54:41 +01:00
Charles
6ed84b5584 Add wings diagnostics retrieving to Edit Node page (#1865)
Co-authored-by: Boy132 <mail@boy132.de>
2025-11-08 15:47:40 -05:00
Lance Pioch
49f24e37b6 Laravel 12.37.0 Shift (#1864)
Co-authored-by: Shift <shift@laravelshift.com>
2025-11-06 08:43:02 -05:00
Boy132
e0c4e47a6c Fix directAccessibleServers returning duplicates (#1862) 2025-11-05 16:19:03 +01:00
Boy132
4bda7cba75 Allow to "embed" server list (#1860) 2025-11-05 16:18:44 +01:00
Boy132
852f7beb39 Allow to register "special file" alert banners (#1861) 2025-11-04 12:48:18 +01:00
mristau
d61583cd7b add server description to grid view too (#1851) 2025-11-04 06:03:50 -05:00
Charles
21f9f259d0 Add Egg Images (#1849) 2025-11-03 12:32:11 -05:00
M41den
b2aff5445b Fix admin serverlist search (#1854) 2025-11-03 06:50:08 -05:00
Boy132
1f26750a2a Add api endpoint for updating username (#1826) 2025-11-03 08:31:07 +01:00
Charles
6d83c6d908 composer update (#1856) 2025-11-02 18:53:24 -05:00
Copilot
574a391e73 Add border-radius to activity log avatars (#1848)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: notAreYouScared <1757840+notAreYouScared@users.noreply.github.com>
2025-11-02 15:13:36 -05:00
PalmarHealer
605fcbe61a feat: Add mixed navigation type with admin-configurable defaults (#1850) 2025-10-31 14:12:54 -04:00
Letter N
0214b127e4 Add setup wizard to all oauth providers (#1801) 2025-10-31 14:09:20 -04:00
MartinOscar
e6aa76ef2c Refactor: add FilamentServiceProvider & globally make Select native(false) (#1836) 2025-10-29 23:23:18 +01:00
Boy132
d38075e3cb Add boolean cast to read_only toggle buttons (#1844) 2025-10-28 16:06:33 +01:00
M41den
0fec6adc3e Fix 500 "No route found" when creating db host (#1841) 2025-10-28 08:48:46 -04:00
M41den
5e3c22ea5e Fix weird postgres behavior when selecting mounts (#1842)
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-10-28 08:48:35 -04:00
MartinOscar
d1a808a746 Hide User reset password Action on create Operation (#1840) 2025-10-28 01:38:37 +01:00
MartinOscar
3bcdeea800 Leverage user() helper (#1832) 2025-10-26 16:24:34 +01:00
Charles
e6bd6e416f Add archive extension selection (#1828) 2025-10-24 12:39:30 -04:00
Boy132
8e006ac32d Fix user permissions service (#1819) 2025-10-22 16:00:51 +02:00
Boy132
430f28a847 Add "cancel" button to profile (#1821) 2025-10-22 16:00:31 +02:00
Charles
1a4fa5e67a Replace Xtermjs canvas with webgl (#1807)
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-10-14 20:35:26 -04:00
Charles
a65469b33b Remove duplicate translation entries (#1812) 2025-10-14 06:58:33 -04:00
Charles
d587cf3ee5 composer update (#1806) 2025-10-13 17:34:21 -04:00
Boy132
2cd9fa2cde Only keep the last 120 stored stats (#1805) 2025-10-13 22:50:16 +02:00
MartinOscar
d735e858a2 Rename Create actions in EditProfile (#1804) 2025-10-13 00:58:22 +02:00
MartinOscar
317fa46894 Use tenantMiddleware instead of manually fetching tenant query param (#1799) 2025-10-12 18:07:10 +02:00
Letter N
e589f972fb Add changelog preview when a new update is available (#1792)
Co-authored-by: Boy132 <mail@boy132.de>
2025-10-11 21:34:38 -04:00
MartinOscar
266e3779d5 Fix 500 when oauth is null (#1798) 2025-10-11 22:06:51 +02:00
MartinOscar
4652680a7b Add cpu helper on EditServer & move helperText to hintIcon on Create (#1795) 2025-10-10 22:46:47 +02:00
JoanFo
e99f7179c6 Topbar removed if using sidebar (#1789)
Co-authored-by: Boy132 <mail@boy132.de>
2025-10-10 16:37:14 -04:00
Charles
1f56b8e114 Language Update (#1784) 2025-10-08 16:00:47 -04:00
Charles
574e03a986 composer update (#1782) 2025-10-08 11:12:13 -04:00
Charles
05f3422dda Add Laravel/Filament Log Viewer (#1778)
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-10-08 06:18:20 -04:00
Charles
dbe4bdd62d General Edit User Improvements (#1779)
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: Boy132 <mail@boy132.de>
2025-10-08 05:04:52 -04:00
Boy132
f6710dbbe4 Improve time offset ux (#1772)
Co-authored-by: Lance Pioch <git@lance.sh>
2025-10-08 08:55:37 +02:00
Charles
e4f807b297 Change node config to use Code Entry (#1781) 2025-10-07 22:25:16 -04:00
Boy132
cd965678b7 Allow multiple startup commands per egg (#1656) 2025-10-07 23:42:28 +02:00
Boy132
a58ae874f3 Add own endpoint for exporting eggs (#1760) 2025-10-07 23:41:28 +02:00
Charles
432fb8a514 Filament v4.1.4 (#1780) 2025-10-07 17:40:26 -04:00
MartinOscar
bb02ec4c6c Add user() helper (#1768) 2025-10-07 17:12:31 -04:00
Charles
69b669e345 v4.1.2 + upgrade (#1775) 2025-10-06 06:20:18 -04:00
Boy132
80993f38a9 Add sudo to crontab command (#1773) 2025-10-03 00:03:22 +02:00
Boy132
19103b16b8 Allow both nodes for server requests when doing transfers (#1701) 2025-10-02 17:55:20 +02:00
Boy132
246997754e Remove "custom" email views (#1763) 2025-10-01 10:31:01 +02:00
Boy132
df75dbe2ad Fix mime type for jar files (#1757) 2025-10-01 10:30:49 +02:00
773 changed files with 17117 additions and 5711 deletions

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ yarn-error.log
/.idea
/.nova
/.vscode
/.ddev
public/assets/manifest.json
/database/*.sqlite*

View File

@@ -35,7 +35,7 @@ class DeleteUserCommand extends Command
if ($this->input->isInteractive()) {
$tableValues = [];
foreach ($results as $user) {
$tableValues[] = [$user->id, $user->email, $user->name];
$tableValues[] = [$user->id, $user->email, $user->username];
}
$this->table(['User ID', 'Email', 'Name'], $tableValues);

View File

@@ -18,7 +18,7 @@ enum CustomizationKey: string
self::ConsoleFont => 'monospace',
self::ConsoleFontSize => 14,
self::ConsoleGraphPeriod => 30,
self::TopNavigation => false,
self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'),
self::DashboardLayout => 'grid',
};
}

View File

@@ -54,7 +54,7 @@ class GSLTokenSchema implements FeatureSchemaInterface
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledSchema(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->disabledSchema(fn () => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server))
->schema([
TextEntry::make('info')
->label(new HtmlString(Blade::render('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'))),

View File

@@ -44,7 +44,7 @@ class JavaVersionSchema implements FeatureSchemaInterface
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledSchema(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->disabledSchema(fn () => !user()?->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->schema([
TextEntry::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),
@@ -56,8 +56,7 @@ class JavaVersionSchema implements FeatureSchemaInterface
->default(fn () => $server->image)
->notIn(fn () => $server->image)
->required()
->preload()
->native(false),
->preload(),
])
->action(function (array $data, DaemonServerRepository $serverRepository) use ($server) {
try {

View File

@@ -32,9 +32,9 @@ class PIDLimitSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->icon('tabler-alert-triangle')
->modalHeading(fn () => auth()->user()->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalHeading(fn () => user()?->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
user()?->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>

View File

@@ -29,7 +29,7 @@ class SteamDiskSpaceSchema implements FeatureSchemaInterface
->requiresConfirmation()
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
user()?->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.

View File

@@ -2,7 +2,9 @@
namespace App\Extensions\OAuth;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Laravel\Socialite\Contracts\User as OAuthUser;
use SocialiteProviders\Manager\SocialiteWasCalled;
class OAuthService
@@ -43,4 +45,27 @@ class OAuthService
$this->schemas[$schema->getId()] = $schema;
}
public function linkUser(User $user, OAuthSchemaInterface $schema, OAuthUser $oauthUser): User
{
$oauth = $user->oauth ?? [];
$oauth[$schema->getId()] = $oauthUser->getId();
$user->update(['oauth' => $oauth]);
return $user->refresh();
}
public function unlinkUser(User $user, OAuthSchemaInterface $schema): User
{
$oauth = $user->oauth ?? [];
if (!isset($oauth[$schema->getId()])) {
return $user;
}
unset($oauth[$schema->getId()]);
$user->update(['oauth' => $oauth]);
return $user->refresh();
}
}

View File

@@ -4,6 +4,10 @@ namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use SocialiteProviders\Authentik\Provider;
final class AuthentikSchema extends OAuthSchema
@@ -20,11 +24,27 @@ final class AuthentikSchema extends OAuthSchema
public function getServiceConfig(): array
{
return [
return array_merge(parent::getServiceConfig(), [
'base_url' => env('OAUTH_AUTHENTIK_BASE_URL'),
'client_id' => env('OAUTH_AUTHENTIK_CLIENT_ID'),
'client_secret' => env('OAUTH_AUTHENTIK_CLIENT_SECRET'),
];
]);
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Create Authentik Application')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>On your Authentik dashboard select <b>Applications</b>, then select <b>Create with Provider</b>.</p><p>On the creation step select <b>OAuth2/OpenID Provider</b> and on the configure step set <b>Redirect URIs/Origins</b> to the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Callback URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/authentik')),
]),
], parent::getSetupSteps());
}
public function getSettingsForm(): array

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class BitbucketSchema extends OAuthSchema
{
public function getId(): string
{
return 'bitbucket';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Bitbucket Consumer')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud" target="_blank">Bitbucket OAuth Documentation</x-filament::link> and follow the steps in <b>Create a consumer</b>.</p><p>For the <b>Callback URL</b> use the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Callback URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/bitbucket')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-bitbucket-f';
}
public function getHexColor(): string
{
return '#205081';
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class FacebookSchema extends OAuthSchema
{
public function getId(): string
{
return 'facebook';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Facebook Application')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://developers.facebook.com/apps" target="_blank">Facebook Developer Dashboard</x-filament::link> and select or create a new app you will use for authentication. Make sure to have "Authenticate and request data from users with Facebook Login" as one of the Use Cases.</p><p>Once selected go to <b>Use Cases</b> and customize "Authenticate and request data from users with Facebook Login", from there go to <b>Settings</b> and add <b>Valid OAuth Redirect URIs</b> using the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Valid OAuth Redirect URIs')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/facebook')),
TextEntry::make('get_app_info')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>To obtain the OAuth values go to <b>App Settings > Basic</b>.</p>'))),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-facebook-f';
}
public function getHexColor(): string
{
return '#1877f2';
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class GoogleSchema extends OAuthSchema
{
public function getId(): string
{
return 'google';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new OAuth client')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://console.developers.google.com/" target="_blank">Google API Console</x-filament::link> and create or select the project you want to use.</p><p>Navigate or search <b>Credentials</b>, click on the <b>Create Credentials</b> button and select <b>OAuth client ID</b>. On the Application type select <b>Web Application</b>.</p><p>On <b>Authorized JavaScript origins</b> and <b>Authorized redirect URIs</b> add and use the values below.</p>'))),
TextInput::make('_noenv_origin')
->label('Authorized JavaScript origins')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('')),
TextInput::make('_noenv_callback')
->label('Authorized redirect URIs')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/google')),
TextEntry::make('register_application')
->hiddenLabel()
->state(new HtmlString('<p>When you filled all fields click on <b>Create</b>.</p>')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-google-f';
}
public function getHexColor(): string
{
return '#4285f4';
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class LinkedinSchema extends OAuthSchema
{
public function getId(): string
{
return 'linkedin';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Obtain Linkedin App OAuth Config')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p><x-filament::link href="https://www.linkedin.com/developers/apps/new" target="_blank">Create</x-filament::link> or <x-filament::link href="https://www.linkedin.com/developers/apps" target="_blank">select</x-filament::link> the one you will be using for authentication.</p><p>Select the <b>Auth</b> tab and set <b>Authorized redirect URLs for your app</b> to the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Authorized redirect URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/linkedin')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-linkedin-f';
}
public function getHexColor(): string
{
return '#0a66c2';
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class SlackSchema extends OAuthSchema
{
public function getId(): string
{
return 'slack';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Slack OAuth')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p><x-filament::link href="https://api.slack.com/apps?new_app=1" target="_blank">Create</x-filament::link> a slack app or <x-filament::link href="https://api.slack.com/apps" target="_blank">select</x-filament::link> the one you will be using for authentication.</p><p>Navigate to the <b>OAuth & Permissions</b> section and configure the <b>Redirect URL</b> using the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Redirect URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/slack')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-slack';
}
public function getHexColor(): string
{
return '#6ecadc';
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class XSchema extends OAuthSchema
{
public function getId(): string
{
return 'x';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new X App')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://developer.x.com/en/portal/dashboard" target="_blank">X Developer Dashboard</x-filament::link> and create or select the project app you want to use.</p><p>Go to the app\'s settings and set up <b>User authentication</b> if not yet. Make sure to select <b>Web App</b> as the type of app.</p><p>For the <b>Callback URI / Redirect URL</b> and <b>Website URL</b> set it using the value below.</p>'))),
TextInput::make('_noenv_origin')
->label('Website URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('')),
TextInput::make('_noenv_callback')
->label('Callback URI / Redirect URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/x')),
TextEntry::make('register_application')
->hiddenLabel()
->state(new HtmlString('<p>If you have already set this up go to your app\'s <b>Keys and tokens</b> and obtain the Client ID and Secret there.</p>')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-x';
}
public function getHexColor(): string
{
return '#1da1f2';
}
}

View File

@@ -39,7 +39,7 @@ class Health extends Page
public static function canAccess(): bool
{
return auth()->user()->can('view health');
return user()?->can('view health');
}
protected function getActions(): array

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Filament\Admin\Pages;
use Boquizo\FilamentLogViewer\Actions\DeleteAction;
use Boquizo\FilamentLogViewer\Actions\DownloadAction;
use Boquizo\FilamentLogViewer\Actions\ViewLogAction;
use Boquizo\FilamentLogViewer\Pages\ListLogs as BaseListLogs;
use Boquizo\FilamentLogViewer\Tables\Columns\LevelColumn;
use Boquizo\FilamentLogViewer\Tables\Columns\NameColumn;
use Boquizo\FilamentLogViewer\Utils\Level;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Http;
class ListLogs extends BaseListLogs
{
protected string $view = 'filament.components.list-logs';
public function getHeading(): string|null|\Illuminate\Contracts\Support\Htmlable
{
return trans('admin/log.navigation.panel_logs');
}
public static function table(Table $table): Table
{
return parent::table($table)
->emptyStateHeading(trans('admin/log.empty_table'))
->emptyStateIcon('tabler-check')
->columns([
NameColumn::make('date'),
LevelColumn::make(Level::ALL)
->tooltip(trans('admin/log.total_logs')),
LevelColumn::make(Level::Error)
->tooltip(trans('admin/log.error')),
LevelColumn::make(Level::Warning)
->tooltip(trans('admin/log.warning')),
LevelColumn::make(Level::Notice)
->tooltip(trans('admin/log.notice')),
LevelColumn::make(Level::Info)
->tooltip(trans('admin/log.info')),
LevelColumn::make(Level::Debug)
->tooltip(trans('admin/log.debug')),
])
->recordActions([
ViewLogAction::make()
->icon('tabler-file-description')->iconSize(IconSize::Medium),
DownloadAction::make()
->icon('tabler-file-download')->iconSize(IconSize::Medium),
Action::make('uploadLogs')
->button()
->hiddenLabel()
->icon('tabler-world-upload')->iconSize(IconSize::Medium)
->requiresConfirmation()
->modalHeading(trans('admin/log.actions.upload_logs'))
->modalDescription(fn ($record) => trans('admin/log.actions.upload_logs_description', ['file' => $record['date'], 'url' => 'https://logs.pelican.dev']))
->action(function ($record) {
$logPath = storage_path('logs/' . $record['date']);
if (!file_exists($logPath)) {
Notification::make()
->title(trans('admin/log.actions.log_not_found'))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => $record['date']]))
->danger()
->send();
return;
}
$lines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totalLines = count($lines);
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
$logUrl = 'https://logs.pelican.dev';
try {
$response = Http::timeout(10)->asMultipart()->post($logUrl, [
[
'name' => 'c',
'contents' => $content,
],
[
'name' => 'e',
'contents' => '14d',
],
]);
if ($response->failed()) {
Notification::make()
->title(trans('admin/log.actions.failed_to_upload'))
->body(trans('admin/log.actions.failed_to_upload_description', ['status' => $response->status()]))
->danger()
->send();
return;
}
$data = $response->json();
$url = $data['url'];
Notification::make()
->title(trans('admin/log.actions.log_upload'))
->body("{$url}")
->success()
->actions([
Action::make('viewLogs')
->label(trans('admin/log.actions.view_logs'))
->url($url)
->openUrlInNewTab(true),
])
->persistent()
->send();
} catch (\Exception $e) {
Notification::make()
->title(trans('admin/log.actions.failed_to_upload'))
->body($e->getMessage())
->danger()
->send();
return;
}
}),
DeleteAction::make()
->icon('tabler-trash')->iconSize(IconSize::Medium),
]);
}
}

View File

@@ -82,7 +82,7 @@ class Settings extends Page implements HasSchemas
public static function canAccess(): bool
{
return auth()->user()->can('view settings');
return user()?->can('view settings');
}
public function getTitle(): string
@@ -106,7 +106,7 @@ class Settings extends Page implements HasSchemas
Tabs::make('Tabs')
->columns()
->persistTabInQueryString()
->disabled(fn () => !auth()->user()->can('update settings'))
->disabled(fn () => !user()?->can('update settings'))
->tabs([
Tab::make('general')
->label(trans('admin/setting.navigation.general'))
@@ -181,7 +181,6 @@ class Settings extends Page implements HasSchemas
->schema([
Select::make('FILAMENT_AVATAR_PROVIDER')
->label(trans('admin/setting.general.avatar_provider'))
->native(false)
->options($this->avatarService->getMappings())
->selectablePlaceholder(false)
->default(env('FILAMENT_AVATAR_PROVIDER', config('panel.filament.avatar-provider'))),
@@ -204,6 +203,15 @@ class Settings extends Page implements HasSchemas
])
->stateCast(new BooleanStateCast(false, true))
->default(env('PANEL_USE_BINARY_PREFIX', config('panel.use_binary_prefix'))),
ToggleButtons::make('FILAMENT_DEFAULT_NAVIGATION')
->label(trans('admin/setting.general.default_navigation'))
->inline()
->options([
'sidebar' => trans('admin/setting.general.sidebar'),
'topbar' => trans('admin/setting.general.topbar'),
'mixed' => trans('admin/setting.general.mixed'),
])
->default(env('FILAMENT_DEFAULT_NAVIGATION', config('panel.filament.default-navigation'))),
ToggleButtons::make('APP_2FA_REQUIRED')
->label(trans('admin/setting.general.2fa_requirement'))
->inline()
@@ -217,7 +225,6 @@ class Settings extends Page implements HasSchemas
->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))),
Select::make('FILAMENT_WIDTH')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(Width::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
@@ -233,12 +240,12 @@ class Settings extends Page implements HasSchemas
->color('danger')
->icon('tabler-trash')
->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings'))
->authorize(fn () => user()?->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
Action::make('cloudflare')
->label(trans('admin/setting.general.set_to_cf'))
->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->authorize(fn () => user()?->can('update settings'))
->action(function (Factory $client, Set $set) {
$ips = collect();
@@ -335,7 +342,7 @@ class Settings extends Page implements HasSchemas
->label(trans('admin/setting.mail.test_mail'))
->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings'))
->authorize(fn () => user()?->can('update settings'))
->action(function (Get $get) {
// Store original mail configuration
$originalConfig = [
@@ -368,8 +375,8 @@ class Settings extends Page implements HasSchemas
'services.mailgun.endpoint' => $get('MAILGUN_ENDPOINT'),
]);
MailNotification::route('mail', auth()->user()->email)
->notify(new MailTested(auth()->user()));
MailNotification::route('mail', user()?->email)
->notify(new MailTested(user()));
Notification::make()
->title(trans('admin/setting.mail.test_mail_sent'))
@@ -822,7 +829,7 @@ class Settings extends Page implements HasSchemas
return [
Action::make('save')
->action('save')
->authorize(fn () => auth()->user()->can('update settings'))
->authorize(fn () => user()?->can('update settings'))
->keyBindings(['mod+s']),
];

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Filament\Admin\Pages;
use App\Traits\ResolvesRecordDate;
use Boquizo\FilamentLogViewer\Actions\BackAction;
use Boquizo\FilamentLogViewer\Actions\DeleteAction;
use Boquizo\FilamentLogViewer\Actions\DownloadAction;
use Boquizo\FilamentLogViewer\Pages\ViewLog as BaseViewLog;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Support\Enums\IconSize;
use Illuminate\Support\Facades\Http;
class ViewLogs extends BaseViewLog
{
use ResolvesRecordDate;
public function getHeaderActions(): array
{
return [
DeleteAction::make(withTooltip: true)
->icon('tabler-trash')->iconSize(IconSize::Medium),
DownloadAction::make(withTooltip: true)
->icon('tabler-file-download')->iconSize(IconSize::Medium),
Action::make('uploadLogs')
->button()
->hiddenLabel()
->icon('tabler-world-upload')->iconSize(IconSize::Medium)
->requiresConfirmation()
->tooltip(trans('admin/log.actions.upload_tooltip', ['url' => 'logs.pelican.dev']))
->modalHeading(trans('admin/log.actions.upload_logs'))
->modalDescription(fn () => trans('admin/log.actions.upload_logs_description', ['file' => $this->resolveRecordDate(), 'url' => 'https://logs.pelican.dev']))
->action(function () {
$logPath = storage_path('logs/' . $this->resolveRecordDate());
if (!file_exists($logPath)) {
Notification::make()
->title(trans('admin/log.actions.log_not_found'))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => $this->resolveRecordDate()]))
->danger()
->send();
return;
}
$lines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totalLines = count($lines);
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
$logUrl = 'https://logs.pelican.dev';
try {
$response = Http::timeout(10)->asMultipart()->post($logUrl, [
[
'name' => 'c',
'contents' => $content,
],
[
'name' => 'e',
'contents' => '14d',
],
]);
if ($response->failed()) {
Notification::make()
->title(trans('admin/log.actions.failed_to_upload'))
->body(trans('admin/log.actions.failed_to_upload_description', ['status' => $response->status()]))
->danger()
->send();
return;
}
$data = $response->json();
$url = $data['url'];
Notification::make()
->title(trans('admin/log.actions.log_upload'))
->body("{$url}")
->success()
->actions([
Action::make('viewLogs')
->label(trans('admin/log.actions.view_logs'))
->url($url)
->openUrlInNewTab(true),
])
->persistent()
->send();
} catch (\Exception $e) {
Notification::make()
->title(trans('admin/log.actions.failed_to_upload'))
->body($e->getMessage())
->danger()
->send();
return;
}
}),
BackAction::make()
->icon('tabler-arrow-left')->iconSize(IconSize::Medium),
];
}
}

View File

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

View File

@@ -38,7 +38,7 @@ class CreateApiKey extends CreateRecord
{
$data['identifier'] = ApiKey::generateTokenIdentifier(ApiKey::TYPE_APPLICATION);
$data['token'] = Str::random(ApiKey::KEY_LENGTH);
$data['user_id'] = auth()->user()->id;
$data['user_id'] = user()?->id;
$data['key_type'] = ApiKey::TYPE_APPLICATION;
$permissions = [];

View File

@@ -166,7 +166,7 @@ class DatabaseHostResource extends Resource
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'))),
]),
]);
}
@@ -196,7 +196,7 @@ class DatabaseHostResource extends Resource
return $query->where(function (Builder $query) {
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
$query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
});
}

View File

@@ -157,7 +157,7 @@ class CreateDatabaseHost extends CreateRecord
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'))),
]),
];
}

View File

@@ -32,7 +32,7 @@ class EggResource extends Resource
public static function getNavigationGroup(): ?string
{
return auth()->user()->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationLabel(): string

View File

@@ -83,14 +83,16 @@ class CreateEgg extends CreateRecord
->rows(2)
->columnSpanFull()
->helperText(trans('admin/egg.description_help')),
Textarea::make('startup')
->label(trans('admin/egg.startup'))
->rows(3)
KeyValue::make('startup_commands')
->label(trans('admin/egg.startup_commands'))
->live()
->columnSpanFull()
->required()
->placeholder(implode("\n", [
'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}',
]))
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->keyPlaceholder('Default')
->valueLabel(trans('admin/egg.startup_command'))
->valuePlaceholder('java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}')
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
@@ -255,7 +257,6 @@ class CreateEgg extends CreateRecord
->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false)
->default('bash')
->options([

View File

@@ -16,6 +16,7 @@ use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -24,13 +25,19 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Enums\IconSize;
use Illuminate\Validation\Rules\Unique;
class EditEgg extends EditRecord
@@ -50,64 +57,242 @@ class EditEgg extends EditRecord
Tabs::make()->tabs([
Tab::make('configuration')
->label(trans('admin/egg.tabs.configuration'))
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->columns(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 6])
->icon('tabler-egg')
->schema([
Grid::make(2)
->columnSpan(1)
->schema([
Image::make('', '')
->hidden(fn ($record) => !$record->image)
->url(fn ($record) => $record->image)
->alt('')
->alignJustify()
->imageSize(150)
->columnSpanFull(),
Flex::make([
Action::make('uploadImage')
->iconButton()
->iconSize(IconSize::Large)
->icon('tabler-photo-up')
->modal()
->modalHeading('')
->modalSubmitActionLabel(trans('admin/egg.import.import_image'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('base64Image'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
return;
}
try {
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$allowedExtensions = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
];
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, $allowedExtensions)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', $allowedExtensions)]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new \Exception(trans('admin/egg.import.no_local_ip'));
}
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$imageContent = @file_get_contents($state, false, $context, 0, 1048576); // 1024KB
if (!$imageContent) {
throw new \Exception(trans('admin/egg.import.image_error'));
}
if (strlen($imageContent) >= 1048576) {
throw new \Exception(trans('admin/egg.import.image_too_large'));
}
$mimeType = $allowedExtensions[$extension];
$base64 = 'data:' . $mimeType . ';base64,' . base64_encode($imageContent);
$set('base64Image', $base64);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('base64Image', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn ($get) => $get('image_url_error') !== null)
->afterStateHydrated(fn ($set, $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn ($get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(1024)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->saveUploadedFileUsing(function ($file, Set $set) {
$base64 = "data:{$file->getMimeType()};base64,". base64_encode(file_get_contents($file->getRealPath()));
$set('base64Image', $base64);
return $base64;
}),
]),
]),
])
->action(function (array $data, $record): void {
$base64 = $data['base64Image'] ?? null;
if (empty($base64) && !empty($data['image'])) {
$base64 = $data['image'];
}
if (!empty($base64)) {
$record->update([
'image' => $base64,
]);
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
$record->refresh();
} else {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
Action::make('deleteImage')
->visible(fn ($record) => $record->image)
->label('')
->icon('tabler-trash')
->iconButton()
->iconSize(IconSize::Large)
->color('danger')
->action(function ($record) {
$record->update([
'image' => null,
]);
Notification::make()
->title(trans('admin/egg.import.image_deleted'))
->success()
->send();
$record->refresh();
}),
]),
]),
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 3, 'lg' => 2])
->helperText(trans('admin/egg.name_help')),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(3)
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 3])
->helperText(trans('admin/egg.description_help')),
TextInput::make('id')
->label(trans('admin/egg.egg_id'))
->columnSpan(1)
->disabled(),
TextInput::make('uuid')
->label(trans('admin/egg.egg_uuid'))
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText(trans('admin/egg.uuid_help')),
TextInput::make('id')
->label(trans('admin/egg.egg_id'))
->disabled(),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(3)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.description_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->required()
->maxLength(255)
->email()
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText(trans('admin/egg.author_help_edit')),
Textarea::make('startup')
->label(trans('admin/egg.startup'))
->rows(3)
Toggle::make('force_outgoing_ip')
->inline(false)
->label(trans('admin/egg.force_ip'))
->columnSpan(1)
->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')),
KeyValue::make('startup_commands')
->label(trans('admin/egg.startup_commands'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->valueLabel(trans('admin/egg.startup_command'))
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]),
Toggle::make('force_outgoing_ip')
->inline(false)
->label(trans('admin/egg.force_ip'))
->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')),
Hidden::make('script_is_privileged')
->helperText('The docker images available to servers using this egg.'),
TagsInput::make('tags')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->url()
->hintIcon('tabler-question-mark', trans('admin/egg.update_url_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
Hidden::make('script_is_privileged')
->helperText('The docker images available to servers using this egg.'),
TagsInput::make('tags')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
@@ -245,7 +430,6 @@ class EditEgg extends EditRecord
->placeholder('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false)
->options([
'bash' => 'bash',

View File

@@ -19,6 +19,7 @@ use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
@@ -42,6 +43,13 @@ class ListEggs extends ListRecords
TextColumn::make('id')
->label('Id')
->hidden(),
ImageColumn::make('image')
->label('')
->alignCenter()
->circular()
->getStateUsing(fn ($record) => $record->image
? $record->image
: 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')))),
TextColumn::make('name')
->label(trans('admin/egg.name'))
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
@@ -68,7 +76,7 @@ class ListEggs extends ListRecords
->modal(false)
->excludeAttributes(['author', 'uuid', 'update_url', 'servers_count', 'created_at', 'updated_at'])
->beforeReplicaSaved(function (Egg $replica) {
$replica->author = auth()->user()->email;
$replica->author = user()?->email;
$replica->name .= ' Copy';
$replica->uuid = Str::uuid()->toString();
})

View File

@@ -24,6 +24,7 @@ use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -125,6 +126,7 @@ class MountResource extends Resource
ToggleButtons::make('read_only')
->label(trans('admin/mount.read_only'))
->helperText(trans('admin/mount.read_only_help'))
->stateCast(new BooleanStateCast(false, true))
->options([
false => trans('admin/mount.toggles.writable'),
true => trans('admin/mount.toggles.read_only'),
@@ -138,8 +140,7 @@ class MountResource extends Resource
true => 'success',
])
->inline()
->default(false)
->required(),
->default(false),
TextInput::make('source')
->label(trans('admin/mount.source'))
->required()
@@ -162,11 +163,12 @@ class MountResource extends Resource
Section::make()->schema([
Select::make('eggs')->multiple()
->label(trans('admin/mount.eggs'))
->relationship('eggs', 'name')
// Selecting only non-json fields to prevent Postgres from choking on DISTINCT JSON columns
->relationship('eggs', 'name', fn (Builder $query) => $query->select(['eggs.id', 'eggs.name']))
->preload(),
Select::make('nodes')->multiple()
->label(trans('admin/mount.nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id')))
->searchable(['name', 'fqdn'])
->preload(),
]),
@@ -197,7 +199,7 @@ class MountResource extends Resource
return $query->where(function (Builder $query) {
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
$query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
});
}

View File

@@ -44,7 +44,7 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string
{
return auth()->user()->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
@@ -75,6 +75,6 @@ class NodeResource extends Resource
{
$query = parent::getEloquentQuery();
return $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'));
return $query->whereIn('id', user()?->accessibleNodes()->pluck('id'));
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Admin\Resources\Nodes\Pages;
use App\Filament\Admin\Resources\Nodes\NodeResource;
use App\Models\Node;
use App\Repositories\Daemon\DaemonConfigurationRepository;
use App\Repositories\Daemon\DaemonSystemRepository;
use App\Services\Helpers\SoftwareVersionService;
use App\Services\Nodes\NodeAutoDeployService;
use App\Services\Nodes\NodeUpdateService;
@@ -14,16 +14,20 @@ use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Slider;
use Filament\Forms\Components\Slider\Enums\PipsMode;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Infolists\Components\CodeEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
@@ -32,8 +36,12 @@ use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\IconSize;
use Filament\Support\RawJs;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
use Phiki\Grammar\Grammar;
use Throwable;
class EditNode extends EditRecord
@@ -43,13 +51,13 @@ class EditNode extends EditRecord
protected static string $resource = NodeResource::class;
private DaemonConfigurationRepository $daemonConfigurationRepository;
private DaemonSystemRepository $daemonSystemRepository;
private NodeUpdateService $nodeUpdateService;
public function boot(DaemonConfigurationRepository $daemonConfigurationRepository, NodeUpdateService $nodeUpdateService): void
public function boot(DaemonSystemRepository $daemonSystemRepository, NodeUpdateService $nodeUpdateService): void
{
$this->daemonConfigurationRepository = $daemonConfigurationRepository;
$this->daemonSystemRepository = $daemonSystemRepository;
$this->nodeUpdateService = $nodeUpdateService;
}
@@ -547,11 +555,12 @@ class EditNode extends EditRecord
->label(trans('admin/node.instructions'))
->columnSpanFull()
->state(new HtmlString(trans('admin/node.instructions_help'))),
Textarea::make('config')
CodeEntry::make('config')
->label('/etc/pelican/config.yml')
->grammar(Grammar::Yaml)
->state(fn (Node $node) => $node->getYamlConfiguration())
->copyable()
->disabled()
->rows(19)
->hintCopy()
->columnSpanFull(),
Grid::make()
->columns()
@@ -621,6 +630,154 @@ class EditNode extends EditRecord
])->fullWidth(),
]),
]),
Tab::make('diagnostics')
->label(trans('admin/node.tabs.diagnostics'))
->icon('tabler-heart-search')
->schema([
Section::make('diag')
->heading(trans('admin/node.tabs.diagnostics'))
->columnSpanFull()
->columns(4)
->disabled(fn (Get $get) => $get('pulled'))
->headerActions([
Action::make('pull')
->label(trans('admin/node.diagnostics.pull'))
->icon('tabler-cloud-download')->iconButton()->iconSize(IconSize::ExtraLarge)
->hidden(fn (Get $get) => $get('pulled'))
->action(function (Get $get, Set $set, Node $node) {
$includeEndpoints = $get('include_endpoints') ?? true;
$includeLogs = $get('include_logs') ?? true;
$logLines = $get('log_lines') ?? 200;
try {
$response = $this->daemonSystemRepository->setNode($node)->getDiagnostics($logLines, $includeEndpoints, $includeLogs);
if ($response->status() === 404) {
Notification::make()
->title(trans('admin/node.diagnostics.404'))
->warning()
->send();
return;
}
$set('pulled', true);
$set('uploaded', false);
$set('log', $response->body());
Notification::make()
->title(trans('admin/node.diagnostics.logs_pulled'))
->success()
->send();
} catch (ConnectionException $e) {
Notification::make()
->title(trans('admin/node.error_connecting', ['node' => $node->name]))
->body($e->getMessage())
->danger()
->send();
}
}),
Action::make('upload')
->label(trans('admin/node.diagnostics.upload'))
->visible(fn (Get $get) => $get('pulled') ?? false)
->icon('tabler-cloud-upload')->iconButton()->iconSize(IconSize::ExtraLarge)
->action(function (Get $get, Set $set) {
try {
$response = Http::asMultipart()->post('https://logs.pelican.dev', [
[
'name' => 'c',
'contents' => $get('log'),
],
[
'name' => 'e',
'contents' => '14d',
],
]);
if ($response->failed()) {
Notification::make()
->title(trans('admin/node.diagnostics.upload_failed'))
->body(fn () => $response->status() . ' - ' . $response->body())
->danger()
->send();
return;
}
$data = $response->json();
$url = $data['url'];
Notification::make()
->title(trans('admin/node.diagnostics.logs_uploaded'))
->body("{$url}")
->success()
->actions([
Action::make('viewLogs')
->label(trans('admin/node.diagnostics.view_logs'))
->url($url)
->openUrlInNewTab(true),
])
->persistent()
->send();
$set('log', $url);
$set('pulled', false);
$set('uploaded', true);
} catch (\Exception $e) {
Notification::make()
->title(trans('admin/node.diagnostics.upload_failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
Action::make('clear')
->label(trans('admin/node.diagnostics.clear'))
->visible(fn (Get $get) => $get('pulled') ?? false)
->icon('tabler-trash')->iconButton()->iconSize(IconSize::ExtraLarge)->color('danger')
->action(function (Get $get, Set $set) {
$set('pulled', false);
$set('uploaded', false);
$set('log', null);
$this->refresh();
}
),
])
->schema([
ToggleButtons::make('include_endpoints')
->hintIcon('tabler-question-mark')->inline()
->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint'))
->formatStateUsing(fn () => 1)
->boolean(),
ToggleButtons::make('include_logs')
->live()
->hintIcon('tabler-question-mark')->inline()
->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint'))
->formatStateUsing(fn () => 1)
->boolean(),
Slider::make('log_lines')
->columnSpan(2)
->hiddenLabel()
->live()
->tooltips(RawJs::make(<<<'JS'
`${$value} lines`
JS))
->visible(fn (Get $get) => $get('include_logs'))
->range(minValue: 100, maxValue: 500)
->pips(PipsMode::Steps, density: 10)
->step(50)
->formatStateUsing(fn () => 200)
->fillTrack(),
Hidden::make('pulled'),
Hidden::make('uploaded'),
]),
Textarea::make('log')
->hiddenLabel()
->columnSpanFull()
->rows(35)
->visible(fn (Get $get) => ($get('pulled') ?? false) || ($get('uploaded') ?? false)),
]),
]),
]);
}
@@ -629,8 +786,6 @@ class EditNode extends EditRecord
{
$node = Node::findOrFail($data['id']);
$data['config'] = $node->getYamlConfiguration();
if (!is_ip($node->fqdn)) {
$ip = get_ip_from_hostname($node->fqdn);
if ($ip) {
@@ -680,7 +835,7 @@ class EditNode extends EditRecord
try {
if ($changed) {
$this->daemonConfigurationRepository->setNode($node)->update($node);
$this->daemonSystemRepository->setNode($node)->update($node);
}
parent::getSavedNotification()?->send();
} catch (ConnectionException) {

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Admin\Resources\Nodes\RelationManagers;
use App\Filament\Admin\Resources\Servers\Pages\CreateServer;
use App\Filament\Components\Actions\UpdateNodeAllocations;
use App\Models\Allocation;
use App\Models\Node;
use App\Services\Allocations\AssignmentService;
@@ -80,7 +81,9 @@ class AllocationsRelationManager extends RelationManager
->searchable()
->label(trans('admin/node.table.ip')),
])
->headerActions([
->toolbarActions([
DeleteBulkAction::make()
->authorize(fn () => user()?->can('update', $this->getOwnerRecord())),
Action::make('create new allocation')
->label(trans('admin/node.create_allocation'))
->schema(fn () => [
@@ -118,10 +121,9 @@ class AllocationsRelationManager extends RelationManager
->required(),
])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord(), $data)),
])
->groupedBulkActions([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('update', $this->getOwnerRecord())),
UpdateNodeAllocations::make()
->nodeRecord($this->getOwnerRecord())
->authorize(fn () => user()?->can('update', $this->getOwnerRecord())),
]);
}
}

View File

@@ -32,7 +32,7 @@ class NodeCpuChart extends ChartWidget
$this->cpuHistory = session("{$sessionKey}.cpu_history", []);
$this->cpuHistory[] = [
'cpu' => round($data['cpu_percent'] * $this->threads, 2),
'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
'timestamp' => now(user()->timezone ?? 'UTC')->format('H:i:s'),
];
$this->cpuHistory = array_slice($this->cpuHistory, -60);
@@ -50,7 +50,7 @@ class NodeCpuChart extends ChartWidget
],
],
'labels' => array_column($this->cpuHistory, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
'locale' => user()->language ?? 'en',
];
}

View File

@@ -34,7 +34,7 @@ class NodeMemoryChart extends ChartWidget
'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'),
'timestamp' => now(user()->timezone ?? 'UTC')->format('H:i:s'),
];
$this->memoryHistory = array_slice($this->memoryHistory, -60);
@@ -52,7 +52,7 @@ class NodeMemoryChart extends ChartWidget
],
],
'labels' => array_column($this->memoryHistory, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
'locale' => user()->language ?? 'en',
];
}

View File

@@ -60,7 +60,7 @@ class NodeStorageChart extends ChartWidget
],
],
'labels' => [trans('admin/node.used'), trans('admin/node.unused')],
'locale' => auth()->user()->language ?? 'en',
'locale' => user()->language ?? 'en',
];
}

View File

@@ -65,7 +65,7 @@ class RoleResource extends Resource
public static function getNavigationGroup(): ?string
{
return auth()->user()->getCustomization(CustomizationKey::TopNavigation) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string

View File

@@ -116,8 +116,16 @@ class CreateServer extends CreateRecord
->prefixIcon('tabler-server-2')
->selectablePlaceholder(false)
->default(function () {
$lastUsedNode = session()->get('last_utilized_node');
if ($lastUsedNode && user()?->accessibleNodes()->where('id', $lastUsedNode)->exists()) {
$this->node = Node::find($lastUsedNode);
return $this->node?->id;
}
/** @var ?Node $latestNode */
$latestNode = auth()->user()->accessibleNodes()->latest()->first();
$latestNode = user()?->accessibleNodes()->latest()->first();
$this->node = $latestNode;
return $this->node?->id;
@@ -128,7 +136,7 @@ class CreateServer extends CreateRecord
'md' => 2,
])
->live()
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', user()?->accessibleNodes()->pluck('id')))
->searchable()
->required()
->preload()
@@ -141,7 +149,7 @@ class CreateServer extends CreateRecord
->preload()
->prefixIcon('tabler-user')
->selectablePlaceholder(false)
->default(auth()->user()->id)
->default(user()?->id)
->label(trans('admin/server.owner'))
->columnSpan([
'default' => 1,
@@ -151,7 +159,7 @@ class CreateServer extends CreateRecord
->relationship('user', 'username')
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)")
->createOptionAction(fn (Action $action) => $action->authorize(fn () => auth()->user()->can('create', User::class)))
->createOptionAction(fn (Action $action) => $action->authorize(fn () => user()?->can('create', User::class)))
->createOptionForm([
TextInput::make('username')
->label(trans('admin/user.username'))
@@ -212,7 +220,7 @@ class CreateServer extends CreateRecord
->where('node_id', $get('node_id'))
->whereNull('server_id'),
)
->createOptionAction(fn (Action $action) => $action->authorize(fn (Get $get) => auth()->user()->can('create', Node::find($get('node_id')))))
->createOptionAction(fn (Action $action) => $action->authorize(fn (Get $get) => user()?->can('create', Node::find($get('node_id')))))
->createOptionForm(function (Get $get) {
$getPage = $get;
@@ -328,7 +336,7 @@ class CreateServer extends CreateRecord
->live()
->afterStateUpdated(function ($state, Set $set, Get $get, $old) {
$egg = Egg::query()->find($state);
$set('startup', $egg->startup ?? '');
$set('startup', '');
$set('image', '');
$variables = $egg->variables ?? [];
@@ -402,24 +410,45 @@ class CreateServer extends CreateRecord
])
->inline(),
Textarea::make('startup')
->hintIcon('tabler-code')
Select::make('select_startup')
->label(trans('admin/server.startup_cmd'))
->hidden(fn (Get $get) => $get('egg_id') === null)
->live()
->afterStateUpdated(fn (Set $set, $state) => $set('startup', $state))
->options(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$startups = $egg->startup_commands ?? [];
$currentStartup = $get('startup');
if (!$currentStartup && $startups) {
$currentStartup = collect($startups)->first();
$set('startup', $currentStartup);
$set('select_startup', $currentStartup);
}
return array_flip($startups) + ['' => 'Custom Startup'];
})
->selectablePlaceholder(false)
->columnSpanFull(),
Textarea::make('startup')
->hiddenLabel()
->hidden(fn (Get $get) => $get('egg_id') === null)
->required()
->live()
->rows(function ($state) {
return str($state)->explode("\n")->reduce(
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
1
);
->autosize()
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$startups = $egg->startup_commands ?? [];
if (in_array($state, $startups)) {
$set('select_startup', $state);
} else {
$set('select_startup', '');
}
})
->columnSpan([
'default' => 1,
'sm' => 4,
'md' => 4,
'lg' => 6,
]),
->placeholder(trans('admin/server.startup_placeholder'))
->columnSpanFull(),
Hidden::make('environment')->default([]),
@@ -497,12 +526,12 @@ class CreateServer extends CreateRecord
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/server.cpu_limit'))->inlineLabel()
->suffix('%')
->hintIcon('tabler-question-mark', trans('admin/server.cpu_helper'))
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0)
->helperText(trans('admin/server.cpu_helper')),
->minValue(0),
]),
Grid::make()
->columns(4)
@@ -808,6 +837,8 @@ class CreateServer extends CreateRecord
$data['allocation_additional'] = collect($allocation_additional)->filter()->all();
}
session()->put('last_utilized_node', $data['node_id']);
try {
return $this->serverCreationService->handle($data);
} catch (Exception $exception) {

View File

@@ -210,7 +210,7 @@ class EditServer extends EditRecord
->maxLength(255),
Select::make('node_id')
->label(trans('admin/server.node'))
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', user()?->accessibleNodes()->pluck('id')))
->columnSpan([
'default' => 2,
'sm' => 1,
@@ -258,6 +258,7 @@ class EditServer extends EditRecord
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/server.cpu_limit'))->inlineLabel()
->suffix('%')
->hintIcon('tabler-question-mark', trans('admin/server.cpu_helper'))
->required()
->columnSpan(2)
->numeric()
@@ -609,26 +610,51 @@ class EditServer extends EditRecord
1 => 'tabler-code-off',
])
->required(),
Hidden::make('previewing')
->default(false),
Textarea::make('startup')
Select::make('select_startup')
->label(trans('admin/server.startup_cmd'))
->required()
->columnSpan(6)
->autosize()
->live()
->afterStateUpdated(function (Set $set, $state) {
$set('startup', $state);
$set('previewing', false);
})
->options(function ($state, Get $get, Set $set) {
$egg = Egg::find($get('egg_id'));
$startups = $egg->startup_commands ?? [];
$currentStartup = $get('startup');
if (!$currentStartup && $startups) {
$currentStartup = collect($startups)->first();
$set('startup', $currentStartup);
$set('select_startup', $currentStartup);
}
return array_flip($startups) + ['' => 'Custom Startup'];
})
->selectablePlaceholder(false)
->columnSpanFull()
->hintAction(PreviewStartupAction::make('preview')),
Textarea::make('defaultStartup')
->hintCopy()
->label(trans('admin/server.default_startup'))
->disabled()
Textarea::make('startup')
->hiddenLabel()
->required()
->live()
->autosize()
->columnSpan(6)
->formatStateUsing(function ($state, Get $get) {
$egg = Egg::query()->find($get('egg_id'));
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::find($get('egg_id'));
$startups = $egg->startup_commands ?? [];
return $egg->startup;
}),
if (in_array($state, $startups)) {
$set('select_startup', $state);
} else {
$set('select_startup', '');
}
})
->placeholder(trans('admin/server.startup_placeholder'))
->columnSpanFull(),
Repeater::make('server_variables')
->hiddenLabel()
@@ -916,7 +942,7 @@ class EditServer extends EditRecord
}
})
->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
->authorize(fn (Server $server) => user()?->can('delete server', $server)),
Action::make('ForceDelete')
->color('danger')
->label(trans('filament-actions::force-delete.single.label'))
@@ -933,7 +959,7 @@ class EditServer extends EditRecord
}
})
->visible(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
->authorize(fn (Server $server) => user()?->can('delete server', $server)),
Action::make('console')
->label(trans('admin/server.console'))
->icon('tabler-terminal')

View File

@@ -16,6 +16,7 @@ use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Grouping\Group;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ListServers extends ListRecords
{
@@ -47,7 +48,9 @@ class ListServers extends ListRecords
->searchable(),
TextColumn::make('name')
->label(trans('admin/server.name'))
->searchable()
->searchable(query: fn (Builder $query, string $search) => $query->where(
Server::query()->qualifyColumn('name'), 'like', "%{$search}%")
)
->sortable(),
TextColumn::make('node.name')
->label(trans('admin/server.node'))
@@ -69,7 +72,7 @@ class ListServers extends ListRecords
->searchable(),
SelectColumn::make('allocation_id')
->label(trans('admin/server.primary_allocation'))
->hidden(fn () => !auth()->user()->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->hidden(fn () => !user()?->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->disabled(fn (Server $server) => $server->allocations->count() <= 1)
->options(fn (Server $server) => $server->allocations->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->selectablePlaceholder(fn (Server $server) => $server->allocations->count() <= 1)
@@ -77,7 +80,7 @@ class ListServers extends ListRecords
->sortable(),
TextColumn::make('allocation_id_readonly')
->label(trans('admin/server.primary_allocation'))
->hidden(fn () => auth()->user()->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->hidden(fn () => user()?->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->state(fn (Server $server) => $server->allocation->address ?? trans('admin/server.none')),
TextColumn::make('image')->hidden(),
TextColumn::make('backups_count')
@@ -90,7 +93,7 @@ class ListServers extends ListRecords
Action::make('View')
->label(trans('admin/server.view'))
->url(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->authorize(fn (Server $server) => auth()->user()->canAccessTenant($server)),
->authorize(fn (Server $server) => user()?->canAccessTenant($server)),
EditAction::make(),
])
->emptyStateIcon('tabler-brand-docker')

View File

@@ -11,6 +11,7 @@ use Filament\Actions\AssociateAction;
use Filament\Actions\CreateAction;
use Filament\Actions\DissociateAction;
use Filament\Actions\DissociateBulkAction;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
@@ -60,16 +61,35 @@ class AllocationsRelationManager extends RelationManager
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id)
->label(trans('admin/server.primary')),
IconColumn::make('is_locked')
->label(trans('admin/server.locked'))
->tooltip(trans('admin/server.locked_helper'))
->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'),
])
->recordActions([
Action::make('make-primary')
->label(trans('admin/server.make_primary'))
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
Action::make('lock')
->label(trans('admin/server.lock'))
->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => true]) && $this->deselectAllTableRecords())
->hidden(fn (Allocation $allocation) => $allocation->is_locked),
Action::make('unlock')
->label(trans('admin/server.unlock'))
->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => false]) && $this->deselectAllTableRecords())
->visible(fn (Allocation $allocation) => $allocation->is_locked),
DissociateAction::make()
->after(function (Allocation $allocation) {
$allocation->update(['notes' => null]);
$this->getOwnerRecord()->allocation_id && $this->getOwnerRecord()->update(['allocation_id' => $this->getOwnerRecord()->allocations()->first()?->id]);
$allocation->update([
'notes' => null,
'is_locked' => false,
]);
if (!$this->getOwnerRecord()->allocation_id) {
$this->getOwnerRecord()->update(['allocation_id' => $this->getOwnerRecord()->allocations()->first()?->id]);
}
}),
])
->headerActions([
@@ -107,6 +127,8 @@ class AllocationsRelationManager extends RelationManager
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip'))))
->splitKeys(['Tab', ' ', ','])
->required(),
Hidden::make('is_locked')
->default(true),
])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())),
AssociateAction::make()
@@ -116,13 +138,25 @@ class AllocationsRelationManager extends RelationManager
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)->whereNull('server_id'))
->recordSelectSearchColumns(['ip', 'port'])
->label(trans('admin/server.add_allocation'))
->after(fn (array $data) => !$this->getOwnerRecord()->allocation_id && $this->getOwnerRecord()->update(['allocation_id' => $data['recordId'][0]])),
->after(function (array $data) {
Allocation::whereIn('id', array_values(array_unique($data['recordId'])))->update(['is_locked' => true]);
if (!$this->getOwnerRecord()->allocation_id) {
$this->getOwnerRecord()->update(['allocation_id' => $data['recordId'][0]]);
}
}),
])
->groupedBulkActions([
DissociateBulkAction::make()
->after(function () {
Allocation::whereNull('server_id')->update(['notes' => null]);
$this->getOwnerRecord()->allocation_id && $this->getOwnerRecord()->update(['allocation_id' => $this->getOwnerRecord()->allocations()->first()?->id]);
Allocation::whereNull('server_id')->update([
'notes' => null,
'is_locked' => false,
]);
if (!$this->getOwnerRecord()->allocation_id) {
$this->getOwnerRecord()->update(['allocation_id' => $this->getOwnerRecord()->allocations()->first()?->id]);
}
}),
]);
}

View File

@@ -81,6 +81,7 @@ class DatabasesRelationManager extends RelationManager
ViewAction::make()
->color('primary'),
DeleteAction::make()
->successNotificationTitle(null)
->using(function (Database $database, DatabaseManagementService $service) {
try {
$service->delete($database);

View File

@@ -47,7 +47,7 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string
{
return auth()->user()->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
@@ -103,6 +103,6 @@ class ServerResource extends Resource
{
$query = parent::getEloquentQuery();
return $query->whereIn('node_id', auth()->user()->accessibleNodes()->pluck('id'));
return $query->whereIn('node_id', user()?->accessibleNodes()->pluck('id'));
}
}

View File

@@ -32,8 +32,8 @@ class EditUser extends EditRecord
{
return [
DeleteAction::make()
->label(fn (User $user) => auth()->user()->id === $user->id ? trans('admin/user.self_delete') : ($user->servers()->count() > 0 ? trans('admin/user.has_servers') : trans('filament-actions::delete.single.modal.actions.delete.label')))
->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
->label(fn (User $user) => user()?->id === $user->id ? trans('admin/user.self_delete') : ($user->servers()->count() > 0 ? trans('admin/user.has_servers') : trans('filament-actions::delete.single.modal.actions.delete.label')))
->disabled(fn (User $user) => user()?->id === $user->id || $user->servers()->count() > 0),
$this->getSaveFormAction()->formId('form'),
];
}
@@ -48,8 +48,7 @@ class EditUser extends EditRecord
if (!$record instanceof User) {
return $record;
}
unset($data['roles']);
unset($data['roles'], $data['avatar']);
return $this->service->handle($record, $data);
}

View File

@@ -67,7 +67,7 @@ class ServersRelationManager extends RelationManager
->sortable(),
SelectColumn::make('allocation_id')
->label(trans('admin/server.primary_allocation'))
->hidden(fn () => !auth()->user()->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->hidden(fn () => !user()?->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->disabled(fn (Server $server) => $server->allocations->count() <= 1)
->options(fn (Server $server) => $server->allocations->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->selectablePlaceholder(fn (Server $server) => $server->allocations->count() <= 1)
@@ -75,7 +75,7 @@ class ServersRelationManager extends RelationManager
->sortable(),
TextColumn::make('allocation_id_readonly')
->label(trans('admin/server.primary_allocation'))
->hidden(fn () => auth()->user()->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->hidden(fn () => user()?->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->state(fn (Server $server) => $server->allocation->address ?? trans('admin/server.none')),
TextColumn::make('databases_count')
->counts('databases')

View File

@@ -3,33 +3,56 @@
namespace App\Filament\Admin\Resources\Users;
use App\Enums\CustomizationKey;
use App\Extensions\OAuth\OAuthService;
use App\Facades\Activity;
use App\Filament\Admin\Resources\Users\Pages\CreateUser;
use App\Filament\Admin\Resources\Users\Pages\EditUser;
use App\Filament\Admin\Resources\Users\Pages\ListUsers;
use App\Filament\Admin\Resources\Users\Pages\ViewUser;
use App\Filament\Admin\Resources\Users\RelationManagers\ServersRelationManager;
use App\Models\ActivityLog;
use App\Models\ApiKey;
use App\Models\Role;
use App\Models\User;
use App\Models\UserSSHKey;
use App\Services\Helpers\LanguageService;
use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use DateTimeZone;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Auth\Notifications\ResetPassword;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Support\Colors\Color;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Events\PasswordResetLinkSent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\HtmlString;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class UserResource extends Resource
{
@@ -61,7 +84,7 @@ class UserResource extends Resource
public static function getNavigationGroup(): ?string
{
return auth()->user()->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.user');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@@ -110,7 +133,7 @@ class UserResource extends Resource
->hidden(fn ($record) => static::canEdit($record)),
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count)
->checkIfRecordIsSelectableUsing(fn (User $user) => user()?->id !== $user->id && !$user->servers_count)
->groupedBulkActions([
DeleteBulkAction::make(),
]);
@@ -119,44 +142,324 @@ class UserResource extends Resource
public static function defaultForm(Schema $schema): Schema
{
return $schema
->columns(['default' => 1, 'lg' => 3])
->columns(['default' => 1, 'lg' => 3, 'md' => 2])
->components([
TextInput::make('username')
->label(trans('admin/user.username'))
->required()
->unique()
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->email()
->required()
->unique()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null)
->password(),
CheckboxList::make('roles')
->hidden(fn (?User $user) => $user && $user->isRootAdmin())
->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id))
->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role))))
->dehydrated()
->label(trans('admin/user.admin_roles'))
->columnSpanFull()
->bulkToggleable(false),
CheckboxList::make('root_admin_role')
->visible(fn (?User $user) => $user && $user->isRootAdmin())
->disabled()
->options([
'root_admin' => Role::ROOT_ADMIN,
])
->descriptions([
'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]),
])
->formatStateUsing(fn () => ['root_admin'])
->dehydrated(false)
->label(trans('admin/user.admin_roles'))
->columnSpanFull(),
Tabs::make()
->schema([
Tab::make('account')
->label(trans('profile.tabs.account'))
->icon('tabler-user-cog')
->columns([
'default' => 1,
'md' => 3,
'lg' => 3,
])
->schema([
TextInput::make('username')
->label(trans('admin/user.username'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->unique()
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->email()
->required()
->unique()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null)
->password()
->hintAction(
Action::make('password_reset')
->label(trans('admin/user.password_reset'))
->hidden(fn (string $operation) => $operation === 'create' || config('mail.default', 'log') === 'log')
->icon('tabler-send')
->action(function (User $user) {
$status = Password::broker(Filament::getPanel('app')->getAuthPasswordBroker())->sendResetLink([
'email' => $user->email,
],
function (User $user, string $token) {
$notification = new ResetPassword($token);
$notification->url = Filament::getPanel('app')->getResetPasswordUrl($token, $user);
$user->notify($notification);
event(new PasswordResetLinkSent($user));
},
);
if ($status === Password::RESET_LINK_SENT) {
Notification::make()
->title(trans('admin/user.password_reset_sent'))
->success()
->send();
} else {
Notification::make()
->title(trans('admin/user.password_reset_failed'))
->body($status)
->danger()
->send();
}
})),
TextInput::make('external_id')
->label(trans('admin/user.external_id'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
]),
Select::make('timezone')
->label(trans('profile.timezone'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->prefixIcon('tabler-clock-pin')
->default(fn () => config('app.timezone', 'UTC'))
->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(),
Select::make('language')
->label(trans('profile.language'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->prefixIcon('tabler-flag')
->live()
->default('en')
->searchable()
->selectablePlaceholder(false)
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()),
FileUpload::make('avatar')
->visible(fn (?User $user, FileUpload $fileUpload) => $user ? $fileUpload->getDisk()->exists($fileUpload->getDirectory() . '/' . $user->id . '.png') : false)
->avatar()
->directory('avatars')
->disk('public')
->formatStateUsing(function (FileUpload $fileUpload, ?User $user) {
if (!$user) {
return null;
}
$path = $fileUpload->getDirectory() . '/' . $user->id . '.png';
if ($fileUpload->getDisk()->exists($path)) {
return $path;
}
})
->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) {
if ($file instanceof TemporaryUploadedFile) {
return $file->delete();
}
if ($fileUpload->getDisk()->exists($file)) {
return $fileUpload->getDisk()->delete($file);
}
}),
Section::make(trans('profile.tabs.oauth'))
->visible(fn (?User $user) => $user)
->collapsible()
->columnSpanFull()
->schema(function (OAuthService $oauthService, ?User $user) {
if (!$user) {
return;
}
$actions = [];
foreach ($user->oauth ?? [] as $schema => $_) {
$schema = $oauthService->get($schema);
if (!$schema) {
return;
}
$id = $schema->getId();
$name = $schema->getName();
$actions[] = Action::make("oauth_$id")
->label(trans('profile.unlink', ['name' => $name]))
->icon('tabler-unlink')
->requiresConfirmation()
->color(Color::hex($schema->getHexColor()))
->action(function ($livewire) use ($oauthService, $user, $name, $schema) {
$oauthService->unlinkUser($user, $schema);
$livewire->form->fill($user->attributesToArray());
Notification::make()
->title(trans('profile.unlinked', ['name' => $name]))
->success()
->send();
});
}
if (!$actions) {
return [
TextEntry::make('no_oauth')
->state(trans('profile.no_oauth'))
->hiddenLabel(),
];
}
return [Actions::make($actions)];
}),
]),
Tab::make('roles')
->label(trans('admin/user.roles'))
->icon('tabler-users-group')
->components([
CheckboxList::make('roles')
->hidden(fn (?User $user) => $user && $user->isRootAdmin())
->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id))
->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role))))
->dehydrated()
->label(trans('admin/user.admin_roles'))
->columnSpanFull()
->bulkToggleable(false),
CheckboxList::make('root_admin_role')
->visible(fn (?User $user) => $user && $user->isRootAdmin())
->disabled()
->options([
'root_admin' => Role::ROOT_ADMIN,
])
->descriptions([
'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]),
])
->formatStateUsing(fn () => ['root_admin'])
->dehydrated(false)
->label(trans('admin/user.admin_roles'))
->columnSpanFull(),
]),
Tab::make('keys')
->visible(fn (?User $user) => $user)
->label(trans('profile.tabs.keys'))
->icon('tabler-key')
->schema([
Section::make(trans('profile.api_keys'))
->columnSpan(2)
->schema([
Repeater::make('api_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('apiKeys')
->addable(false)
->itemLabel(fn ($state) => $state['identifier'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, ?User $user) {
$items = $component->getState();
$key = $items[$arguments['item']] ?? null;
if ($key) {
$apiKey = ApiKey::find($key['id']);
if ($apiKey?->exists()) {
$apiKey->delete();
Activity::event('user:api-key.delete')
->actor(user())
->subject($user)
->subject($apiKey)
->property('identifier', $apiKey->identifier)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
}
});
})
->schema([
TextEntry::make('memo')
->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo),
])
->visible(fn (User $user) => $user->apiKeys()->exists()),
TextEntry::make('no_api_keys')
->state(trans('profile.no_api_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->apiKeys()->exists()),
]),
Section::make(trans('profile.ssh_keys'))->columnSpan(2)
->schema([
Repeater::make('ssh_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('sshKeys')
->addable(false)
->itemLabel(fn ($state) => $state['name'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$sshKey = UserSSHKey::find($key['id'] ?? null);
if ($sshKey->exists()) {
$sshKey->delete();
Activity::event('user:ssh-key.delete')
->actor(user())
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
});
})
->schema(fn () => [
TextEntry::make('fingerprint')
->hiddenLabel()
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"),
])
->visible(fn (User $user) => $user->sshKeys()->exists()),
TextEntry::make('no_ssh_keys')
->state(trans('profile.no_ssh_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->sshKeys()->exists()),
]),
]),
Tab::make('activity')
->visible(fn (?User $user) => $user)
->disabledOn('create')
->label(trans('profile.tabs.activity'))
->icon('tabler-history')
->schema([
Repeater::make('activity')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
])->columnSpanFull(),
]);
}

View File

@@ -43,7 +43,18 @@ class UpdateWidget extends FormWidget
->iconColor('warning')
->schema([
TextEntry::make('info')
->hiddenLabel()
->state(trans('admin/dashboard.sections.intro-update-available.content', ['latestVersion' => $this->softwareVersionService->latestPanelVersion()])),
Section::make(trans('admin/dashboard.sections.intro-update-available.button_changelog'))
->icon('tabler-script')
->collapsible()
->collapsed()
->schema([
TextEntry::make('changelog')
->hiddenLabel()
->state($this->softwareVersionService->latestPanelVersionChangelog())
->markdown(),
]),
])
->headerActions([
Action::make('update')

View File

@@ -102,9 +102,9 @@ class ListServers extends ListRecords
public function table(Table $table): Table
{
$baseQuery = auth()->user()->accessibleServers();
$baseQuery = user()?->accessibleServers();
$usingGrid = auth()->user()->getCustomization(CustomizationKey::DashboardLayout) === 'grid';
$usingGrid = user()?->getCustomization(CustomizationKey::DashboardLayout) === 'grid';
return $table
->paginated(false)
@@ -139,9 +139,9 @@ class ListServers extends ListRecords
public function getTabs(): array
{
$all = auth()->user()->accessibleServers();
$my = (clone $all)->where('owner_id', auth()->user()->id);
$other = (clone $all)->whereNot('owner_id', auth()->user()->id);
$all = user()?->accessibleServers();
$my = (clone $all)->where('owner_id', user()?->id);
$other = (clone $all)->whereNot('owner_id', user()?->id);
return [
'my' => Tab::make('my')
@@ -232,21 +232,21 @@ class ListServers extends ListRecords
->label(trans('server/console.power_actions.start'))
->color('primary')
->icon('tabler-player-play-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_START, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isStartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'start']),
Action::make('restart')
->label(trans('server/console.power_actions.restart'))
->color('gray')
->icon('tabler-reload')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_RESTART, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isRestartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'restart']),
Action::make('stop')
->label(trans('server/console.power_actions.stop'))
->color('danger')
->icon('tabler-player-stop-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isStoppable() && !$server->retrieveStatus()->isKillable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'stop']),
Action::make('kill')
@@ -254,7 +254,7 @@ class ListServers extends ListRecords
->color('danger')
->icon('tabler-alert-square')
->tooltip(trans('server/console.power_actions.kill_tooltip'))
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isKillable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'kill']),
])

View File

@@ -10,10 +10,17 @@ class ServerResource extends Resource
{
protected static ?string $model = Server::class;
protected static string|\BackedEnum|null $navigationIcon = 'tabler-brand-docker';
protected static ?string $slug = '/';
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationBadge(): ?string
{
return (string) user()?->directAccessibleServers()->where('owner_id', user()?->id)->count();
}
public static function canAccess(): bool
{
return true;
@@ -25,4 +32,10 @@ class ServerResource extends Resource
'index' => ListServers::route('/'),
];
}
public static function embedServerList(bool $condition = true): void
{
static::$slug = $condition ? null : '/';
static::$shouldRegisterNavigation = $condition;
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Filament\Components\Actions;
use App\Enums\EggFormat;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Actions\Action;
use Filament\Infolists\Components\TextEntry;
use Filament\Support\Enums\Alignment;
@@ -24,7 +23,7 @@ class ExportEggAction extends Action
$this->tableIcon('tabler-download');
$this->authorize(fn () => auth()->user()->can('export egg'));
$this->authorize(fn () => user()?->can('export egg'));
$this->modalHeading(fn (Egg $egg) => trans('filament-actions::export.modal.actions.export.label') . ' ' . $egg->name);
@@ -38,17 +37,15 @@ class ExportEggAction extends Action
$this->modalFooterActionsAlignment(Alignment::Center);
$this->modalFooterActions([ //TODO: Close modal after clicking ->close() does not allow action to preform before closing modal
$this->modalFooterActions([
Action::make('json')
->label(trans('admin/egg.export.as', ['format' => 'json']))
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id, EggFormat::JSON);
}, 'egg-' . $egg->getKebabName() . '.json')),
->url(fn (Egg $egg) => route('api.application.eggs.eggs.export', ['egg' => $egg, 'format' => EggFormat::JSON->value]), true)
->close(),
Action::make('yaml')
->label(trans('admin/egg.export.as', ['format' => 'yaml']))
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id, EggFormat::YAML);
}, 'egg-' . $egg->getKebabName() . '.yaml')),
->url(fn (Egg $egg) => route('api.application.eggs.eggs.export', ['egg' => $egg, 'format' => EggFormat::YAML->value]), true)
->close(),
]);
}
}

View File

@@ -25,10 +25,12 @@ class ExportScheduleAction extends Action
$this->label(trans('filament-actions::export.modal.actions.export.label'));
$this->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_READ, $server));
$this->authorize(fn () => user()?->can(Permission::ACTION_SCHEDULE_READ, $server));
$this->action(fn (ScheduleExporterService $service, Schedule $schedule) => response()->streamDownload(function () use ($service, $schedule) {
echo $service->handle($schedule);
}, 'schedule-' . str($schedule->name)->kebab()->lower()->trim() . '.json'));
}, 'schedule-' . str($schedule->name)->kebab()->lower()->trim() . '.json', [
'Content-Type' => 'application/json',
]));
}
}

View File

@@ -35,7 +35,7 @@ class ImportEggAction extends Action
$this->label(trans('filament-actions::import.modal.actions.import.label'));
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->authorize(fn () => user()?->can('import egg'));
$this->action(function (array $data, EggImporterService $eggImportService): void {
$eggs = array_merge(collect($data['urls'])->flatten()->whereNotNull()->unique()->all(), Arr::wrap($data['files']));

View File

@@ -33,7 +33,7 @@ class ImportScheduleAction extends Action
$this->label(trans('filament-actions::import.modal.actions.import.label'));
$this->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_CREATE, $server));
$this->authorize(fn () => user()?->can(Permission::ACTION_SCHEDULE_CREATE, $server));
$this->schema([
Tabs::make('Tabs')

View File

@@ -15,19 +15,17 @@ class PreviewStartupAction extends Action
return 'preview';
}
public function getLabel(): string
{
return trans('server/startup.preview');
}
protected function setUp(): void
{
parent::setUp();
$this->label(fn (Get $get) => $get('previewing') ? trans('server/startup.disable_preview') : trans('server/startup.enable_preview'));
$this->action(function (Get $get, Set $set, Server $server) {
$active = $get('previewing');
$set('previewing', !$active);
$set('startup', $active ? $server->startup : fn (Server $server, StartupCommandService $service) => $service->handle($server));
$previewing = !$get('previewing');
$set('previewing', $previewing);
$set('startup', !$previewing ? $server->startup : fn (Server $server, StartupCommandService $service) => $service->handle($server, $server->startup));
});
}
}

View File

@@ -26,7 +26,7 @@ class RotateDatabasePasswordAction extends Action
$this->icon('tabler-refresh');
$this->authorize(fn (Database $database) => auth()->user()->can('update', $database));
$this->authorize(fn (Database $database) => user()?->can('update', $database));
$this->modalHeading(trans('admin/databasehost.rotate_password'));
@@ -56,7 +56,7 @@ class RotateDatabasePasswordAction extends Action
} catch (Exception $exception) {
Notification::make()
->title(trans('admin/databasehost.rotate_error'))
->body(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin')) ? $exception->getMessage() : null)
->body(fn () => user()?->canAccessPanel(Filament::getPanel('admin')) ? $exception->getMessage() : null)
->danger()
->send();

View File

@@ -59,7 +59,7 @@ class UpdateEggAction extends Action
->send();
});
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->authorize(fn () => user()?->can('import egg'));
$this->visible(fn (Egg $egg) => cache()->get("eggs.$egg->uuid.update", false));
}

View File

@@ -73,7 +73,7 @@ class UpdateEggBulkAction extends BulkAction
->send();
});
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->authorize(fn () => user()?->can('import egg'));
$this->deselectRecordsAfterCompletion();
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Filament\Components\Actions;
use App\Models\Allocation;
use App\Models\Node;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
class UpdateNodeAllocations extends Action
{
public static function getDefaultName(): ?string
{
return 'bulk_update_ip';
}
protected function setUp(): void
{
parent::setUp();
$this->label(trans('admin/node.bulk_update_ip'));
$this->icon('tabler-replace');
$this->color('warning');
$this->requiresConfirmation();
$this->modalHeading(trans('admin/node.bulk_update_ip'));
$this->modalDescription(trans('admin/node.bulk_update_ip_description'));
$this->modalIconColor('warning');
$this->modalSubmitActionLabel(trans('admin/node.update_ip'));
$this->schema(function () {
/** @var Node $node */
$node = $this->record;
$currentIps = Allocation::where('node_id', $node->id)
->pluck('ip')
->unique()
->values()
->all();
return [
Select::make('old_ip')
->label(trans('admin/node.old_ip'))
->options(array_combine($currentIps, $currentIps))
->selectablePlaceholder(false)
->required()
->live(),
Select::make('new_ip')
->label(trans('admin/node.new_ip'))
->options(fn () => array_combine($node->ipAddresses(), $node->ipAddresses()) ?: [])
->required()
->different('old_ip'),
];
});
$this->action(function (array $data) {
/** @var Node $node */
$node = $this->record;
$allocations = Allocation::where('node_id', $node->id)->where('ip', $data['old_ip'])->get();
if ($allocations->count() === 0) {
Notification::make()
->title(trans('admin/node.no_allocations_to_update'))
->warning()
->send();
return;
}
$updated = 0;
$failed = 0;
foreach ($allocations as $allocation) {
try {
$allocation->update(['ip' => $data['new_ip']]);
$updated++;
} catch (Exception $exception) {
$failed++;
report($exception);
}
}
Notification::make()
->title(trans('admin/node.ip_updated', ['count' => $updated, 'total' => $allocations->count()]))
->body($failed > 0 ? trans('admin/node.ip_update_failed', ['count' => $failed]) : null)
->status($failed > 0 ? 'warning' : 'success')
->persistent()
->send();
});
}
public function nodeRecord(Node $node): static
{
$this->record = $node;
return $this;
}
}

View File

@@ -21,8 +21,6 @@ class CopyFrom extends Select
$this->searchable();
$this->native(false);
$this->live();
}

View File

@@ -24,6 +24,6 @@ class DateTimeColumn extends TextColumn
public function getTimezone(): string
{
return auth()->user()->timezone ?? config('app.timezone', 'UTC');
return user()->timezone ?? config('app.timezone', 'UTC');
}
}

View File

@@ -34,7 +34,6 @@ use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
@@ -48,6 +47,7 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password;
use Laravel\Socialite\Facades\Socialite;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
/**
* @method User getUser()
@@ -128,11 +128,10 @@ class EditProfile extends BaseEditProfile
->label(trans('profile.timezone'))
->required()
->prefixIcon('tabler-clock-pin')
->default('UTC')
->default(config('app.timezone', 'UTC'))
->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable()
->native(false),
->searchable(),
Select::make('language')
->label(trans('profile.language'))
->required()
@@ -142,8 +141,7 @@ class EditProfile extends BaseEditProfile
->selectablePlaceholder(false)
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? ''
: trans('profile.language_help', ['state' => $state]) . ' <u><a href="https://crowdin.com/project/pelican-dev/">Update On Crowdin</a></u>'))
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
->native(false),
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()),
FileUpload::make('avatar')
->visible(fn () => config('panel.filament.uploadable-avatars'))
->avatar()
@@ -151,14 +149,20 @@ class EditProfile extends BaseEditProfile
->directory('avatars')
->disk('public')
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
->hintAction(function (FileUpload $fileUpload) {
->formatStateUsing(function (FileUpload $fileUpload) {
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png';
if ($fileUpload->getDisk()->exists($path)) {
return $path;
}
})
->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) {
if ($file instanceof TemporaryUploadedFile) {
return $file->delete();
}
return Action::make('remove_avatar')
->icon('tabler-photo-minus')
->iconButton()
->hidden(fn () => !$fileUpload->getDisk()->exists($path))
->action(fn () => $fileUpload->getDisk()->delete($path));
if ($fileUpload->getDisk()->exists($file)) {
return $fileUpload->getDisk()->delete($file);
}
}),
]),
Tab::make('oauth')
@@ -181,10 +185,10 @@ class EditProfile extends BaseEditProfile
->color(Color::hex($schema->getHexColor()))
->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) {
if ($unlink) {
$oauth = auth()->user()->oauth;
$oauth = user()?->oauth;
unset($oauth[$id]);
$updateService->handle(auth()->user(), ['oauth' => $oauth]);
$updateService->handle(user(), ['oauth' => $oauth]);
$this->fillForm();
@@ -229,7 +233,7 @@ class EditProfile extends BaseEditProfile
->columnSpanFull(),
])
->headerActions([
Action::make('create')
Action::make('create_api_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('description')))
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
@@ -292,7 +296,13 @@ class EditProfile extends BaseEditProfile
TextEntry::make('memo')
->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo),
]),
])
->visible(fn (User $user) => $user->apiKeys()->exists()),
TextEntry::make('no_api_keys')
->state(trans('profile.no_api_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->apiKeys()->exists()),
]),
]),
]),
@@ -312,7 +322,7 @@ class EditProfile extends BaseEditProfile
->live(),
])
->headerActions([
Action::make('create')
Action::make('create_ssh_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
@@ -381,7 +391,13 @@ class EditProfile extends BaseEditProfile
TextEntry::make('fingerprint')
->hiddenLabel()
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"),
]),
])
->visible(fn (User $user) => $user->sshKeys()->exists()),
TextEntry::make('no_ssh_keys')
->state(trans('profile.no_ssh_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->sshKeys()->exists()),
]),
]),
]),
@@ -423,10 +439,10 @@ class EditProfile extends BaseEditProfile
->label(trans('profile.navigation'))
->inline()
->options([
1 => trans('profile.top'),
0 => trans('profile.side'),
])
->stateCast(new BooleanStateCast(false, true)),
'sidebar' => trans('profile.sidebar'),
'topbar' => trans('profile.topbar'),
'mixed' => trans('profile.mixed'),
]),
]),
Section::make(trans('profile.console'))
->collapsible()
@@ -536,6 +552,7 @@ class EditProfile extends BaseEditProfile
{
return [
$this->getSaveFormAction()->formId('form'),
$this->getCancelFormAction()->formId('form'),
];
}
@@ -565,7 +582,14 @@ class EditProfile extends BaseEditProfile
$data['console_rows'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleRows);
$data['console_graph_period'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$data['dashboard_layout'] = $this->getUser()->getCustomization(CustomizationKey::DashboardLayout);
$data['top_navigation'] = (bool) $this->getUser()->getCustomization(CustomizationKey::TopNavigation);
// Handle migration from boolean to string navigation types
$topNavigation = $this->getUser()->getCustomization(CustomizationKey::TopNavigation);
if (is_bool($topNavigation)) {
$data['top_navigation'] = $topNavigation ? 'topbar' : 'sidebar';
} else {
$data['top_navigation'] = $topNavigation;
}
return $data;
}

View File

@@ -87,7 +87,7 @@ class Console extends Page
{
return [
'server' => Filament::getTenant(),
'user' => auth()->user(),
'user' => user(),
];
}
@@ -164,7 +164,7 @@ class Console extends Page
->label(trans('server/console.power_actions.start'))
->color('primary')
->icon('tabler-player-play-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_START, $server))
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isStartable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'start'))
->size(Size::ExtraLarge),
@@ -172,7 +172,7 @@ class Console extends Page
->label(trans('server/console.power_actions.restart'))
->color('gray')
->icon('tabler-reload')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_RESTART, $server))
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isRestartable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'restart'))
->size(Size::ExtraLarge),
@@ -180,7 +180,7 @@ class Console extends Page
->label(trans('server/console.power_actions.stop'))
->color('danger')
->icon('tabler-player-stop-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => !$this->status->isKillable())
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isStoppable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'stop'))
@@ -191,7 +191,7 @@ class Console extends Page
->icon('tabler-alert-square')
->tooltip(trans('server/console.power_actions.kill_tooltip'))
->requiresConfirmation()
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $this->status->isKillable())
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isKillable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'kill'))

View File

@@ -51,7 +51,7 @@ class Settings extends ServerFormPage
->schema([
TextInput::make('name')
->label(trans('server/setting.server_info.name'))
->disabled(fn (Server $server) => !auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_RENAME, $server))
->required()
->columnSpan([
'default' => 1,
@@ -64,7 +64,7 @@ class Settings extends ServerFormPage
Textarea::make('description')
->label(trans('server/setting.server_info.description'))
->hidden(!config('panel.editable_server_descriptions'))
->disabled(fn (Server $server) => !auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_RENAME, $server))
->columnSpan([
'default' => 1,
'sm' => 2,
@@ -153,7 +153,7 @@ class Settings extends ServerFormPage
]),
Fieldset::make(trans('server/setting.server_info.sftp.title'))
->columnSpanFull()
->hidden(fn (Server $server) => !auth()->user()->can(Permission::ACTION_FILE_SFTP, $server))
->hidden(fn (Server $server) => !user()?->can(Permission::ACTION_FILE_SFTP, $server))
->columns([
'default' => 1,
'sm' => 1,
@@ -174,20 +174,20 @@ class Settings extends ServerFormPage
->url(function (Server $server) {
$fqdn = $server->node->daemon_sftp_alias ?? $server->node->fqdn;
return 'sftp://' . rawurlencode(auth()->user()->username) . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
return 'sftp://' . rawurlencode(user()?->username) . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
}),
)
->formatStateUsing(function (Server $server) {
$fqdn = $server->node->daemon_sftp_alias ?? $server->node->fqdn;
return 'sftp://' . rawurlencode(auth()->user()->username) . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
return 'sftp://' . rawurlencode(user()?->username) . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
}),
TextInput::make('username')
->label(trans('server/setting.server_info.sftp.username'))
->columnSpan(1)
->copyable()
->disabled()
->formatStateUsing(fn (Server $server) => auth()->user()->username . '.' . $server->uuid_short),
->formatStateUsing(fn (Server $server) => user()?->username . '.' . $server->uuid_short),
TextEntry::make('password')
->label(trans('server/setting.server_info.sftp.password'))
->columnSpan(1)
@@ -195,19 +195,19 @@ class Settings extends ServerFormPage
]),
]),
Section::make(trans('server/setting.reinstall.title'))
->hidden(fn (Server $server) => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->hidden(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->columnSpanFull()
->footerActions([
Action::make('reinstall')
->label(trans('server/setting.reinstall.action'))
->color('danger')
->disabled(fn (Server $server) => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->requiresConfirmation()
->modalHeading(trans('server/setting.reinstall.modal'))
->modalDescription(trans('server/setting.reinstall.modal_description'))
->modalSubmitActionLabel(trans('server/setting.reinstall.yes'))
->action(function (Server $server, ReinstallServerService $reinstallService) {
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server), 403);
abort_unless(user()?->can(Permission::ACTION_SETTINGS_REINSTALL, $server), 403);
try {
$reinstallService->handle($server);
@@ -246,7 +246,7 @@ class Settings extends ServerFormPage
public function updateName(string $name, Server $server): void
{
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server), 403);
abort_unless(user()?->can(Permission::ACTION_SETTINGS_RENAME, $server), 403);
$original = $server->name;
@@ -277,7 +277,7 @@ class Settings extends ServerFormPage
public function updateDescription(string $description, Server $server): void
{
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server) && config('panel.editable_server_descriptions'), 403);
abort_unless(user()?->can(Permission::ACTION_SETTINGS_RENAME, $server) && config('panel.editable_server_descriptions'), 403);
$original = $server->description;

View File

@@ -17,6 +17,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
@@ -35,40 +36,56 @@ class Startup extends ServerFormPage
return parent::form($schema)
->columns([
'default' => 1,
'sm' => 1,
'md' => 4,
'lg' => 6,
'md' => 2,
])
->components([
Hidden::make('previewing')
->default(false),
Textarea::make('startup')
TextInput::make('custom_startup')
->label(trans('server/startup.command'))
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 4,
])
->autosize()
->hintAction(PreviewStartupAction::make())
->readOnly(),
->readOnly()
->visible(fn (Server $server) => !in_array($server->startup, $server->egg->startup_commands))
->formatStateUsing(fn () => 'Custom Startup')
->hintAction(PreviewStartupAction::make()),
Select::make('startup_select')
->label(trans('server/startup.command'))
->live()
->visible(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server))
->formatStateUsing(fn (Server $server) => $server->startup)
->afterStateUpdated(function ($state, Server $server, Set $set) {
$original = $server->startup;
$server->forceFill(['startup' => $state])->saveOrFail();
$set('startup', $state);
$set('previewing', false);
if ($original !== $server->startup) {
$startups = array_flip($server->egg->startup_commands);
Activity::event('server:startup.command')
->property(['old' => $startups[$original], 'new' => $startups[$state]])
->log();
}
Notification::make()
->title(trans('server/startup.notification_startup'))
->body(trans('server/startup.notification_startup_body'))
->success()
->send();
})
->options(fn (Server $server) => array_flip($server->egg->startup_commands))
->selectablePlaceholder(false)
->hintAction(PreviewStartupAction::make()),
TextInput::make('custom_image')
->label(trans('server/startup.docker_image'))
->readOnly()
->visible(fn (Server $server) => !in_array($server->image, $server->egg->docker_images))
->formatStateUsing(fn (Server $server) => $server->image)
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
]),
->formatStateUsing(fn (Server $server) => $server->image),
Select::make('image')
->label(trans('server/startup.docker_image'))
->live()
->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images))
->disabled(fn (Server $server) => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->afterStateUpdated(function ($state, Server $server) {
$original = $server->image;
$server->forceFill(['image' => $state])->saveOrFail();
@@ -89,14 +106,12 @@ class Startup extends ServerFormPage
$images = $server->egg->docker_images;
return array_flip($images);
})
->selectablePlaceholder(false)
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
]),
}),
Textarea::make('startup')
->hiddenLabel()
->columnSpanFull()
->autosize()
->readOnly(),
Section::make(trans('server/startup.variables'))
->columnSpanFull()
->schema([
@@ -108,7 +123,7 @@ class Startup extends ServerFormPage
return $query->where('egg_variables.user_viewable', true)->orderByPowerJoins('variable.sort');
})
->grid()
->disabled(fn (Server $server) => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server))
->reorderable(false)->addable(false)->deletable(false)
->schema([
StartupVariable::make('variable_value')
@@ -124,12 +139,12 @@ class Startup extends ServerFormPage
protected function authorizeAccess(): void
{
abort_unless(auth()->user()->can(Permission::ACTION_STARTUP_READ, Filament::getTenant()), 403);
abort_unless(user()?->can(Permission::ACTION_STARTUP_READ, Filament::getTenant()), 403);
}
public static function canAccess(): bool
{
return parent::canAccess() && auth()->user()->can(Permission::ACTION_STARTUP_READ, Filament::getTenant());
return parent::canAccess() && user()?->can(Permission::ACTION_STARTUP_READ, Filament::getTenant());
}
public function update(?string $state, ServerVariable $serverVariable): null

View File

@@ -73,14 +73,14 @@ class ActivityResource extends Resource
$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()) {
if (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') : '')
->tooltip(fn (ActivityLog $activityLog) => user()?->can('seeIps activityLog') ? $activityLog->ip : '')
->url(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && user()?->can('update', $activityLog->actor) ? EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin') : '')
->grow(false),
DateTimeColumn::make('timestamp')
->label(trans('server/activity.timestamp'))
@@ -106,11 +106,11 @@ class ActivityResource extends Resource
$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()) {
if (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')) {
if (user()?->can('seeIps activityLog')) {
$user .= " - $activityLog->ip";
}
@@ -120,7 +120,7 @@ class ActivityResource extends Resource
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))
->visible(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && user()?->can('update', $activityLog->actor))
->url(fn (ActivityLog $activityLog) => EditUser::getUrl(['record' => $activityLog->actor], panel: 'admin'))
),
DateTimePicker::make('timestamp')
@@ -166,7 +166,7 @@ class ActivityResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_ACTIVITY_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_ACTIVITY_READ, Filament::getTenant());
}
/** @return array<string, PageRegistration> */

View File

@@ -58,7 +58,7 @@ class AllocationResource extends Resource
TextInputColumn::make('notes')
->label(trans('server/network.notes'))
->visibleFrom('sm')
->disabled(fn () => !auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->disabled(fn () => !user()?->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->placeholder(trans('server/network.no_notes')),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
@@ -70,18 +70,25 @@ class AllocationResource extends Resource
default => 'gray',
})
->tooltip(fn (Allocation $allocation) => $allocation->id === $server->allocation_id ? trans('server/network.primary') : trans('server/network.make_primary'))
->action(fn (Allocation $allocation) => auth()->user()->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server) && $server->update(['allocation_id' => $allocation->id]))
->action(fn (Allocation $allocation) => user()?->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server) && $server->update(['allocation_id' => $allocation->id]))
->default(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->label(trans('server/network.primary')),
IconColumn::make('is_locked')
->label(trans('server/network.locked'))
->tooltip(trans('server/network.locked_helper'))
->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'),
])
->recordActions([
DetachAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, $server))
->visible(fn (Allocation $allocation) => !$allocation->is_locked || user()?->can('update', $allocation->node))
->authorize(fn () => user()?->can(Permission::ACTION_ALLOCATION_DELETE, $server))
->label(trans('server/network.delete'))
->icon('tabler-trash')
->action(function (Allocation $allocation) {
Allocation::query()->where('id', $allocation->id)->update([
Allocation::where('id', $allocation->id)->update([
'notes' => null,
'is_locked' => false,
'server_id' => null,
]);
@@ -93,12 +100,12 @@ class AllocationResource extends Resource
->after(fn (Allocation $allocation) => $allocation->id === $server->allocation_id && $server->update(['allocation_id' => $server->allocations()->first()?->id])),
])
->toolbarActions([
Action::make('addAllocation')
Action::make('add_allocation')
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->icon(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'tabler-network-off' : 'tabler-network')
->authorize(fn () => auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_ALLOCATION_CREATE, $server))
->tooltip(fn () => $server->allocations()->count() >= $server->allocation_limit ? trans('server/network.limit') : trans('server/network.add'))
->hidden(fn () => !config('panel.client_features.allocations.enabled'))
->hidden(fn () => !config('panel.client_features.allocations.enabled') || $server->allocation === null)
->disabled(fn () => $server->allocations()->count() >= $server->allocation_limit)
->color(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'danger' : 'primary')
->action(function (FindAssignableAllocationService $service) use ($server) {
@@ -118,22 +125,22 @@ class AllocationResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_ALLOCATION_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, Filament::getTenant());
return user()?->can(Permission::ACTION_ALLOCATION_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, Filament::getTenant());
return user()?->can(Permission::ACTION_ALLOCATION_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, Filament::getTenant());
return user()?->can(Permission::ACTION_ALLOCATION_DELETE, Filament::getTenant());
}
/** @return array<string, PageRegistration> */

View File

@@ -129,7 +129,7 @@ class BackupResource extends Resource
ActionGroup::make([
Action::make('rename')
->icon('tabler-pencil')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(trans('server/backup.actions.rename.title'))
->schema([
TextInput::make('name')
@@ -160,7 +160,7 @@ class BackupResource extends Resource
Action::make('lock')
->iconSize(IconSize::Large)
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? trans('server/backup.actions.lock.lock') : trans('server/backup.actions.lock.unlock'))
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
@@ -169,7 +169,7 @@ class BackupResource extends Resource
->iconSize(IconSize::Large)
->color('primary')
->icon('tabler-download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->authorize(fn () => 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')
@@ -177,7 +177,7 @@ class BackupResource extends Resource
->iconSize(IconSize::Large)
->color('success')
->icon('tabler-folder-up')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_RESTORE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_RESTORE, $server))
->schema([
TextEntry::make('stop_info')
->hiddenLabel()
@@ -210,7 +210,7 @@ class BackupResource extends Resource
// 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());
$url = $downloadLinkService->handle($backup, user());
}
// Update the status right away for the server so that we know not to allow certain
@@ -230,6 +230,7 @@ class BackupResource extends Resource
->disabled(fn (Backup $backup) => $backup->is_locked && $backup->status !== BackupStatus::Failed)
->modalDescription(fn (Backup $backup) => trans('server/backup.actions.delete.description', ['backup' => $backup->name]))
->modalSubmitActionLabel(trans('server/backup.actions.delete.title'))
->successNotificationTitle(null)
->action(function (Backup $backup, DeleteBackupService $deleteBackupService) {
try {
$deleteBackupService->handle($backup);
@@ -258,17 +259,18 @@ class BackupResource extends Resource
])
->toolbarActions([
CreateAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_CREATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_CREATE, $server))
->icon('tabler-file-zip')
->tooltip(fn () => $server->backups()->count() >= $server->backup_limit ? trans('server/backup.actions.create.limit') : trans('server/backup.actions.create.title'))
->disabled(fn () => $server->backups()->count() >= $server->backup_limit)
->color(fn () => $server->backups()->count() >= $server->backup_limit ? 'danger' : 'primary')
->createAnother(false)
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->successNotificationTitle(null)
->action(function (InitiateBackupService $initiateBackupService, $data) use ($server) {
$action = $initiateBackupService->setIgnoredFiles(explode(PHP_EOL, $data['ignored'] ?? ''));
if (auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
if (user()?->can(Permission::ACTION_BACKUP_DELETE, $server)) {
$action->setIsLocked((bool) $data['is_locked']);
}
@@ -298,17 +300,17 @@ class BackupResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_BACKUP_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_BACKUP_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_BACKUP_CREATE, Filament::getTenant());
return user()?->can(Permission::ACTION_BACKUP_CREATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_BACKUP_DELETE, Filament::getTenant());
return user()?->can(Permission::ACTION_BACKUP_DELETE, Filament::getTenant());
}
/** @return array<string, PageRegistration> */

View File

@@ -88,10 +88,10 @@ class DatabaseResource extends Resource
TextInput::make('password')
->label(trans('server/database.password'))
->password()->revealable()
->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hidden(fn () => !user()?->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hintAction(
RotateDatabasePasswordAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_DATABASE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_DATABASE_UPDATE, $server))
)
->copyable()
->formatStateUsing(fn (Database $database) => $database->password),
@@ -103,7 +103,7 @@ class DatabaseResource extends Resource
TextInput::make('jdbc')
->label(trans('server/database.jdbc'))
->password()->revealable()
->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hidden(!user()?->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->copyable()
->columnSpanFull()
->formatStateUsing(fn (Database $database) => $database->jdbc),
@@ -138,6 +138,7 @@ class DatabaseResource extends Resource
ViewAction::make()
->modalHeading(fn (Database $database) => trans('server/database.viewing', ['database' => $database->database])),
DeleteAction::make()
->successNotificationTitle(null)
->using(function (Database $database, DatabaseManagementService $service) {
try {
$service->delete($database);
@@ -164,6 +165,7 @@ class DatabaseResource extends Resource
->disabled(fn () => $server->databases()->count() >= $server->database_limit)
->color(fn () => $server->databases()->count() >= $server->database_limit ? 'danger' : 'primary')
->createAnother(false)
->successNotificationTitle(null)
->schema([
Grid::make()
->columns(2)
@@ -210,27 +212,27 @@ class DatabaseResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_DATABASE_READ, Filament::getTenant());
}
public static function canView(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_DATABASE_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_CREATE, Filament::getTenant());
return user()?->can(Permission::ACTION_DATABASE_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_UPDATE, Filament::getTenant());
return user()?->can(Permission::ACTION_DATABASE_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_DELETE, Filament::getTenant());
return user()?->can(Permission::ACTION_DATABASE_DELETE, Filament::getTenant());
}
/** @return array<string, PageRegistration> */

View File

@@ -32,22 +32,22 @@ class FileResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_FILE_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_FILE_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_FILE_CREATE, Filament::getTenant());
return user()?->can(Permission::ACTION_FILE_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_FILE_UPDATE, Filament::getTenant());
return user()?->can(Permission::ACTION_FILE_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_FILE_DELETE, Filament::getTenant());
return user()?->can(Permission::ACTION_FILE_DELETE, Filament::getTenant());
}
/** @return array<string, PageRegistration> */

View File

@@ -39,12 +39,12 @@ class DownloadFiles extends Page
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser(auth()->user())
->setUser(user())
->setClaims([
'file_path' => rawurldecode($path),
'server_uuid' => $server->uuid,
])
->handle($server->node, auth()->user()->id . $server->uuid);
->handle($server->node, user()?->id . $server->uuid);
Activity::event('server:file.download')
->property('file', $path)
@@ -55,7 +55,7 @@ class DownloadFiles extends Page
protected function authorizeAccess(): void
{
abort_unless(auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
abort_unless(user()?->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
}
public static function route(string $path): PageRegistration

View File

@@ -7,11 +7,13 @@ use App\Exceptions\Repository\FileNotEditableException;
use App\Facades\Activity;
use App\Filament\Server\Resources\Files\FileResource;
use App\Livewire\AlertBanner;
use App\Models\File;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Closure;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\CodeEditor;
@@ -81,7 +83,7 @@ class EditFiles extends Page
->footerActions([
Action::make('save_and_close')
->label(trans('server/file.actions.edit.save_close'))
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+shift+s')
->action(function () {
@@ -101,7 +103,7 @@ class EditFiles extends Page
}),
Action::make('save')
->label(trans('server/file.actions.edit.save'))
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+s')
->action(function () {
@@ -215,19 +217,21 @@ class EditFiles extends Page
$this->previousUrl = url()->previous();
if (str($path)->endsWith('.pelicanignore')) {
AlertBanner::make('.pelicanignore_info')
->title(trans('server/file.alerts.pelicanignore.title'))
->body(trans('server/file.alerts.pelicanignore.body'))
->info()
->closable()
->send();
foreach (File::getSpecialFiles() as $fileName => $data) {
if ($data['check'] instanceof Closure && $data['check']($path)) {
AlertBanner::make($fileName . '_info')
->title($data['title'])
->body($data['body'])
->info()
->closable()
->send();
}
}
}
protected function authorizeAccess(): void
{
abort_unless(auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
abort_unless(user()?->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
}
/**

View File

@@ -12,8 +12,10 @@ use App\Models\File;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Services\Nodes\NodeJWTService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Carbon\CarbonImmutable;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@@ -25,15 +27,14 @@ use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Panel;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\PageRegistration;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Enums\IconSize;
use Filament\Support\Facades\FilamentView;
@@ -41,7 +42,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\PaginationMode;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Routing\Route;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Route as RouteFacade;
@@ -54,6 +55,8 @@ class ListFiles extends ListRecords
protected static string $resource = FileResource::class;
protected string $view = 'filament.server.pages.list-files';
#[Locked]
public string $path = '/';
@@ -119,7 +122,7 @@ class ListFiles extends ListRecords
return self::getUrl(['path' => encode_path(join_paths($this->path, $file->name))]);
}
if (!auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server)) {
if (!user()?->can(Permission::ACTION_FILE_READ_CONTENT, $server)) {
return null;
}
@@ -127,19 +130,19 @@ class ListFiles extends ListRecords
})
->recordActions([
Action::make('view')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ, $server))
->label(trans('server/file.actions.open'))
->icon('tabler-eye')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => encode_path(join_paths($this->path, $file->name))])),
EditAction::make('edit')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->icon('tabler-edit')
->visible(fn (File $file) => $file->canEdit())
->url(fn (File $file) => EditFiles::getUrl(['path' => encode_path(join_paths($this->path, $file->name))])),
ActionGroup::make([
Action::make('rename')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->label(trans('server/file.actions.rename.title'))
->icon('tabler-forms')->iconSize(IconSize::Large)
->schema([
@@ -169,7 +172,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('copy')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->label(trans('server/file.actions.copy.title'))
->icon('tabler-copy')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->is_file)
@@ -188,13 +191,13 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->label(trans('server/file.actions.download'))
->icon('tabler-download')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->is_file)
->url(fn (File $file) => DownloadFiles::getUrl(['path' => encode_path(join_paths($this->path, $file->name))]), true),
Action::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->label(trans('server/file.actions.move.title'))
->icon('tabler-replace')->iconSize(IconSize::Large)
->schema([
@@ -231,7 +234,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('permissions')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->label(trans('server/file.actions.permissions.title'))
->icon('tabler-license')->iconSize(IconSize::Large)
->schema([
@@ -293,17 +296,30 @@ class ListFiles extends ListRecords
->send();
}),
Action::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label(trans('server/file.actions.archive.title'))
->icon('tabler-archive')->iconSize(IconSize::Large)
->schema([
TextInput::make('name')
->label(trans('server/file.actions.archive.archive_name'))
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
Grid::make(3)
->schema([
TextInput::make('name')
->label(trans('server/file.actions.archive.archive_name'))
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->columnSpan(2),
Select::make('extension')
->label(trans('server/file.actions.archive.extension'))
->selectablePlaceholder(false)
->options([
'tar.gz' => 'tar.gz',
'zip' => 'zip',
'tar.bz2' => 'tar.bz2',
'tar.xz' => 'tar.xz',
])
->columnSpan(1),
]),
])
->action(function ($data, File $file) {
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, [$file->name], $data['name']);
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, [$file->name], $data['name'], $data['extension']);
Activity::event('server:file.compress')
->property('name', $archive['name'])
@@ -320,7 +336,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('unarchive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label(trans('server/file.actions.unarchive.title'))
->icon('tabler-archive')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->isArchive())
@@ -341,7 +357,7 @@ class ListFiles extends ListRecords
}),
])->iconSize(IconSize::Large),
DeleteAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_DELETE, $server))
->hiddenLabel()
->icon('tabler-trash')->iconSize(IconSize::Large)
->requiresConfirmation()
@@ -361,7 +377,7 @@ class ListFiles extends ListRecords
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->schema([
TextInput::make('location')
->label(trans('server/file.actions.move.directory'))
@@ -390,17 +406,30 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server))
->schema([
TextInput::make('name')
->label(trans('server/file.actions.archive.archive_name'))
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
Grid::make(3)
->schema([
TextInput::make('name')
->label(trans('server/file.actions.archive.archive_name'))
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->columnSpan(2),
Select::make('extension')
->label(trans('server/file.actions.archive.extension'))
->selectablePlaceholder(false)
->options([
'tar.gz' => 'tar.gz',
'zip' => 'zip',
'tar.bz2' => 'tar.bz2',
'tar.xz' => 'tar.xz',
])
->columnSpan(1),
]),
])
->action(function ($data, Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name'], $data['extension']);
Activity::event('server:file.compress')
->property('name', $archive['name'])
@@ -417,7 +446,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
@@ -437,7 +466,7 @@ class ListFiles extends ListRecords
]),
Action::make('new_file')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->tooltip(trans('server/file.actions.new_file.title'))
->hiddenLabel()->icon('tabler-file-plus')->iconButton()->iconSize(IconSize::ExtraLarge)
->color('primary')
@@ -470,13 +499,13 @@ class ListFiles extends ListRecords
->hiddenLabel(),
]),
Action::make('new_folder')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->hiddenLabel()->icon('tabler-folder-plus')->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.new_folder.title'))
->color('primary')
->action(function ($data) {
try {
$this->getDaemonFileRepository()->createDirectory($data['name'], $this->path);
$this->createFolder($data['name']);
Activity::event('server:file.create-directory')
->property(['directory' => $this->path, 'name' => $data['name']])
@@ -500,61 +529,33 @@ class ListFiles extends ListRecords
->label(trans('server/file.actions.new_folder.folder_name'))
->required(),
]),
Action::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->hiddenLabel()->icon('tabler-upload')->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.upload.title'))
Action::make('uploadFile')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->view('filament.server.pages.file-upload'),
Action::make('uploadURL')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->hiddenLabel()->icon('tabler-download')->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.upload.from_url'))
->modalHeading(trans('server/file.actions.upload.from_url'))
->color('success')
->action(function ($data) {
if (count($data['files']) > 0 && !isset($data['url'])) {
/** @var UploadedFile $file */
foreach ($data['files'] as $file) {
$this->getDaemonFileRepository()->putContent(join_paths($this->path, $file->getClientOriginalName()), $file->getContent());
$this->getDaemonFileRepository()->pull($data['url'], $this->path);
Activity::event('server:file.uploaded')
->property('directory', $this->path)
->property('file', $file->getClientOriginalName())
->log();
}
} elseif ($data['url'] !== null) {
$this->getDaemonFileRepository()->pull($data['url'], $this->path);
Activity::event('server:file.pull')
->property('url', $data['url'])
->property('directory', $this->path)
->log();
}
Activity::event('server:file.pull')
->property('url', $data['url'])
->property('directory', $this->path)
->log();
$this->refreshPage();
})
->schema([
Tabs::make()
->contained(false)
->schema([
Tab::make('files')
->label(trans('server/file.actions.upload.from_files'))
->live()
->schema([
FileUpload::make('files')
->storeFiles(false)
->previewable(false)
->preserveFilenames()
->maxSize((int) round($server->node->upload_size * (config('panel.use_binary_prefix') ? 1.048576 * 1024 : 1000)))
->multiple(),
]),
Tab::make('url')
->label(trans('server/file.actions.upload.url'))
->live()
->disabled(fn (Get $get) => count($get('files')) > 0)
->schema([
TextInput::make('url')
->label(trans('server/file.actions.upload.url'))
->url(),
]),
]),
TextInput::make('url')
->label(trans('server/file.actions.upload.url'))
->required()
->url(),
]),
Action::make('search')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ, $server))
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.global_search.title'))
->color('primary')
@@ -599,6 +600,81 @@ class ListFiles extends ListRecords
};
}
public function getUploadUrl(NodeJWTService $jwtService): string
{
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(Permission::ACTION_FILE_CREATE, $server)) {
abort(403, 'You do not have permission to upload files.');
}
$token = $jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser(user())
->setClaims(['server_uuid' => $server->uuid])
->handle($server->node, user()->id . $server->uuid);
return sprintf(
'%s/upload/file?token=%s',
$server->node->getConnectionAddress(),
$token->toString()
);
}
public function getUploadSizeLimit(): int
{
/** @var Server $server */
$server = Filament::getTenant();
return $server->node->upload_size * 1024 * 1024;
}
/**
* @throws ConnectionException
* @throws FileExistsException
* @throws \Throwable
*/
public function createFolder(string $folderPath): void
{
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(Permission::ACTION_FILE_CREATE, $server)) {
abort(403, 'You do not have permission to create folders.');
}
try {
$this->getDaemonFileRepository()->createDirectory($folderPath, $this->path);
Activity::event('server:file.create-directory')
->property(['directory' => $this->path, 'name' => $folderPath])
->log();
} catch (FileExistsException) {
// Ignore if the folder already exists.
} catch (ConnectionException $e) {
Notification::make()
->body($e->getMessage())
->danger()
->send();
}
}
/**
* @param string[] $files
*/
public function logUploadedFiles(array $files): void
{
$filesCollection = collect($files);
Activity::event('server:files.uploaded')
->property('directory', $this->path)
->property('files', $filesCollection)
->log();
}
private function getDaemonFileRepository(): DaemonFileRepository
{
/** @var Server $server */

View File

@@ -29,7 +29,7 @@ class ViewSchedule extends ViewRecord
{
return [
Action::make('run_now')
->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant()))
->authorize(fn () => user()?->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant()))
->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? trans('server/schedule.no_tasks') : ($schedule->status === ScheduleStatus::Processing ? ScheduleStatus::Processing->getLabel() : trans('server/schedule.run_now')))
->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->status === ScheduleStatus::Processing ? 'warning' : 'primary')
->disabled(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->status === ScheduleStatus::Processing)

View File

@@ -72,12 +72,12 @@ class TasksRelationManager extends RelationManager
->default('restart'),
TextInput::make('time_offset')
->label(trans('server/schedule.tasks.time_offset'))
->hidden(fn (Get $get) => config('queue.default') === 'sync' || $get('sequence_id') === 1)
->hidden(fn (Get $get) => config('queue.default') === 'sync' || $get('sequence_id') === 1 || $schedule->tasks->isEmpty())
->default(0)
->numeric()
->minValue(0)
->maxValue(900)
->suffix(trans('server/schedule.tasks.seconds')),
->suffix(trans_choice('server/schedule.tasks.seconds', 2)),
Toggle::make('continue_on_failure')
->label(trans('server/schedule.tasks.continue_on_failure')),
];
@@ -108,7 +108,9 @@ class TasksRelationManager extends RelationManager
TextColumn::make('time_offset')
->label(trans('server/schedule.tasks.time_offset'))
->hidden(fn () => config('queue.default') === 'sync')
->suffix(' '. trans('server/schedule.tasks.seconds')),
->suffix(fn (Task $task) => $task->sequence_id > 1 ? ' '. trans_choice('server/schedule.tasks.seconds', $task->time_offset) : null)
->state(fn (Task $task) => $task->sequence_id === 1 ? null : $task->time_offset)
->placeholder(trans('server/schedule.tasks.first_task')),
IconColumn::make('continue_on_failure')
->label(trans('server/schedule.tasks.continue_on_failure'))
->boolean(),

View File

@@ -66,22 +66,22 @@ class ScheduleResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_SCHEDULE_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_CREATE, Filament::getTenant());
return user()?->can(Permission::ACTION_SCHEDULE_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant());
return user()?->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_DELETE, Filament::getTenant());
return user()?->can(Permission::ACTION_SCHEDULE_DELETE, Filament::getTenant());
}
/**
@@ -123,12 +123,12 @@ class ScheduleResource extends Resource
->label(trans('server/schedule.cron'))
->description(function (Get $get) {
try {
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(auth()->user()->timezone);
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(user()->timezone ?? 'UTC');
} catch (Exception) {
$nextRun = trans('server/schedule.invalid');
}
return new HtmlString(trans('server/schedule.cron_body') . '<br>' . trans('server/schedule.cron_timezone', ['timezone' => auth()->user()->timezone, 'next_run' => $nextRun]));
return new HtmlString(trans('server/schedule.cron_body') . '<br>' . trans('server/schedule.cron_timezone', ['timezone' => user()->timezone ?? 'UTC', 'next_run' => $nextRun]));
})
->schema([
Actions::make([
@@ -255,8 +255,7 @@ class ScheduleResource extends Resource
'6' => trans('server/schedule.time.saturday'),
'0' => trans('server/schedule.time.sunday'),
])
->selectablePlaceholder(false)
->native(false),
->selectablePlaceholder(false),
])
->action(function (Set $set, $data) {
$set('cron_minute', '0');

View File

@@ -65,22 +65,22 @@ class UserResource extends Resource
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_USER_READ, Filament::getTenant());
return user()?->can(Permission::ACTION_USER_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_USER_CREATE, Filament::getTenant());
return user()?->can(Permission::ACTION_USER_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_USER_UPDATE, Filament::getTenant());
return user()?->can(Permission::ACTION_USER_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_USER_DELETE, Filament::getTenant());
return user()?->can(Permission::ACTION_USER_DELETE, Filament::getTenant());
}
public static function defaultTable(Table $table): Table
@@ -139,7 +139,8 @@ class UserResource extends Resource
->recordActions([
DeleteAction::make()
->label(trans('server/user.delete'))
->hidden(fn (User $user) => auth()->user()->id === $user->id)
->hidden(fn (User $user) => user()?->id === $user->id)
->successNotificationTitle(null)
->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuser = $server->subusers->where('user_id', $user->id)->first();
$subuserDeletionService->handle($subuser, $server);
@@ -151,9 +152,10 @@ class UserResource extends Resource
}),
EditAction::make()
->label(trans('server/user.edit'))
->hidden(fn (User $user) => auth()->user()->id === $user->id)
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server))
->hidden(fn (User $user) => user()?->id === $user->id)
->authorize(fn () => user()?->can(Permission::ACTION_USER_UPDATE, $server))
->modalHeading(fn (User $user) => trans('server/user.editing', ['user' => $user->email]))
->successNotificationTitle(null)
->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) {
$subuser = $server->subusers->where('user_id', $user->id)->first();
@@ -237,7 +239,7 @@ class UserResource extends Resource
->icon('tabler-user-plus')
->tooltip(trans('server/user.invite_user'))
->createAnother(false)
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_CREATE, $server))
->authorize(fn () => user()?->can(Permission::ACTION_USER_CREATE, $server))
->schema([
Grid::make()
->columnSpanFull()

View File

@@ -121,11 +121,11 @@ class ServerConsole extends Widget
foreach ($data as $key => $value) {
$cacheKey = "servers.{$this->server->id}.$key";
$data = cache()->get($cacheKey, []);
$cachedStats = cache()->get($cacheKey, []);
$data[$timestamp] = $value;
$cachedStats[$timestamp] = $value;
cache()->put($cacheKey, $data, now()->addMinute());
cache()->put($cacheKey, array_slice($cachedStats, -120), now()->addMinute());
}
}

View File

@@ -27,12 +27,12 @@ class ServerCpuChart extends ChartWidget
protected function getData(): array
{
$period = (int) auth()->user()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$period = (int) user()?->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))
->slice(-$period)
->map(fn ($value, $key) => [
'cpu' => round($value, 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
'timestamp' => Carbon::createFromTimestamp($key, user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();
@@ -48,7 +48,7 @@ class ServerCpuChart extends ChartWidget
],
],
'labels' => array_column($cpu, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
'locale' => user()->language ?? 'en',
];
}

View File

@@ -27,12 +27,12 @@ class ServerMemoryChart extends ChartWidget
protected function getData(): array
{
$period = (int) auth()->user()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$period = (int) user()?->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))
->slice(-$period)
->map(fn ($value, $key) => [
'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'),
'timestamp' => Carbon::createFromTimestamp($key, user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();
@@ -48,7 +48,7 @@ class ServerMemoryChart extends ChartWidget
],
],
'labels' => array_column($memUsed, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
'locale' => user()->language ?? 'en',
];
}

View File

@@ -29,7 +29,7 @@ class ServerNetworkChart extends ChartWidget
{
$previous = null;
$period = (int) auth()->user()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$period = (int) user()?->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$net = collect(cache()->get("servers.{$this->server->id}.network"))
->slice(-$period)
->map(function ($current, $timestamp) use (&$previous) {
@@ -39,7 +39,7 @@ class ServerNetworkChart extends ChartWidget
$net = [
'rx' => max(0, $current->rx_bytes - $previous->rx_bytes),
'tx' => max(0, $current->tx_bytes - $previous->tx_bytes),
'timestamp' => Carbon::createFromTimestamp($timestamp, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
'timestamp' => Carbon::createFromTimestamp($timestamp, user()->timezone ?? 'UTC')->format('H:i:s'),
];
}

View File

@@ -77,7 +77,7 @@ class DatabaseHostController extends ApplicationApiController
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->addMeta([
'resource' => route('api.application.databases.view', [
'resource' => route('api.application.databasehosts.view', [
'database_host' => $databaseHost->id,
]),
])

View File

@@ -2,14 +2,24 @@
namespace App\Http\Controllers\Api\Application\Eggs;
use App\Enums\EggFormat;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Eggs\ExportEggRequest;
use App\Http\Requests\Api\Application\Eggs\GetEggRequest;
use App\Http\Requests\Api\Application\Eggs\GetEggsRequest;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Transformers\Api\Application\EggTransformer;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EggController extends ApplicationApiController
{
public function __construct(
private EggExporterService $exporterService,
) {
parent::__construct();
}
/**
* List eggs
*
@@ -37,4 +47,20 @@ class EggController extends ApplicationApiController
->transformWith($this->getTransformer(EggTransformer::class))
->toArray();
}
/**
* Export egg
*
* Return a single egg as yaml or json file (defaults to YAML)
*/
public function export(ExportEggRequest $request, Egg $egg): StreamedResponse
{
$format = EggFormat::tryFrom($request->input('format')) ?? EggFormat::YAML;
return response()->streamDownload(function () use ($egg, $format) {
echo $this->exporterService->handle($egg->id, $format);
}, 'egg-' . $egg->getKebabName() . '.' . $format->value, [
'Content-Type' => 'application/' . $format->value,
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Client;
use App\Facades\Activity;
use App\Http\Requests\Api\Client\Account\UpdateEmailRequest;
use App\Http\Requests\Api\Client\Account\UpdatePasswordRequest;
use App\Http\Requests\Api\Client\Account\UpdateUsernameRequest;
use App\Services\Users\UserUpdateService;
use App\Transformers\Api\Client\UserTransformer;
use Illuminate\Auth\AuthManager;
@@ -36,6 +37,25 @@ class AccountController extends ClientApiController
->toArray();
}
/**
* Update username
*
* Update the authenticated user's username.
*/
public function updateUsername(UpdateUsernameRequest $request): JsonResponse
{
$original = $request->user()->username;
$this->updateService->handle($request->user(), $request->validated());
if ($original !== $request->input('username')) {
Activity::event('user:account.username-changed')
->property(['old' => $original, 'new' => $request->input('username')])
->log();
}
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Update email
*

View File

@@ -212,7 +212,8 @@ class FileController extends ClientApiController
$file = $this->fileRepository->setServer($server)->compressFiles(
$request->input('root'),
$request->input('files'),
$request->input('name')
$request->input('name'),
$request->input('extension')
);
Activity::event('server:file.compress')

View File

@@ -56,7 +56,7 @@ class OAuthController extends Controller
$oauthUser = Socialite::driver($driver->getId())->user();
if ($request->user()) {
$this->linkUser($request->user(), $driver, $oauthUser);
$this->oauthService->linkUser($request->user(), $driver, $oauthUser);
return redirect(EditProfile::getUrl(['tab' => 'oauth::data::tab'], panel: 'app'));
}
@@ -69,16 +69,6 @@ class OAuthController extends Controller
return $this->handleMissingUser($driver, $oauthUser);
}
private function linkUser(User $user, OAuthSchemaInterface $driver, OAuthUser $oauthUser): User
{
$oauth = $user->oauth;
$oauth[$driver->getId()] = $oauthUser->getId();
$user->update(['oauth' => $oauth]);
return $user->refresh();
}
private function handleMissingUser(OAuthSchemaInterface $driver, OAuthUser $oauthUser): RedirectResponse
{
$email = $oauthUser->getEmail();
@@ -93,7 +83,7 @@ class OAuthController extends Controller
return $this->errorRedirect();
}
$user = $this->linkUser($user, $driver, $oauthUser);
$user = $this->oauthService->linkUser($user, $driver, $oauthUser);
} else {
if (!$driver->shouldCreateMissingUsers()) {
return $this->errorRedirect();

View File

@@ -5,6 +5,7 @@ namespace App\Http\Middleware\Activity;
use App\Facades\LogTarget;
use App\Models\Server;
use Closure;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -21,10 +22,7 @@ class ServerSubject
public function handle(Request $request, Closure $next): Response
{
$server = $request->route()->parameter('server');
if ($request->route()->hasParameter('tenant')) {
$server = Server::find($request->route()->parameter('tenant'));
}
$server ??= Filament::getTenant();
if ($server instanceof Server) {
LogTarget::setActor($request->user());

View File

@@ -17,7 +17,7 @@ class AuthenticateApplicationUser
{
/** @var User|null $user */
$user = $request->user();
if (!$user || !$user->isRootAdmin()) {
if (!$user || !$user->isAdmin()) {
throw new AccessDeniedHttpException('This account does not have permission to access the API.');
}

View File

@@ -41,10 +41,14 @@ abstract class ApplicationApiRequest extends FormRequest
$token = $this->user()->currentAccessToken();
if ($token instanceof TransientToken) {
return true;
return match ($this->permission) {
default => false,
AdminAcl::READ => $this->user()->can('viewList ' . $this->resource) && $this->user()->can('view ' . $this->resource),
AdminAcl::WRITE => $this->user()->can('update ' . $this->resource),
};
}
if ($token->key_type === ApiKey::TYPE_ACCOUNT) {
if ($this->user()->isRootAdmin() && $token->key_type === ApiKey::TYPE_ACCOUNT) {
return true;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Eggs;
class ExportEggRequest extends GetEggRequest
{
public function rules(): array
{
return [
'format' => 'nullable|string|in:yaml,json',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Api\Client\Account;
use App\Exceptions\Http\Base\InvalidPasswordProvidedException;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\User;
use Illuminate\Container\Container;
use Illuminate\Contracts\Hashing\Hasher;
class UpdateUsernameRequest extends ClientApiRequest
{
/**
* @throws InvalidPasswordProvidedException
*/
public function authorize(): bool
{
if (!parent::authorize()) {
return false;
}
$hasher = Container::getInstance()->make(Hasher::class);
// Verify password matches when changing password or email.
if (!$hasher->check($this->input('password'), $this->user()->password)) {
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
return true;
}
public function rules(): array
{
$rules = User::getRulesForUpdate($this->user());
return ['username' => $rules['username']];
}
}

View File

@@ -22,6 +22,7 @@ class CompressFilesRequest extends ClientApiRequest
'files' => 'required|array',
'files.*' => 'string',
'name' => 'sometimes|nullable|string',
'extension' => 'sometimes|in:zip,tgz,tar.gz,txz,tar.xz,tbz2,tar.bz2',
];
}
}

View File

@@ -16,6 +16,14 @@ class ServerRequest extends FormRequest
/** @var ?Server $server */
$server = $this->route()->parameter('server');
return $server && $server->node_id === $node->id;
if ($server) {
if ($server->transfer) {
return $server->transfer->old_node === $node->id || $server->transfer->new_node === $node->id;
}
return $server->node_id === $node->id;
}
return false;
}
}

View File

@@ -34,7 +34,9 @@ class ProcessWebhook implements ShouldQueue
$data = reset($data);
}
$data = Arr::wrap(json_decode($data, true) ?? []);
if (is_string($data)) {
$data = Arr::wrap(json_decode($data, true) ?? []);
}
$data['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
if ($this->webhookConfiguration->type === WebhookType::Discord) {
@@ -55,12 +57,13 @@ class ProcessWebhook implements ShouldQueue
}
try {
$customHeaders = $this->webhookConfiguration->headers;
$headers = [];
foreach ($customHeaders as $key => $value) {
$headers[$key] = $this->webhookConfiguration->replaceVars($data, $value);
}
if ($this->webhookConfiguration->type === WebhookType::Regular) {
foreach ($this->webhookConfiguration->headers as $key => $value) {
$headers[$key] = $this->webhookConfiguration->replaceVars($data, $value);
}
}
Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $data)->throw();
$successful = now();
} catch (Exception $exception) {

View File

@@ -117,7 +117,6 @@ class PanelInstaller extends SimplePage implements HasForms
->selectablePlaceholder(false)
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
->afterStateUpdated(fn ($state, Application $app) => $app->setLocale($state ?? config('app.locale')))
->native(false)
->columnStart(4);
}

View File

@@ -48,7 +48,7 @@ class QueueStep
->label(new HtmlString(trans('installer.queue.fields.crontab')))
->disabled()
->hintCopy()
->default('(crontab -l -u www-data 2>/dev/null; echo "* * * * * php ' . base_path() . '/artisan schedule:run >> /dev/null 2>&1") | crontab -u www-data -')
->default('(sudo crontab -l -u www-data 2>/dev/null; echo "* * * * * php ' . base_path() . '/artisan schedule:run >> /dev/null 2>&1") | sudo crontab -u www-data -')
->hidden(fn () => @file_exists('/.dockerenv'))
->columnSpanFull(),
TextInput::make('queueService')

View File

@@ -25,6 +25,18 @@ class ServerEntry extends Component
</div>
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
@if($server->egg->image)
<div style="
position: absolute;
inset: 0;
background: url('{{ $server->egg->image }}') right no-repeat;
background-size: contain;
opacity: 0.20;
max-width: 680px;
max-height: 140px;
"></div>
@endif
<div class="flex items-center mb-5 gap-2">
<x-filament::loading-indicator class="h-6 w-6" />
<h2 class="text-xl font-bold">

View File

@@ -163,6 +163,11 @@ class ActivityLog extends Model implements HasIcon, HasLabel
return trans_choice('activity.'.str($this->event)->replace(':', '.'), array_key_exists('count', $properties) ? $properties['count'] : 1, $properties);
}
public function getIp(): ?string
{
return user()?->can('seeIps activityLog') ? $this->ip : null;
}
public function htmlable(): string
{
$user = $this->actor;
@@ -175,15 +180,17 @@ class ActivityLog extends Model implements HasIcon, HasLabel
$avatarUrl = Filament::getUserAvatarUrl($user);
$username = str($user->username)->stripTags();
$ip = $this->getIp();
$ip = $ip ? $ip . ' — ' : '';
return "
<div style='display: flex; align-items: center;'>
<img width='50px' height='50px' src='{$avatarUrl}' style='margin-right: 15px' />
<img width='50px' height='50px' src='{$avatarUrl}' style='margin-right: 15px; border-radius: 50%;' />
<div>
<p>$username$this->event</p>
<p>{$this->getLabel()}</p>
<p>$this->ip<span title='{$this->timestamp->format('M j, Y g:ia')}'>{$this->timestamp->diffForHumans()}</span></p>
<p>$ip<span title='{$this->timestamp->format('M j, Y g:ia')}'>{$this->timestamp->diffForHumans()}</span></p>
</div>
</div>
";

View File

@@ -29,6 +29,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $address
* @property Server|null $server
* @property Node $node
* @property bool $is_locked
*
* @method static AllocationFactory factory(...$parameters)
* @method static Builder|Allocation newModelQuery()
@@ -55,6 +56,10 @@ class Allocation extends Model
*/
public const RESOURCE_NAME = 'allocation';
protected $attributes = [
'is_locked' => false,
];
/**
* Fields that are not mass assignable.
*/
@@ -68,10 +73,17 @@ class Allocation extends Model
'ip_alias' => ['nullable', 'string'],
'server_id' => ['nullable', 'exists:servers,id'],
'notes' => ['nullable', 'string', 'max:256'],
'is_locked' => ['boolean'],
];
protected static function booted(): void
{
static::updating(function (self $allocation) {
if (is_null($allocation->server_id)) {
$allocation->is_locked = false;
}
});
static::deleting(function (self $allocation) {
throw_if($allocation->server_id, new ServerUsingAllocationException(trans('exceptions.allocations.server_using')));
});
@@ -83,6 +95,7 @@ class Allocation extends Model
'node_id' => 'integer',
'port' => 'integer',
'server_id' => 'integer',
'is_locked' => 'bool',
];
}

View File

@@ -21,9 +21,9 @@ use Illuminate\Support\Str;
* @property string $author
* @property string $name
* @property string|null $description
* @property string|null $image
* @property string[]|null $features
* @property string $docker_image -- deprecated, use $docker_images
* @property array<array-key, string> $docker_images
* @property array<string, string> $docker_images
* @property string|null $update_url
* @property bool $force_outgoing_ip
* @property string[]|null $file_denylist
@@ -32,7 +32,7 @@ use Illuminate\Support\Str;
* @property string|null $config_logs
* @property string|null $config_stop
* @property int|null $config_from
* @property string|null $startup
* @property array<string, string> $startup_commands
* @property bool $script_is_privileged
* @property string|null $script_install
* @property string $script_entry
@@ -71,7 +71,7 @@ class Egg extends Model implements Validatable
/**
* Defines the current egg export version.
*/
public const EXPORT_VERSION = 'PLCN_v2';
public const EXPORT_VERSION = 'PLCN_v3';
/**
* Fields that are not mass assignable.
@@ -81,6 +81,7 @@ class Egg extends Model implements Validatable
'name',
'author',
'description',
'image',
'features',
'docker_images',
'force_outgoing_ip',
@@ -90,7 +91,7 @@ class Egg extends Model implements Validatable
'config_logs',
'config_stop',
'config_from',
'startup',
'startup_commands',
'update_url',
'script_is_privileged',
'script_install',
@@ -105,13 +106,15 @@ class Egg extends Model implements Validatable
'uuid' => ['required', 'string', 'size:36'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'nullable'],
'image' => ['string', 'nullable'],
'features' => ['array', 'nullable'],
'author' => ['required', 'string', 'email'],
'file_denylist' => ['array', 'nullable'],
'file_denylist.*' => ['string'],
'docker_images' => ['required', 'array', 'min:1'],
'docker_images.*' => ['required', 'string'],
'startup' => ['required', 'nullable', 'string'],
'startup_commands' => ['required', 'array', 'min:1'],
'startup_commands.*' => ['required', 'string', 'distinct'],
'config_from' => ['sometimes', 'bail', 'nullable', 'numeric', 'exists:eggs,id'],
'config_stop' => ['required_without:config_from', 'nullable', 'string', 'max:255'],
'config_startup' => ['required_without:config_from', 'nullable', 'json'],
@@ -143,6 +146,7 @@ class Egg extends Model implements Validatable
'features' => 'array',
'docker_images' => 'array',
'file_denylist' => 'array',
'startup_commands' => 'array',
'tags' => 'array',
];
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Livewire\AlertBanner;
use App\Repositories\Daemon\DaemonFileRepository;
use Carbon\Carbon;
use Closure;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -54,6 +55,32 @@ class File extends Model
protected static ?string $searchTerm;
/** @var array<string, array<string, string|Closure|null>> */
protected static array $customSpecialFiles = [];
public static function registerSpecialFile(string $fileName, string|Closure $bannerTitle, string|Closure|null $bannerBody = null, ?Closure $nameCheck = null): void
{
static::$customSpecialFiles[$fileName] = [
'title' => $bannerTitle,
'body' => $bannerBody,
'check' => $nameCheck ?? fn (string $path) => str($path)->endsWith($fileName),
];
}
/** @return array<string, array<string, string|Closure|null>> */
public static function getSpecialFiles(): array
{
$specialFiles = [
'.pelicanignore' => [
'title' => fn () => trans('server/file.alerts.pelicanignore.title'),
'body' => fn () => trans('server/file.alerts.pelicanignore.body'),
'check' => fn (string $path) => str($path)->endsWith('.pelicanignore'),
],
];
return array_merge($specialFiles, static::$customSpecialFiles);
}
public static function get(Server $server, string $path = '/', ?string $searchTerm = null): Builder
{
self::$server = $server;
@@ -175,7 +202,7 @@ class File extends Model
'is_directory' => $file['directory'],
'is_file' => $file['file'],
'is_symlink' => $file['symlink'],
'mime_type' => $file['mime'],
'mime_type' => $file['file'] && str($file['name'])->lower()->endsWith('.jar') && in_array($file['mime'], self::ARCHIVE_MIMES) ? 'application/jar' : $file['mime'],
];
}, $contents);

View File

@@ -4,7 +4,7 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Exceptions\Service\HasActiveServersException;
use App\Repositories\Daemon\DaemonConfigurationRepository;
use App\Repositories\Daemon\DaemonSystemRepository;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Exception;
@@ -316,7 +316,7 @@ class Node extends Model implements Validatable
{
return once(function () {
try {
return (new DaemonConfigurationRepository())
return (new DaemonSystemRepository())
->setNode($this)
->getSystemInformation();
} catch (Exception $exception) {

View File

@@ -49,6 +49,9 @@ class Role extends BaseRole
'activityLog' => [
'seeIps',
],
'panelLog' => [
'view',
],
];
/** @var array<string, array<string>> */

Some files were not shown because too many files have changed in this diff Show More