Merge branch 'main' into feat/settings-description

This commit is contained in:
Vehikl 2025-04-24 16:22:54 -04:00
commit a70311b7d4
222 changed files with 2771 additions and 1919 deletions

View File

@ -3,5 +3,4 @@ APP_DEBUG=false
APP_KEY= APP_KEY=
APP_URL=http://panel.test APP_URL=http://panel.test
APP_INSTALLED=false APP_INSTALLED=false
APP_TIMEZONE=UTC
APP_LOCALE=en APP_LOCALE=en

15
.github/CODEOWNERS vendored
View File

@ -1,15 +0,0 @@
# Lines starting with '#' are comments.
# Each line is a file pattern followed by one or more owners.
# More details are here: https://help.github.com/articles/about-codeowners/
# The '*' pattern is global owners.
# Order is important. The last matching pattern has the most precedence.
# The folders are ordered as follows:
# In each subsection folders are ordered first by depth, then alphabetically.
# This should make it easy to add new rules without breaking existing ones.
# Global
* @pelican-dev/panel

View File

@ -213,3 +213,79 @@ jobs:
- name: Integration tests - name: Integration tests
run: vendor/bin/pest tests/Integration run: vendor/bin/pest tests/Integration
postgresql:
name: PostgreSQL
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
database: ["postgres:14"]
services:
database:
image: ${{ matrix.database }}
env:
POSTGRES_DB: testing
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: postgres
DB_PASSWORD: postgres
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
steps:
- name: Code Checkout
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Unit tests
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration

View File

@ -4,11 +4,11 @@
# For those who want to build this Dockerfile themselves, uncomment lines 6-12 and replace "localhost:5000/base-php:$TARGETARCH" on lines 17 and 67 with "base". # For those who want to build this Dockerfile themselves, uncomment lines 6-12 and replace "localhost:5000/base-php:$TARGETARCH" on lines 17 and 67 with "base".
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine as base # FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine as base
# ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ # ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
# RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql # RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
# RUN rm /usr/local/bin/install-php-extensions # RUN rm /usr/local/bin/install-php-extensions
@ -85,10 +85,13 @@ RUN chown root:www-data ./ \
# Symlink to env/database path, as www-data won't be able to write to webroot # Symlink to env/database path, as www-data won't be able to write to webroot
&& ln -s /pelican-data/.env ./.env \ && ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \ && ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& mkdir -p /pelican-data/storage \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage /var/www/html/storage/app/public/avatars \
# Create necessary directories # Create necessary directories
&& mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \ && mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \
# Finally allow www-data write permissions where necessary # Finally allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \ && chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord && chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord
# Configure Supervisor # Configure Supervisor

View File

@ -1,10 +1,10 @@
# ================================ # ================================
# Stage 0: Build PHP Base Image # Stage 0: Build PHP Base Image
# ================================ # ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN rm /usr/local/bin/install-php-extensions RUN rm /usr/local/bin/install-php-extensions

View File

@ -14,9 +14,9 @@ class NodeVersionsCheck extends Check
public function run(): Result public function run(): Result
{ {
$all = Node::query()->count(); $all = Node::all();
if ($all === 0) { if ($all->isEmpty()) {
$result = Result::make() $result = Result::make()
->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created')) ->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created'))
->shortSummary(trans('admin/health.results.nodeversions.no_nodes')); ->shortSummary(trans('admin/health.results.nodeversions.no_nodes'));
@ -25,16 +25,18 @@ class NodeVersionsCheck extends Check
return $result; return $result;
} }
$latestVersion = $this->versionService->latestWingsVersion(); $outdated = $all
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && !$this->versionService->isLatestWings($node->systemInformation()['version']))
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
->count(); ->count();
$all = $all->count();
$latestVersion = $this->versionService->latestWingsVersion();
$result = Result::make() $result = Result::make()
->meta([ ->meta([
'all' => $all, 'all' => $all,
'outdated' => $outdated, 'outdated' => $outdated,
'latestVersion' => $latestVersion,
]) ])
->shortSummary($outdated === 0 ? trans('admin/health.results.nodeversions.all_up_to_date') : trans('admin/health.results.nodeversions.outdated', ['outdated' => $outdated, 'all' => $all])); ->shortSummary($outdated === 0 ? trans('admin/health.results.nodeversions.all_up_to_date') : trans('admin/health.results.nodeversions.outdated', ['outdated' => $outdated, 'all' => $all]));

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands\Environment; namespace App\Console\Commands\Environment;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command class AppSettingsCommand extends Command
{ {
@ -21,9 +20,13 @@ class AppSettingsCommand extends Command
if (!config('app.key')) { if (!config('app.key')) {
$this->comment('Generating app key'); $this->comment('Generating app key');
Artisan::call('key:generate'); $this->call('key:generate');
} }
Artisan::call('filament:optimize'); $this->comment('Creating storage link');
$this->call('storage:link');
$this->comment('Caching components & icons');
$this->call('filament:optimize');
} }
} }

View File

@ -35,7 +35,7 @@ class RedisSetupCommand extends Command
{ {
$this->variables['CACHE_STORE'] = 'redis'; $this->variables['CACHE_STORE'] = 'redis';
$this->variables['QUEUE_CONNECTION'] = 'redis'; $this->variables['QUEUE_CONNECTION'] = 'redis';
$this->variables['SESSION_DRIVERS'] = 'redis'; $this->variables['SESSION_DRIVER'] = 'redis';
$this->requestRedisSettings(); $this->requestRedisSettings();

View File

@ -6,6 +6,7 @@ use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory; use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use SplFileInfo;
class CleanServiceBackupFilesCommand extends Command class CleanServiceBackupFilesCommand extends Command
{ {
@ -32,9 +33,10 @@ class CleanServiceBackupFilesCommand extends Command
*/ */
public function handle(): void public function handle(): void
{ {
/** @var SplFileInfo[] */
$files = $this->disk->files('services/.bak'); $files = $this->disk->files('services/.bak');
collect($files)->each(function (\SplFileInfo $file) { collect($files)->each(function ($file) {
$lastModified = Carbon::createFromTimestamp($this->disk->lastModified($file->getPath())); $lastModified = Carbon::createFromTimestamp($this->disk->lastModified($file->getPath()));
if ($lastModified->diffInMinutes(Carbon::now()) > self::BACKUP_THRESHOLD_MINUTES) { if ($lastModified->diffInMinutes(Carbon::now()) > self::BACKUP_THRESHOLD_MINUTES) {
$this->disk->delete($file->getPath()); $this->disk->delete($file->getPath());

View File

@ -0,0 +1,37 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum BackupStatus: string implements HasColor, HasIcon, HasLabel
{
case InProgress = 'in_progress';
case Successful = 'successful';
case Failed = 'failed';
public function getIcon(): string
{
return match ($this) {
self::InProgress => 'tabler-circle-dashed',
self::Successful => 'tabler-circle-check',
self::Failed => 'tabler-circle-x',
};
}
public function getColor(): string
{
return match ($this) {
self::InProgress => 'primary',
self::Successful => 'success',
self::Failed => 'danger',
};
}
public function getLabel(): string
{
return str($this->value)->headline();
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum ConsoleWidgetPosition: string
{
case Top = 'top';
case AboveConsole = 'above_console';
case BelowConsole = 'below_console';
case Bottom = 'bottom';
}

View File

@ -62,7 +62,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
self::Removing => 'warning', self::Removing => 'warning',
self::Missing => 'danger', self::Missing => 'danger',
self::Stopping => 'warning', self::Stopping => 'warning',
self::Offline => 'gray', self::Offline => 'danger',
}; };
} }

View File

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions\Repository;
use Exception;
class FileNotEditableException extends Exception {}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Extensions\Avatar;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
abstract class AvatarProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
public static function getProvider(string $id): ?self
{
return Arr::get(static::$providers, $id);
}
/**
* @return array<string, static>
*/
public static function getAll(): array
{
return static::$providers;
}
public function __construct()
{
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
abstract public function get(User $user): ?string;
public function getName(): string
{
return Str::title($this->getId());
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
class GravatarProvider extends AvatarProvider
{
public function getId(): string
{
return 'gravatar';
}
public function get(User $user): string
{
return 'https://gravatar.com/avatar/' . md5($user->email);
}
public static function register(): self
{
return new self();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
class UiAvatarsProvider extends AvatarProvider
{
public function getId(): string
{
return 'uiavatars';
}
public function getName(): string
{
return 'UI Avatars';
}
public function get(User $user): ?string
{
// UI Avatars is the default of filament so just return null here
return null;
}
public static function register(): self
{
return new self();
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Extensions;
use App\Models\DatabaseHost;
class DynamicDatabaseConnection
{
public const DB_CHARSET = 'utf8';
public const DB_COLLATION = 'utf8_unicode_ci';
public const DB_DRIVER = 'mysql';
/**
* Adds a dynamic database connection entry to the runtime config.
*/
public function set(string $connection, DatabaseHost|int $host, string $database = 'mysql'): void
{
if (!$host instanceof DatabaseHost) {
$host = DatabaseHost::query()->findOrFail($host);
}
config()->set('database.connections.' . $connection, [
'driver' => self::DB_DRIVER,
'host' => $host->host,
'port' => $host->port,
'database' => $database,
'username' => $host->username,
'password' => $host->password,
'charset' => self::DB_CHARSET,
'collation' => self::DB_COLLATION,
]);
}
}

View File

@ -6,8 +6,8 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use SocialiteProviders\Discord\Provider; use SocialiteProviders\Discord\Provider;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@ -34,15 +34,15 @@ final class DiscordProvider extends OAuthProvider
Step::make('Register new Discord OAuth App') Step::make('Register new Discord OAuth App')
->schema([ ->schema([
Placeholder::make('') Placeholder::make('')
->content(new HtmlString('<p>Visit the <u><a href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</a></u> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b>, you will need them in the final step.</p>')), ->content(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</x-filament::link> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b> from the OAuth2 tab, you will need them in the final step.</p>'))),
Placeholder::make('') Placeholder::make('')
->content(new HtmlString('<p>Under <b>Redirects</b> add the below URL.</p>')), ->content(new HtmlString('<p>Under <b>Redirects</b> add the below URL.</p>')),
TextInput::make('_noenv_callback') TextInput::make('_noenv_callback')
->label('Redirect URL') ->label('Redirect URL')
->dehydrated() ->dehydrated()
->disabled() ->disabled()
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null) ->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn () => config('app.url') . (Str::endsWith(config('app.url'), '/') ? '' : '/') . 'auth/oauth/callback/discord'), ->formatStateUsing(fn () => url('/auth/oauth/callback/discord')),
]), ]),
], parent::getSetupSteps()); ], parent::getSetupSteps());
} }

View File

@ -6,8 +6,8 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GithubProvider extends OAuthProvider final class GithubProvider extends OAuthProvider
@ -28,13 +28,13 @@ final class GithubProvider extends OAuthProvider
Step::make('Register new Github OAuth App') Step::make('Register new Github OAuth App')
->schema([ ->schema([
Placeholder::make('') Placeholder::make('')
->content(new HtmlString('<p>Visit the <u><a href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</a></u>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>')), ->content(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
TextInput::make('_noenv_callback') TextInput::make('_noenv_callback')
->label('Authorization callback URL') ->label('Authorization callback URL')
->dehydrated() ->dehydrated()
->disabled() ->disabled()
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null) ->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->default(fn () => config('app.url') . (Str::endsWith(config('app.url'), '/') ? '' : '/') . 'auth/oauth/callback/github'), ->default(fn () => url('/auth/oauth/callback/github')),
Placeholder::make('') Placeholder::make('')
->content(new HtmlString('<p>When you filled all fields click on <b>Register application</b>.</p>')), ->content(new HtmlString('<p>When you filled all fields click on <b>Register application</b>.</p>')),
]), ]),

View File

@ -0,0 +1,76 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GitlabProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'gitlab';
}
public function getServiceConfig(): array
{
return array_merge(parent::getServiceConfig(), [
'host' => env('OAUTH_GITLAB_HOST'),
]);
}
public function getSettingsForm(): array
{
return array_merge(parent::getSettingsForm(), [
TextInput::make('OAUTH_GITLAB_HOST')
->label('Custom Host')
->placeholder('Only set a custom host if you are self hosting gitlab')
->columnSpan(2)
->url()
->autocomplete(false)
->default(env('OAUTH_GITLAB_HOST')),
]);
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Gitlab OAuth App')
->schema([
Placeholder::make('')
->content(new HtmlString(Blade::render('Check out the <x-filament::link href="https://docs.gitlab.com/integration/oauth_provider/" target="_blank">Gitlab docs</x-filament::link> on how to create the oauth app.'))),
TextInput::make('_noenv_callback')
->label('Redirect URI')
->dehydrated()
->disabled()
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->default(fn () => url('/auth/oauth/callback/gitlab')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-gitlab';
}
public function getHexColor(): string
{
return '#fca326';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -6,6 +6,7 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use SocialiteProviders\Steam\Provider; use SocialiteProviders\Steam\Provider;
@ -58,7 +59,7 @@ final class SteamProvider extends OAuthProvider
Step::make('Create API Key') Step::make('Create API Key')
->schema([ ->schema([
Placeholder::make('') Placeholder::make('')
->content(new HtmlString('Visit <u><a href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</a></u> to generate an API key.')), ->content(new HtmlString(Blade::render('Visit <x-filament::link href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</x-filament::link> to generate an API key.'))),
]), ]),
], parent::getSetupSteps()); ], parent::getSetupSteps());
} }

View File

@ -2,36 +2,13 @@
namespace App\Filament\Admin\Pages; namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Resources\NodeResource\Pages\CreateNode;
use App\Filament\Admin\Resources\NodeResource\Pages\ListNodes;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Server;
use App\Models\User;
use App\Services\Helpers\SoftwareVersionService; use App\Services\Helpers\SoftwareVersionService;
use Filament\Actions\CreateAction; use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Pages\Page;
class Dashboard extends Page class Dashboard extends BaseDashboard
{ {
protected static ?string $navigationIcon = 'tabler-layout-dashboard'; protected static ?string $navigationIcon = 'tabler-layout-dashboard';
protected static string $view = 'filament.pages.dashboard';
protected ?string $heading = '';
public function getTitle(): string
{
return trans('admin/dashboard.title');
}
public static function getNavigationLabel(): string
{
return trans('admin/dashboard.title');
}
protected static ?string $slug = '/';
private SoftwareVersionService $softwareVersionService; private SoftwareVersionService $softwareVersionService;
public function mount(SoftwareVersionService $softwareVersionService): void public function mount(SoftwareVersionService $softwareVersionService): void
@ -39,51 +16,18 @@ class Dashboard extends Page
$this->softwareVersionService = $softwareVersionService; $this->softwareVersionService = $softwareVersionService;
} }
public function getViewData(): array public function getColumns(): int
{ {
return [ return 1;
'inDevelopment' => config('app.version') === 'canary', }
'version' => $this->softwareVersionService->currentPanelVersion(),
'latestVersion' => $this->softwareVersionService->latestPanelVersion(),
'isLatest' => $this->softwareVersionService->isLatestPanel(),
'eggsCount' => Egg::query()->count(),
'nodesList' => ListNodes::getUrl(),
'nodesCount' => Node::query()->count(),
'serversCount' => Server::query()->count(),
'usersCount' => User::query()->count(),
'devActions' => [ public function getHeading(): string
CreateAction::make() {
->label(trans('admin/dashboard.sections.intro-developers.button_issues')) return trans('admin/dashboard.heading');
->icon('tabler-brand-github') }
->url('https://github.com/pelican-dev/panel/issues', true),
], public function getSubheading(): string
'updateActions' => [ {
CreateAction::make() return trans('admin/dashboard.version', ['version' => $this->softwareVersionService->currentPanelVersion()]);
->label(trans('admin/dashboard.sections.intro-update-available.heading'))
->icon('tabler-clipboard-text')
->url('https://pelican.dev/docs/panel/update', true)
->color('warning'),
],
'nodeActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-first-node.button_label'))
->icon('tabler-server-2')
->url(CreateNode::getUrl()),
],
'supportActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-support.button_donate'))
->icon('tabler-cash')
->url('https://pelican.dev/donate', true)
->color('success'),
],
'helpActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-help.button_docs'))
->icon('tabler-speedboat')
->url('https://pelican.dev/docs', true),
],
];
} }
} }

View File

@ -2,6 +2,7 @@
namespace App\Filament\Admin\Pages; namespace App\Filament\Admin\Pages;
use App\Extensions\Avatar\AvatarProvider;
use App\Extensions\Captcha\Providers\CaptchaProvider; use App\Extensions\Captcha\Providers\CaptchaProvider;
use App\Extensions\OAuth\Providers\OAuthProvider; use App\Extensions\OAuth\Providers\OAuthProvider;
use App\Models\Backup; use App\Models\Backup;
@ -134,26 +135,47 @@ class Settings extends Page implements HasForms
->default(env('APP_FAVICON', '/pelican.ico')) ->default(env('APP_FAVICON', '/pelican.ico'))
->placeholder('/pelican.ico'), ->placeholder('/pelican.ico'),
]), ]),
Toggle::make('APP_DEBUG') Group::make()
->label(trans('admin/setting.general.debug_mode')) ->columnSpan(2)
->inline(false) ->columns(4)
->onIcon('tabler-check') ->schema([
->offIcon('tabler-x') Toggle::make('APP_DEBUG')
->onColor('success') ->label(trans('admin/setting.general.debug_mode'))
->offColor('danger') ->inline(false)
->formatStateUsing(fn ($state): bool => (bool) $state) ->onIcon('tabler-check')
->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state)) ->offIcon('tabler-x')
->default(env('APP_DEBUG', config('app.debug'))), ->onColor('success')
ToggleButtons::make('FILAMENT_TOP_NAVIGATION') ->offColor('danger')
->label(trans('admin/setting.general.navigation')) ->formatStateUsing(fn ($state): bool => (bool) $state)
->inline() ->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state))
->options([ ->default(env('APP_DEBUG', config('app.debug'))),
false => trans('admin/setting.general.sidebar'), ToggleButtons::make('FILAMENT_TOP_NAVIGATION')
true => trans('admin/setting.general.topbar'), ->label(trans('admin/setting.general.navigation'))
]) ->inline()
->formatStateUsing(fn ($state): bool => (bool) $state) ->options([
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state)) false => trans('admin/setting.general.sidebar'),
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))), true => trans('admin/setting.general.topbar'),
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
Select::make('FILAMENT_AVATAR_PROVIDER')
->label(trans('admin/setting.general.avatar_provider'))
->native(false)
->options(collect(AvatarProvider::getAll())->mapWithKeys(fn ($provider) => [$provider->getId() => $provider->getName()]))
->selectablePlaceholder(false)
->default(env('FILAMENT_AVATAR_PROVIDER', config('panel.filament.avatar-provider'))),
Toggle::make('FILAMENT_UPLOADABLE_AVATARS')
->label(trans('admin/setting.general.uploadable_avatars'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state) => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_UPLOADABLE_AVATARS', (bool) $state))
->default(env('FILAMENT_UPLOADABLE_AVATARS', config('panel.filament.uploadable-avatars'))),
]),
ToggleButtons::make('PANEL_USE_BINARY_PREFIX') ToggleButtons::make('PANEL_USE_BINARY_PREFIX')
->label(trans('admin/setting.general.unit_prefix')) ->label(trans('admin/setting.general.unit_prefix'))
->inline() ->inline()
@ -308,7 +330,7 @@ class Settings extends Page implements HasForms
'mail.mailers.smtp.port' => config('mail.mailers.smtp.port'), 'mail.mailers.smtp.port' => config('mail.mailers.smtp.port'),
'mail.mailers.smtp.username' => config('mail.mailers.smtp.username'), 'mail.mailers.smtp.username' => config('mail.mailers.smtp.username'),
'mail.mailers.smtp.password' => config('mail.mailers.smtp.password'), 'mail.mailers.smtp.password' => config('mail.mailers.smtp.password'),
'mail.mailers.smtp.encryption' => config('mail.mailers.smtp.encryption'), 'mail.mailers.smtp.scheme' => config('mail.mailers.smtp.scheme'),
'mail.from.address' => config('mail.from.address'), 'mail.from.address' => config('mail.from.address'),
'mail.from.name' => config('mail.from.name'), 'mail.from.name' => config('mail.from.name'),
'services.mailgun.domain' => config('services.mailgun.domain'), 'services.mailgun.domain' => config('services.mailgun.domain'),
@ -324,7 +346,7 @@ class Settings extends Page implements HasForms
'mail.mailers.smtp.port' => $get('MAIL_PORT'), 'mail.mailers.smtp.port' => $get('MAIL_PORT'),
'mail.mailers.smtp.username' => $get('MAIL_USERNAME'), 'mail.mailers.smtp.username' => $get('MAIL_USERNAME'),
'mail.mailers.smtp.password' => $get('MAIL_PASSWORD'), 'mail.mailers.smtp.password' => $get('MAIL_PASSWORD'),
'mail.mailers.smtp.encryption' => $get('MAIL_SCHEME'), 'mail.mailers.smtp.scheme' => $get('MAIL_SCHEME'),
'mail.from.address' => $get('MAIL_FROM_ADDRESS'), 'mail.from.address' => $get('MAIL_FROM_ADDRESS'),
'mail.from.name' => $get('MAIL_FROM_NAME'), 'mail.from.name' => $get('MAIL_FROM_NAME'),
'services.mailgun.domain' => $get('MAILGUN_DOMAIN'), 'services.mailgun.domain' => $get('MAILGUN_DOMAIN'),
@ -388,22 +410,16 @@ class Settings extends Page implements HasForms
->revealable() ->revealable()
->default(env('MAIL_PASSWORD')), ->default(env('MAIL_PASSWORD')),
ToggleButtons::make('MAIL_SCHEME') ToggleButtons::make('MAIL_SCHEME')
->label(trans('admin/setting.mail.smtp.encryption')) ->label(trans('admin/setting.mail.smtp.scheme'))
->inline() ->inline()
->options([ ->options([
'tls' => trans('admin/setting.mail.smtp.tls'), 'smtp' => 'SMTP',
'ssl' => trans('admin/setting.mail.smtp.ssl'), 'smtps' => 'SMTPS',
'' => trans('admin/setting.mail.smtp.none'),
]) ])
->default(env('MAIL_SCHEME', config('mail.mailers.smtp.encryption', 'tls'))) ->default(env('MAIL_SCHEME', config('mail.mailers.smtp.scheme')))
->live() ->live()
->afterStateUpdated(function ($state, Set $set) { ->afterStateUpdated(function ($state, Set $set) {
$port = match ($state) { $set('MAIL_PORT', $state === 'smtps' ? 587 : 2525);
'tls' => 587,
'ssl' => 465,
default => 25,
};
$set('MAIL_PORT', $port);
}), }),
]), ]),
Section::make(trans('admin/setting.mail.mailgun.mailgun_title')) Section::make(trans('admin/setting.mail.mailgun.mailgun_title'))

View File

@ -4,14 +4,29 @@ namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource; use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Services\Databases\Hosts\HostCreationService; use App\Services\Databases\Hosts\HostCreationService;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use PDOException; use PDOException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class CreateDatabaseHost extends CreateRecord class CreateDatabaseHost extends CreateRecord
{ {
use HasWizard;
protected static string $resource = DatabaseHostResource::class; protected static string $resource = DatabaseHostResource::class;
protected static bool $canCreateAnother = false; protected static bool $canCreateAnother = false;
@ -23,18 +38,118 @@ class CreateDatabaseHost extends CreateRecord
$this->service = $service; $this->service = $service;
} }
protected function getHeaderActions(): array /** @return Step[] */
public function getSteps(): array
{ {
return [ return [
$this->getCreateFormAction()->formId('form'), Step::make(trans('admin/databasehost.setup.preparations'))
->columns()
->schema([
Placeholder::make('')
->content(trans('admin/databasehost.setup.note')),
Toggle::make('different_server')
->label(new HtmlString(trans('admin/databasehost.setup.different_server')))
->dehydrated(false)
->live()
->columnSpanFull()
->afterStateUpdated(fn ($state, Set $set) => $state ? $set('panel_ip', gethostbyname(str(config('app.url'))->replace(['http:', 'https:', '/'], ''))) : '127.0.0.1'),
Hidden::make('panel_ip')
->default('127.0.0.1')
->dehydrated(false),
TextInput::make('username')
->label(trans('admin/databasehost.username'))
->helperText(trans('admin/databasehost.username_help'))
->required()
->default('pelicanuser')
->maxLength(255),
TextInput::make('password')
->label(trans('admin/databasehost.password'))
->helperText(trans('admin/databasehost.password_help'))
->required()
->default(Str::password(16))
->password()
->revealable()
->maxLength(255),
])
->afterValidation(function (Get $get, Set $set) {
$set('create_user', "CREATE USER '{$get('username')}'@'{$get('panel_ip')}' IDENTIFIED BY '{$get('password')}';");
$set('assign_permissions', "GRANT ALL PRIVILEGES ON *.* TO '{$get('username')}'@'{$get('panel_ip')}' WITH GRANT OPTION;");
}),
Step::make(trans('admin/databasehost.setup.database_setup'))
->schema([
Fieldset::make(trans('admin/databasehost.setup.database_user'))
->schema([
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.cli_login')))
->columnSpanFull(),
TextInput::make('create_user')
->label(trans('admin/databasehost.setup.command_create_user'))
->default(fn (Get $get) => "CREATE USER '{$get('username')}'@'{$get('panel_ip')}' IDENTIFIED BY '{$get('password')}';")
->disabled()
->dehydrated(false)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull(),
TextInput::make('assign_permissions')
->label(trans('admin/databasehost.setup.command_assign_permissions'))
->default(fn (Get $get) => "GRANT ALL PRIVILEGES ON *.* TO '{$get('username')}'@'{$get('panel_ip')}' WITH GRANT OPTION;")
->disabled()
->dehydrated(false)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull(),
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.cli_exit')))
->columnSpanFull(),
]),
Fieldset::make(trans('admin/databasehost.setup.external_access'))
->schema([
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.allow_external_access')))
->columnSpanFull(),
]),
]),
Step::make(trans('admin/databasehost.setup.panel_setup'))
->columns([
'default' => 2,
'lg' => 3,
])
->schema([
TextInput::make('host')
->columnSpan(2)
->label(trans('admin/databasehost.host'))
->helperText(trans('admin/databasehost.host_help'))
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Set $set) => $set('name', $state))
->maxLength(255),
TextInput::make('port')
->label(trans('admin/databasehost.port'))
->helperText(trans('admin/databasehost.port_help'))
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
TextInput::make('max_databases')
->label(trans('admin/databasehost.max_database'))
->helpertext(trans('admin/databasehost.max_databases_help'))
->placeholder(trans('admin/databasehost.unlimited'))
->numeric(),
TextInput::make('name')
->label(trans('admin/databasehost.display_name'))
->helperText(trans('admin/databasehost.display_name_help'))
->required()
->maxLength(60),
Select::make('node_ids')
->multiple()
->searchable()
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name'),
]),
]; ];
} }
protected function getFormActions(): array
{
return [];
}
protected function handleRecordCreation(array $data): Model protected function handleRecordCreation(array $data): Model
{ {
try { try {

View File

@ -21,7 +21,7 @@ class EggResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return trans('admin/dashboard.server'); return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
} }
public static function getNavigationLabel(): string public static function getNavigationLabel(): string

View File

@ -243,6 +243,7 @@ class CreateEgg extends CreateRecord
->default('ghcr.io/pelican-eggs/installers:debian'), ->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry') Select::make('script_entry')
->label(trans('admin/egg.script_entry')) ->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->default('bash') ->default('bash')
->options(['bash', 'ash', '/bin/bash']) ->options(['bash', 'ash', '/bin/bash'])

View File

@ -235,6 +235,7 @@ class EditEgg extends EditRecord
->placeholder('ghcr.io/pelican-eggs/installers:debian'), ->placeholder('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry') Select::make('script_entry')
->label(trans('admin/egg.script_entry')) ->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->options(['bash', 'ash', '/bin/bash']) ->options(['bash', 'ash', '/bin/bash'])
->required(), ->required(),

View File

@ -32,7 +32,7 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return trans('admin/dashboard.server'); return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string

View File

@ -3,6 +3,7 @@
namespace App\Filament\Admin\Resources\NodeResource\Pages; namespace App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource; use App\Filament\Admin\Resources\NodeResource;
use App\Models\Node;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
@ -44,7 +45,8 @@ class CreateNode extends CreateRecord
->required() ->required()
->autofocus() ->autofocus()
->live(debounce: 1500) ->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure()) ->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain')) ->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') ->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) { ->helperText(function ($state) {

View File

@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource; use App\Filament\Admin\Resources\NodeResource;
use App\Models\Node; use App\Models\Node;
use App\Repositories\Daemon\DaemonConfigurationRepository;
use App\Services\Helpers\SoftwareVersionService; use App\Services\Helpers\SoftwareVersionService;
use App\Services\Nodes\NodeAutoDeployService; use App\Services\Nodes\NodeAutoDeployService;
use App\Services\Nodes\NodeUpdateService; use App\Services\Nodes\NodeUpdateService;
@ -26,7 +27,7 @@ use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Alignment; use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@ -34,12 +35,13 @@ class EditNode extends EditRecord
{ {
protected static string $resource = NodeResource::class; protected static string $resource = NodeResource::class;
private bool $errored = false; private DaemonConfigurationRepository $daemonConfigurationRepository;
private NodeUpdateService $nodeUpdateService; private NodeUpdateService $nodeUpdateService;
public function boot(NodeUpdateService $nodeUpdateService): void public function boot(DaemonConfigurationRepository $daemonConfigurationRepository, NodeUpdateService $nodeUpdateService): void
{ {
$this->daemonConfigurationRepository = $daemonConfigurationRepository;
$this->nodeUpdateService = $nodeUpdateService; $this->nodeUpdateService = $nodeUpdateService;
} }
@ -108,7 +110,8 @@ class EditNode extends EditRecord
->required() ->required()
->autofocus() ->autofocus()
->live(debounce: 1500) ->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure()) ->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain')) ->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') ->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) { ->helperText(function ($state) {
@ -596,39 +599,6 @@ class EditNode extends EditRecord
return $data; return $data;
} }
protected function handleRecordUpdate(Model $record, array $data): Model
{
if (!$record instanceof Node) {
return $record;
}
try {
$record = $this->nodeUpdateService->handle($record, $data);
} catch (Exception $exception) {
$this->errored = true;
Notification::make()
->title(trans('admin/node.error_connecting', ['node' => $record->name]))
->body(trans('admin/node.error_connecting_description'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
return parent::handleRecordUpdate($record, $data);
}
protected function getSavedNotification(): ?Notification
{
if ($this->errored) {
return null;
}
return parent::getSavedNotification();
}
protected function getFormActions(): array protected function getFormActions(): array
{ {
return []; return [];
@ -647,6 +617,31 @@ class EditNode extends EditRecord
protected function afterSave(): void protected function afterSave(): void
{ {
$this->fillForm(); $this->fillForm();
/** @var Node $node */
$node = $this->record;
$changed = collect($node->getChanges())->except(['updated_at', 'name', 'tags', 'public', 'maintenance_mode', 'memory', 'memory_overallocate', 'disk', 'disk_overallocate', 'cpu', 'cpu_overallocate'])->all();
try {
if ($changed) {
$this->daemonConfigurationRepository->setNode($node)->update($node);
}
parent::getSavedNotification()?->send();
} catch (ConnectionException) {
Notification::make()
->title(trans('admin/node.error_connecting', ['node' => $node->name]))
->body(trans('admin/node.error_connecting_description'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
}
protected function getSavedNotification(): ?Notification
{
return null;
} }
protected function getColumnSpan(): ?int protected function getColumnSpan(): ?int

View File

@ -23,7 +23,7 @@ class NodeCpuChart extends ChartWidget
$cpu = collect(cache()->get("nodes.{$this->node->id}.cpu_percent")) $cpu = collect(cache()->get("nodes.{$this->node->id}.cpu_percent"))
->slice(-10) ->slice(-10)
->map(fn ($value, $key) => [ ->map(fn ($value, $key) => [
'cpu' => Number::format($value * $threads, maxPrecision: 2), 'cpu' => round($value * $threads, 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'), 'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
]) ])
->all(); ->all();

View File

@ -20,7 +20,7 @@ class NodeMemoryChart extends ChartWidget
{ {
$memUsed = collect(cache()->get("nodes.{$this->node->id}.memory_used"))->slice(-10) $memUsed = collect(cache()->get("nodes.{$this->node->id}.memory_used"))->slice(-10)
->map(fn ($value, $key) => [ ->map(fn ($value, $key) => [
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2), '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, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
]) ])
->all(); ->all();

View File

@ -4,7 +4,6 @@ namespace App\Filament\Admin\Resources\NodeResource\Widgets;
use App\Models\Node; use App\Models\Node;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
class NodeStorageChart extends ChartWidget class NodeStorageChart extends ChartWidget
{ {
@ -46,8 +45,8 @@ class NodeStorageChart extends ChartWidget
$unused = $total - $used; $unused = $total - $used;
$used = Number::format($used, maxPrecision: 2); $used = round($used, 2);
$unused = Number::format($unused, maxPrecision: 2); $unused = round($unused, 2);
return [ return [
'datasets' => [ 'datasets' => [

View File

@ -2,8 +2,6 @@
namespace App\Filament\Admin\Resources; namespace App\Filament\Admin\Resources;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Filament\Admin\Resources\RoleResource\Pages; use App\Filament\Admin\Resources\RoleResource\Pages;
use App\Models\Role; use App\Models\Role;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
@ -50,7 +48,7 @@ class RoleResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return trans('admin/dashboard.user'); return config('panel.filament.top-navigation', false) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
@ -95,32 +93,16 @@ class RoleResource extends Resource
public static function form(Form $form): Form public static function form(Form $form): Form
{ {
$permissions = []; $permissionSections = [];
foreach (RolePermissionModels::cases() as $model) { foreach (Role::getPermissionList() as $model => $permissions) {
$options = []; $options = [];
foreach (RolePermissionPrefixes::cases() as $prefix) { foreach ($permissions as $permission) {
$options[$prefix->value . ' ' . strtolower($model->value)] = Str::headline($prefix->value); $options[$permission . ' ' . strtolower($model)] = Str::headline($permission);
} }
if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) { $permissionSections[] = self::makeSection($model, $options);
foreach (Role::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) {
$options[$permission . ' ' . strtolower($model->value)] = Str::headline($permission);
}
}
$permissions[] = self::makeSection($model->value, $options);
}
foreach (Role::SPECIAL_PERMISSIONS as $model => $prefixes) {
$options = [];
foreach ($prefixes as $prefix) {
$options[$prefix . ' ' . strtolower($model)] = Str::headline($prefix);
}
$permissions[] = self::makeSection($model, $options);
} }
return $form return $form
@ -137,7 +119,7 @@ class RoleResource extends Resource
->hidden(), ->hidden(),
Fieldset::make(trans('admin/role.permissions')) Fieldset::make(trans('admin/role.permissions'))
->columns(3) ->columns(3)
->schema($permissions) ->schema($permissionSections)
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), ->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Placeholder::make('permissions') Placeholder::make('permissions')
->label(trans('admin/role.permissions')) ->label(trans('admin/role.permissions'))

View File

@ -31,7 +31,7 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return trans('admin/dashboard.server'); return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string

View File

@ -426,7 +426,7 @@ class CreateServer extends CreateRecord
Repeater::make('server_variables') Repeater::make('server_variables')
->label('') ->label('')
->relationship('serverVariables') ->relationship('serverVariables', fn (Builder $query) => $query->orderByPowerJoins('variable.sort'))
->saveRelationshipsBeforeChildrenUsing(null) ->saveRelationshipsBeforeChildrenUsing(null)
->saveRelationshipsUsing(null) ->saveRelationshipsUsing(null)
->grid(2) ->grid(2)
@ -792,6 +792,7 @@ class CreateServer extends CreateRecord
]), ]),
KeyValue::make('docker_labels') KeyValue::make('docker_labels')
->live()
->label('Container Labels') ->label('Container Labels')
->keyLabel(trans('admin/server.title')) ->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description')) ->valueLabel(trans('admin/server.description'))

View File

@ -2,16 +2,19 @@
namespace App\Filament\Admin\Resources\ServerResource\Pages; namespace App\Filament\Admin\Resources\ServerResource\Pages;
use App\Enums\ServerState;
use App\Enums\SuspendAction; use App\Enums\SuspendAction;
use App\Filament\Admin\Resources\ServerResource; use App\Filament\Admin\Resources\ServerResource;
use App\Filament\Admin\Resources\ServerResource\RelationManagers\AllocationsRelationManager; use App\Filament\Admin\Resources\ServerResource\RelationManagers\AllocationsRelationManager;
use App\Filament\Components\Forms\Actions\PreviewStartupAction; use App\Filament\Components\Forms\Actions\PreviewStartupAction;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction; use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Server\Pages\Console; use App\Filament\Server\Pages\Console;
use App\Models\Allocation;
use App\Models\Database; use App\Models\Database;
use App\Models\DatabaseHost; use App\Models\DatabaseHost;
use App\Models\Egg; use App\Models\Egg;
use App\Models\Mount; use App\Models\Mount;
use App\Models\Node;
use App\Models\Server; use App\Models\Server;
use App\Models\ServerVariable; use App\Models\ServerVariable;
use App\Models\User; use App\Models\User;
@ -28,8 +31,10 @@ use Closure;
use Exception; use Exception;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Hidden;
@ -49,9 +54,10 @@ use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\HtmlString;
use LogicException; use LogicException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@ -59,8 +65,6 @@ class EditServer extends EditRecord
{ {
protected static string $resource = ServerResource::class; protected static string $resource = ServerResource::class;
private bool $errored = false;
private DaemonServerRepository $daemonServerRepository; private DaemonServerRepository $daemonServerRepository;
public function boot(DaemonServerRepository $daemonServerRepository): void public function boot(DaemonServerRepository $daemonServerRepository): void
@ -482,6 +486,7 @@ class EditServer extends EditRecord
]), ]),
KeyValue::make('docker_labels') KeyValue::make('docker_labels')
->live()
->label(trans('admin/server.container_labels')) ->label(trans('admin/server.container_labels'))
->keyLabel(trans('admin/server.title')) ->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description')) ->valueLabel(trans('admin/server.description'))
@ -591,7 +596,7 @@ class EditServer extends EditRecord
]); ]);
} }
return $query; return $query->orderByPowerJoins('variable.sort');
}) })
->grid() ->grid()
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array { ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
@ -731,7 +736,7 @@ class EditServer extends EditRecord
->deletable(false) ->deletable(false)
->addable(false) ->addable(false)
->columnSpan(4), ->columnSpan(4),
Forms\Components\Actions::make([ FormActions::make([
Action::make('createDatabase') Action::make('createDatabase')
->authorize(fn () => auth()->user()->can('create database')) ->authorize(fn () => auth()->user()->can('create database'))
->disabled(fn () => DatabaseHost::query()->count() < 1) ->disabled(fn () => DatabaseHost::query()->count() < 1)
@ -799,14 +804,50 @@ class EditServer extends EditRecord
Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ FormActions::make([
Action::make('toggleInstall') Action::make('toggleInstall')
->label(trans('admin/server.toggle_install')) ->label(trans('admin/server.toggle_install'))
->disabled(fn (Server $server) => $server->isSuspended()) ->disabled(fn (Server $server) => $server->isSuspended())
->action(function (ToggleInstallService $service, Server $server) { ->modal(fn (Server $server) => $server->status === ServerState::InstallFailed)
$service->handle($server); ->modalHeading(trans('admin/server.toggle_install_failed_header'))
->modalDescription(trans('admin/server.toggle_install_failed_desc'))
->modalSubmitActionLabel(trans('admin/server.reinstall'))
->action(function (ToggleInstallService $toggleService, ReinstallServerService $reinstallService, Server $server) {
if ($server->status === ServerState::InstallFailed) {
try {
$reinstallService->handle($server);
$this->refreshFormData(['status', 'docker']); Notification::make()
->title(trans('admin/server.notifications.reinstall_started'))
->success()
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
} else {
try {
$toggleService->handle($server);
Notification::make()
->title(trans('admin/server.notifications.install_toggled'))
->success()
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception $exception) {
Notification::make()
->title(trans('admin/server.notifications.install_toggle_failed'))
->body($exception->getMessage())
->danger()
->send();
}
}
}), }),
])->fullWidth(), ])->fullWidth(),
ToggleButtons::make('') ToggleButtons::make('')
@ -815,7 +856,7 @@ class EditServer extends EditRecord
Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ FormActions::make([
Action::make('toggleSuspend') Action::make('toggleSuspend')
->label(trans('admin/server.suspend')) ->label(trans('admin/server.suspend'))
->color('warning') ->color('warning')
@ -823,12 +864,20 @@ class EditServer extends EditRecord
->action(function (SuspensionService $suspensionService, Server $server) { ->action(function (SuspensionService $suspensionService, Server $server) {
try { try {
$suspensionService->handle($server, SuspendAction::Suspend); $suspensionService->handle($server, SuspendAction::Suspend);
} catch (\Exception $exception) {
Notification::make()->warning()->title(trans('admin/server.notifications.server_suspension'))->body($exception->getMessage())->send();
}
Notification::make()->success()->title(trans('admin/server.notifications.server_suspended'))->send();
$this->refreshFormData(['status', 'docker']); Notification::make()
->success()
->title(trans('admin/server.notifications.server_suspended'))
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->send();
}
}), }),
Action::make('toggleUnsuspend') Action::make('toggleUnsuspend')
->label(trans('admin/server.unsuspend')) ->label(trans('admin/server.unsuspend'))
@ -837,12 +886,20 @@ class EditServer extends EditRecord
->action(function (SuspensionService $suspensionService, Server $server) { ->action(function (SuspensionService $suspensionService, Server $server) {
try { try {
$suspensionService->handle($server, SuspendAction::Unsuspend); $suspensionService->handle($server, SuspendAction::Unsuspend);
} catch (\Exception $exception) {
Notification::make()->warning()->title(trans('admin/server.notifications.server_suspension'))->body($exception->getMessage())->send();
}
Notification::make()->success()->title(trans('admin/server.notifications.server_unsuspended'))->send();
$this->refreshFormData(['status', 'docker']); Notification::make()
->success()
->title(trans('admin/server.notifications.server_unsuspended'))
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->send();
}
}), }),
])->fullWidth(), ])->fullWidth(),
ToggleButtons::make('') ToggleButtons::make('')
@ -855,42 +912,36 @@ class EditServer extends EditRecord
Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ FormActions::make([
Action::make('transfer') Action::make('transfer')
->label(trans('admin/server.transfer')) ->label(trans('admin/server.transfer'))
// ->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, [])) ->disabled(fn (Server $server) => Node::count() <= 1 || $server->isInConflictState())
->disabled() //TODO! ->modalheading(trans('admin/server.transfer'))
->form([ //TODO! ->form($this->transferServer())
Select::make('newNode') ->action(function (TransferServerService $transfer, Server $server, $data) {
->label('New Node') try {
->required() $transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []));
->options([
true => 'on', Notification::make()
false => 'off', ->title('Transfer started')
]), ->success()
Select::make('newMainAllocation') ->send();
->label('New Main Allocation') } catch (Exception $exception) {
->required() Notification::make()
->options([ ->title('Transfer failed')
true => 'on', ->body($exception->getMessage())
false => 'off', ->danger()
]), ->send();
Select::make('newAdditionalAllocation') }
->label('New Additional Allocations') }),
->options([
true => 'on',
false => 'off',
]),
])
->modalheading(trans('admin/server.transfer')),
])->fullWidth(), ])->fullWidth(),
ToggleButtons::make('') ToggleButtons::make('')
->hint(trans('admin/server.transfer_help')), ->hint(new HtmlString(trans('admin/server.transfer_help'))),
]), ]),
Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ FormActions::make([
Action::make('reinstall') Action::make('reinstall')
->label(trans('admin/server.reinstall')) ->label(trans('admin/server.reinstall'))
->color('danger') ->color('danger')
@ -898,7 +949,24 @@ class EditServer extends EditRecord
->modalHeading(trans('admin/server.reinstall_modal_heading')) ->modalHeading(trans('admin/server.reinstall_modal_heading'))
->modalDescription(trans('admin/server.reinstall_modal_description')) ->modalDescription(trans('admin/server.reinstall_modal_description'))
->disabled(fn (Server $server) => $server->isSuspended()) ->disabled(fn (Server $server) => $server->isSuspended())
->action(fn (ReinstallServerService $service, Server $server) => $service->handle($server)), ->action(function (ReinstallServerService $service, Server $server) {
try {
$service->handle($server);
Notification::make()
->title(trans('admin/server.notifications.reinstall_started'))
->success()
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
}),
])->fullWidth(), ])->fullWidth(),
ToggleButtons::make('') ToggleButtons::make('')
->hint(trans('admin/server.reinstall_help')), ->hint(trans('admin/server.reinstall_help')),
@ -909,32 +977,86 @@ class EditServer extends EditRecord
]); ]);
} }
protected function transferServer(Form $form): Form /** @return Component[] */
protected function transferServer(): array
{ {
return $form return [
->columns() Select::make('node_id')
->schema([ ->label(trans('admin/server.node'))
Select::make('toNode') ->prefixIcon('tabler-server-2')
->label('New Node'), ->selectablePlaceholder(false)
TextInput::make('newAllocation') ->default(fn (Server $server) => Node::whereNot('id', $server->node->id)->first()?->id)
->label('Allocation'), ->required()
]); ->live()
->options(fn (Server $server) => Node::whereNot('id', $server->node->id)->pluck('name', 'id')->all()),
Select::make('allocation_id')
->label(trans('admin/server.primary_allocation'))
->required()
->prefixIcon('tabler-network')
->disabled(fn (Get $get) => !$get('node_id'))
->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address]))
->searchable(['ip', 'port', 'ip_alias'])
->placeholder(trans('admin/server.select_allocation')),
Select::make('allocation_additional')
->label(trans('admin/server.additional_allocations'))
->multiple()
->prefixIcon('tabler-network')
->disabled(fn (Get $get) => !$get('node_id'))
->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->when($get('allocation_id'), fn ($query) => $query->whereNot('id', $get('allocation_id')))->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address]))
->searchable(['ip', 'port', 'ip_alias'])
->placeholder(trans('admin/server.select_additional')),
];
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
/** @var Server $server */
$server = $this->getRecord();
$canForceDelete = cache()->get("servers.$server->uuid.canForceDelete", false);
return [ return [
Actions\Action::make('Delete') Actions\Action::make('Delete')
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger') ->color('danger')
->label(trans('filament-actions::delete.single.modal.actions.delete.label')) ->label(trans('filament-actions::delete.single.label'))
->modalHeading(trans('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->requiresConfirmation() ->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) { ->action(function (Server $server, ServerDeletionService $service) {
$service->handle($server); try {
$service->handle($server);
return redirect(ListServers::getUrl(panel: 'admin')); return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {
cache()->put("servers.$server->uuid.canForceDelete", true, now()->addMinutes(5));
Notification::make()
->title(trans('admin/server.notifications.error_server_delete'))
->body(trans('admin/server.notifications.error_server_delete_body'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
}) })
->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('ForceDelete')
->color('danger')
->label(trans('filament-actions::force-delete.single.label'))
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::force-delete.single.label'))
->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) {
try {
$service->withForce()->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {
cache()->forget("servers.$server->uuid.canForceDelete");
}
})
->visible(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)), ->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console') Actions\Action::make('console')
->label(trans('admin/server.console')) ->label(trans('admin/server.console'))
@ -961,39 +1083,32 @@ class EditServer extends EditRecord
return $data; return $data;
} }
protected function handleRecordUpdate(Model $record, array $data): Model protected function afterSave(): void
{ {
if (!$record instanceof Server) { /** @var Server $server */
return $record; $server = $this->record;
}
/** @var Server $record */ $changed = collect($server->getChanges())->except(['updated_at', 'name', 'owner_id', 'condition', 'description', 'external_id', 'tags', 'cpu_pinning', 'allocation_limit', 'database_limit', 'backup_limit', 'skip_scripts'])->all();
$record = parent::handleRecordUpdate($record, $data);
try { try {
$this->daemonServerRepository->setServer($record)->sync(); if ($changed) {
$this->daemonServerRepository->setServer($server)->sync();
}
parent::getSavedNotification()?->send();
} catch (ConnectionException) { } catch (ConnectionException) {
$this->errored = true;
Notification::make() Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $record->node->name])) ->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.notifications.error_connecting_description')) ->body(trans('admin/server.notifications.error_connecting_description'))
->color('warning') ->color('warning')
->icon('tabler-database') ->icon('tabler-database')
->warning() ->warning()
->send(); ->send();
} }
return $record;
} }
protected function getSavedNotification(): ?Notification protected function getSavedNotification(): ?Notification
{ {
if ($this->errored) { return null;
return null;
}
return parent::getSavedNotification();
} }
public function getRelationManagers(): array public function getRelationManagers(): array

View File

@ -12,14 +12,16 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Forms\Set; use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Support\Exceptions\Halt;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AssociateAction; use Filament\Tables\Actions\AssociateAction;
use Filament\Tables\Actions\CreateAction; use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DissociateBulkAction;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn; use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
/** /**
* @method Server getOwnerRecord() * @method Server getOwnerRecord()
@ -96,10 +98,21 @@ class AllocationsRelationManager extends RelationManager
->recordSelectSearchColumns(['ip', 'port']) ->recordSelectSearchColumns(['ip', 'port'])
->label(trans('admin/server.add_allocation')), ->label(trans('admin/server.add_allocation')),
]) ])
->bulkActions([ ->groupedBulkActions([
Tables\Actions\BulkActionGroup::make([ DissociateBulkAction::make()
Tables\Actions\DissociateBulkAction::make(), ->before(function (DissociateBulkAction $action, Collection $records) {
]), $records = $records->filter(function ($allocation) {
/** @var Allocation $allocation */
return $allocation->id !== $this->getOwnerRecord()->allocation_id;
});
if ($records->isEmpty()) {
$action->failureNotificationTitle(trans('admin/server.notifications.dissociate_primary'))->failure();
throw new Halt();
}
return $records;
}),
]); ]);
} }
} }

View File

@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\UserResource\Pages;
use App\Filament\Admin\Resources\UserResource\RelationManagers; use App\Filament\Admin\Resources\UserResource\RelationManagers;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
@ -17,6 +18,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class UserResource extends Resource class UserResource extends Resource
{ {
@ -43,7 +45,7 @@ class UserResource extends Resource
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
{ {
return trans('admin/dashboard.user'); return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.user');
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
@ -58,8 +60,9 @@ class UserResource extends Resource
ImageColumn::make('picture') ImageColumn::make('picture')
->visibleFrom('lg') ->visibleFrom('lg')
->label('') ->label('')
->extraImgAttributes(['class' => 'rounded-full']) ->circular()
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))), ->alignCenter()
->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username') TextColumn::make('username')
->label(trans('admin/user.username')), ->label(trans('admin/user.username')),
TextColumn::make('email') TextColumn::make('email')
@ -120,17 +123,26 @@ class UserResource extends Resource
->hintIconTooltip(fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null) ->hintIconTooltip(fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null)
->password(), ->password(),
CheckboxList::make('roles') CheckboxList::make('roles')
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id) ->hidden(fn (User $user) => $user->isRootAdmin())
->relationship('roles', 'name') ->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id))
->saveRelationshipsUsing(function (User $user, array $state) { ->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role))))
$roles = collect($state)->map(fn ($role) => Role::findById($role))->filter(fn ($role) => $role->id !== Role::getRootAdmin()->id);
$user->syncRoles($roles);
})
->dehydrated() ->dehydrated()
->label(trans('admin/user.admin_roles')) ->label(trans('admin/user.admin_roles'))
->columnSpanFull() ->columnSpanFull()
->bulkToggleable(false), ->bulkToggleable(false),
CheckboxList::make('root_admin_role')
->visible(fn (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(),
]); ]);
} }

View File

@ -33,6 +33,13 @@ class CreateUser extends CreateRecord
return []; return [];
} }
protected function prepareForValidation($attributes): array
{
$attributes['data']['email'] = mb_strtolower($attributes['data']['email']);
return $attributes;
}
protected function handleRecordCreation(array $data): Model protected function handleRecordCreation(array $data): Model
{ {
$data['root_admin'] = false; $data['root_admin'] = false;

View File

@ -0,0 +1,32 @@
<?php
namespace App\Filament\Admin\Widgets;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class CanaryWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.canary-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 1;
public static function canView(): bool
{
return config('app.version') === 'canary';
}
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-developers.button_issues'))
->icon('tabler-brand-github')
->url('https://github.com/pelican-dev/panel/issues', true),
],
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Filament\Admin\Widgets;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class HelpWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.help-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 4;
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-help.button_docs'))
->icon('tabler-speedboat')
->url('https://pelican.dev/docs', true),
],
];
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Filament\Admin\Resources\NodeResource\Pages\CreateNode;
use App\Models\Node;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class NoNodesWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.no-nodes-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 2;
public static function canView(): bool
{
return Node::count() <= 0;
}
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-first-node.button_label'))
->icon('tabler-server-2')
->url(CreateNode::getUrl()),
],
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Filament\Admin\Widgets;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class SupportWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.support-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 3;
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-support.button_donate'))
->icon('tabler-cash')
->url('https://pelican.dev/donate', true)
->color('success'),
],
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Services\Helpers\SoftwareVersionService;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class UpdateWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.update-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 0;
private SoftwareVersionService $softwareVersionService;
public function mount(SoftwareVersionService $softwareVersionService): void
{
$this->softwareVersionService = $softwareVersionService;
}
public function getViewData(): array
{
return [
'version' => $this->softwareVersionService->currentPanelVersion(),
'latestVersion' => $this->softwareVersionService->latestPanelVersion(),
'isLatest' => $this->softwareVersionService->isLatestPanel(),
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-update-available.heading'))
->icon('tabler-clipboard-text')
->url('https://pelican.dev/docs/panel/update', true)
->color('warning'),
],
];
}
}

View File

@ -2,13 +2,16 @@
namespace App\Filament\App\Resources\ServerResource\Pages; namespace App\Filament\App\Resources\ServerResource\Pages;
use App\Enums\ServerResourceType;
use App\Filament\App\Resources\ServerResource; use App\Filament\App\Resources\ServerResource;
use App\Filament\Components\Tables\Columns\ServerEntryColumn; use App\Filament\Components\Tables\Columns\ServerEntryColumn;
use App\Filament\Server\Pages\Console; use App\Filament\Server\Pages\Console;
use App\Models\Server; use App\Models\Server;
use Filament\Resources\Components\Tab; use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\ColumnGroup;
use Filament\Tables\Columns\Layout\Stack; use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -17,37 +20,111 @@ class ListServers extends ListRecords
{ {
protected static string $resource = ServerResource::class; protected static string $resource = ServerResource::class;
public const DANGER_THRESHOLD = 0.9;
public const WARNING_THRESHOLD = 0.7;
public function table(Table $table): Table public function table(Table $table): Table
{ {
$baseQuery = auth()->user()->accessibleServers(); $baseQuery = auth()->user()->accessibleServers();
$viewOne = [
TextColumn::make('condition')
->label('')
->default('unknown')
->wrap()
->badge()
->alignCenter()
->tooltip(fn (Server $server) => $server->formatResource('uptime', type: ServerResourceType::Time))
->icon(fn (Server $server) => $server->condition->getIcon())
->color(fn (Server $server) => $server->condition->getColor()),
];
$viewTwo = [
TextColumn::make('name')
->label('')
->size('md')
->searchable(),
TextColumn::make('')
->label('')
->badge()
->copyable(request()->isSecure())
->copyMessage(fn (Server $server, string $state) => 'Copied ' . $server->allocation->address)
->state(fn (Server $server) => $server->allocation->address),
];
$viewThree = [
TextColumn::make('cpuUsage')
->label('')
->icon('tabler-cpu')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('cpu', limit: true, type: ServerResourceType::Percentage, precision: 0))
->state(fn (Server $server) => $server->formatResource('cpu_absolute', type: ServerResourceType::Percentage))
->color(fn (Server $server) => $this->getResourceColor($server, 'cpu')),
TextColumn::make('memoryUsage')
->label('')
->icon('tabler-memory')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('memory', limit: true))
->state(fn (Server $server) => $server->formatResource('memory_bytes'))
->color(fn (Server $server) => $this->getResourceColor($server, 'memory')),
TextColumn::make('diskUsage')
->label('')
->icon('tabler-device-floppy')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('disk', limit: true))
->state(fn (Server $server) => $server->formatResource('disk_bytes'))
->color(fn (Server $server) => $this->getResourceColor($server, 'disk')),
];
return $table return $table
->paginated(false) ->paginated(false)
->query(fn () => $baseQuery) ->query(fn () => $baseQuery)
->poll('15s') ->poll('15s')
->columns([ ->columns(
Stack::make([ (auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid'
ServerEntryColumn::make('server_entry') ? [
->searchable(['name']), Stack::make([
]), ServerEntryColumn::make('server_entry')
]) ->searchable(['name']),
]),
]
: [
ColumnGroup::make('Status')
->label('Status')
->columns($viewOne),
ColumnGroup::make('Server')
->label('Servers')
->columns($viewTwo),
ColumnGroup::make('Resources')
->label('Resources')
->columns($viewThree),
]
)
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->contentGrid([ ->contentGrid([
'default' => 1, 'default' => 1,
'md' => 2, 'md' => 2,
]) ])
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->emptyStateIcon('tabler-brand-docker') ->emptyStateIcon('tabler-brand-docker')
->emptyStateDescription('') ->emptyStateDescription('')
->emptyStateHeading('You don\'t have access to any servers!') ->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
->persistFiltersInSession() ->persistFiltersInSession()
->filters([ ->filters([
SelectFilter::make('egg') SelectFilter::make('egg')
->relationship('egg', 'name', fn (Builder $query) => $query->whereIn('id', $baseQuery->pluck('egg_id'))) ->relationship('egg', 'name', fn (Builder $query) => $query->whereIn('id', $baseQuery->pluck('egg_id')))
->searchable() ->searchable()
->preload(), ->preload(),
SelectFilter::make('owner')
->relationship('user', 'username', fn (Builder $query) => $query->whereIn('id', $baseQuery->pluck('owner_id')))
->searchable()
->hidden(fn () => $this->activeTab === 'my')
->preload(),
]); ]);
} }
public function updatedActiveTab(): void
{
$this->resetTable();
}
public function getTabs(): array public function getTabs(): array
{ {
$all = auth()->user()->accessibleServers(); $all = auth()->user()->accessibleServers();
@ -67,4 +144,50 @@ class ListServers extends ListRecords
->badge($all->count()), ->badge($all->count()),
]; ];
} }
public function getResourceColor(Server $server, string $resource): ?string
{
$current = null;
$limit = null;
switch ($resource) {
case 'cpu':
$current = $server->resources()['cpu_absolute'] ?? 0;
$limit = $server->cpu;
if ($server->cpu === 0) {
return null;
}
break;
case 'memory':
$current = $server->resources()['memory_bytes'] ?? 0;
$limit = $server->memory * 2 ** 20;
if ($server->memory === 0) {
return null;
}
break;
case 'disk':
$current = $server->resources()['disk_bytes'] ?? 0;
$limit = $server->disk * 2 ** 20;
if ($server->disk === 0) {
return null;
}
break;
default:
return null;
}
if ($current >= $limit * self::DANGER_THRESHOLD) {
return 'danger';
}
if ($current >= $limit * self::WARNING_THRESHOLD) {
return 'warning';
}
return null;
}
} }

View File

@ -17,6 +17,12 @@ class CopyFrom extends Select
$this->placeholder(trans('admin/egg.none')); $this->placeholder(trans('admin/egg.none'));
$this->preload();
$this->searchable();
$this->native(false);
$this->live(); $this->live();
} }

View File

@ -19,6 +19,7 @@ use chillerlan\QRCode\QROptions;
use DateTimeZone; use DateTimeZone;
use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;
@ -29,6 +30,7 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Auth\EditProfile as BaseEditProfile; use Filament\Pages\Auth\EditProfile as BaseEditProfile;
@ -125,6 +127,21 @@ class EditProfile extends BaseEditProfile
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? '' : trans('profile.language_help', ['state' => $state]))) ->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? '' : trans('profile.language_help', ['state' => $state])))
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()) ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
->native(false), ->native(false),
FileUpload::make('avatar')
->visible(fn () => config('panel.filament.uploadable-avatars'))
->avatar()
->acceptedFileTypes(['image/png'])
->directory('avatars')
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
->hintAction(function (FileUpload $fileUpload) {
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png';
return Action::make('remove_avatar')
->icon('tabler-photo-minus')
->iconButton()
->hidden(fn () => !$fileUpload->getDisk()->exists($path))
->action(fn () => $fileUpload->getDisk()->delete($path));
}),
]), ]),
Tab::make(trans('profile.tabs.oauth')) Tab::make(trans('profile.tabs.oauth'))
@ -242,6 +259,7 @@ class EditProfile extends BaseEditProfile
->password(), ->password(),
]; ];
}), }),
Tab::make(trans('profile.tabs.api_keys')) Tab::make(trans('profile.tabs.api_keys'))
->icon('tabler-key') ->icon('tabler-key')
->schema([ ->schema([
@ -261,7 +279,7 @@ class EditProfile extends BaseEditProfile
Action::make('Create') Action::make('Create')
->label(trans('filament-actions::create.single.modal.actions.create.label')) ->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => $get('description') === null) ->disabled(fn (Get $get) => $get('description') === null)
->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'])) ->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app'))
->action(function (Get $get, Action $action, User $user) { ->action(function (Get $get, Action $action, User $user) {
$token = $user->createToken( $token = $user->createToken(
$get('description'), $get('description'),
@ -308,9 +326,11 @@ class EditProfile extends BaseEditProfile
]), ]),
]), ]),
]), ]),
Tab::make(trans('profile.tabs.ssh_keys')) Tab::make(trans('profile.tabs.ssh_keys'))
->icon('tabler-lock-code') ->icon('tabler-lock-code')
->hidden(), ->hidden(),
Tab::make(trans('profile.tabs.activity')) Tab::make(trans('profile.tabs.activity'))
->icon('tabler-history') ->icon('tabler-history')
->schema([ ->schema([
@ -325,6 +345,47 @@ class EditProfile extends BaseEditProfile
Placeholder::make('activity!')->label('')->content(fn (ActivityLog $log) => new HtmlString($log->htmlable())), Placeholder::make('activity!')->label('')->content(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]), ]),
]), ]),
Tab::make(trans('profile.tabs.customization'))
->icon('tabler-adjustments')
->schema([
Section::make(trans('profile.dashboard'))
->collapsible()
->icon('tabler-dashboard')
->schema([
ToggleButtons::make('dashboard_layout')
->label(trans('profile.dashboard_layout'))
->inline()
->required()
->options([
'grid' => trans('profile.grid'),
'table' => trans('profile.table'),
]),
]),
Section::make(trans('profile.console'))
->collapsible()
->icon('tabler-brand-tabler')
->schema([
TextInput::make('console_rows')
->label(trans('profile.rows'))
->minValue(1)
->numeric()
->required()
->columnSpan(1)
->default(30),
// Select::make('console_font')
// ->label(trans('profile.font'))
// ->hidden() //TODO
// ->columnSpan(1),
TextInput::make('console_font_size')
->label(trans('profile.font_size'))
->columnSpan(1)
->minValue(1)
->numeric()
->required()
->default(14),
]),
]),
]), ]),
]) ])
->operation('edit') ->operation('edit')
@ -345,7 +406,7 @@ class EditProfile extends BaseEditProfile
$tokens = $this->toggleTwoFactorService->handle($record, $token, true); $tokens = $this->toggleTwoFactorService->handle($record, $token, true);
cache()->put("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15)); cache()->put("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15));
$this->redirectRoute('filament.admin.auth.profile', ['tab' => '-2fa-tab']); $this->redirect(self::getUrl(['tab' => '-2fa-tab'], panel: 'app'));
} }
if ($token = $data['2fa-disable-code'] ?? null) { if ($token = $data['2fa-disable-code'] ?? null) {
@ -381,4 +442,29 @@ class EditProfile extends BaseEditProfile
]; ];
} }
protected function mutateFormDataBeforeSave(array $data): array
{
$moarbetterdata = [
'console_font_size' => $data['console_font_size'],
'console_rows' => $data['console_rows'],
'dashboard_layout' => $data['dashboard_layout'],
];
unset($data['dashboard_layout'], $data['console_font_size'], $data['console_rows']);
$data['customization'] = json_encode($moarbetterdata);
return $data;
}
protected function mutateFormDataBeforeFill(array $data): array
{
$moarbetterdata = json_decode($data['customization'], true);
$data['console_font_size'] = $moarbetterdata['console_font_size'] ?? 14;
$data['console_rows'] = $moarbetterdata['console_rows'] ?? 30;
$data['dashboard_layout'] = $moarbetterdata['dashboard_layout'] ?? 'grid';
return $data;
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Filament\Server\Pages; namespace App\Filament\Server\Pages;
use App\Enums\ConsoleWidgetPosition;
use App\Enums\ContainerStatus; use App\Enums\ContainerStatus;
use App\Exceptions\Http\Server\ServerStateConflictException; use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Filament\Server\Widgets\ServerConsole; use App\Filament\Server\Widgets\ServerConsole;
@ -38,10 +39,10 @@ class Console extends Page
try { try {
$server->validateCurrentState(); $server->validateCurrentState();
} catch (ServerStateConflictException $exception) { } catch (ServerStateConflictException $exception) {
AlertBanner::make() AlertBanner::make('server_conflict')
->warning()
->title('Warning') ->title('Warning')
->body($exception->getMessage()) ->body($exception->getMessage())
->warning()
->send(); ->send();
} }
} }
@ -54,18 +55,41 @@ class Console extends Page
]; ];
} }
/** @var array<string, array<class-string<Widget>>> */
protected static array $customWidgets = [];
/** @param class-string<Widget>[] $customWidgets */
public static function registerCustomWidgets(ConsoleWidgetPosition $position, array $customWidgets): void
{
static::$customWidgets[$position->value] = array_unique(array_merge(static::$customWidgets[$position->value] ?? [], $customWidgets));
}
/** /**
* @return class-string<Widget>[] * @return class-string<Widget>[]
*/ */
public function getWidgets(): array public function getWidgets(): array
{ {
return [ $allWidgets = [];
ServerOverview::class,
ServerConsole::class, $allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::Top->value] ?? []);
$allWidgets[] = ServerOverview::class;
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::AboveConsole->value] ?? []);
$allWidgets[] = ServerConsole::class;
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::BelowConsole->value] ?? []);
$allWidgets = array_merge($allWidgets, [
ServerCpuChart::class, ServerCpuChart::class,
ServerMemoryChart::class, ServerMemoryChart::class,
//ServerNetworkChart::class, TODO: convert units. //ServerNetworkChart::class, TODO: convert units.
]; ]);
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::Bottom->value] ?? []);
return array_unique($allWidgets);
} }
/** /**
@ -104,20 +128,23 @@ class Console extends Page
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()), ->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable())
->icon('tabler-player-play-filled'),
Action::make('restart') Action::make('restart')
->color('gray') ->color('gray')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()), ->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable())
->icon('tabler-reload'),
Action::make('stop') Action::make('stop')
->color('danger') ->color('danger')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable()) ->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable())
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()), ->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable())
->icon('tabler-player-stop-filled'),
Action::make('kill') Action::make('kill')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
@ -127,7 +154,8 @@ class Console extends Page
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable()), ->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable())
->icon('tabler-alert-square'),
]; ];
} }
} }

View File

@ -18,6 +18,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
class Startup extends ServerFormPage class Startup extends ServerFormPage
@ -100,7 +101,7 @@ class Startup extends ServerFormPage
->schema([ ->schema([
Repeater::make('server_variables') Repeater::make('server_variables')
->label('') ->label('')
->relationship('viewableServerVariables') ->relationship('serverVariables', fn (Builder $query) => $query->where('egg_variables.user_viewable', true)->orderByPowerJoins('variable.sort'))
->grid() ->grid()
->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server)) ->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->reorderable(false)->addable(false)->deletable(false) ->reorderable(false)->addable(false)->deletable(false)

View File

@ -30,8 +30,7 @@ class ActivityResource extends Resource
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
return $server->activity() return ActivityLog::whereHas('subjects', fn (Builder $query) => $query->where('subject_id', $server->id))
->getQuery()
->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS) ->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS)
->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) { ->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) {
// We could do this with a query and a lot of joins, but that gets pretty // We could do this with a query and a lot of joins, but that gets pretty

View File

@ -19,6 +19,7 @@ use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
class ListActivities extends ListRecords class ListActivities extends ListRecords
@ -31,7 +32,7 @@ class ListActivities extends ListRecords
$server = Filament::getTenant(); $server = Filament::getTenant();
return $table return $table
->paginated([25, 50, 100, 250]) ->paginated([25, 50])
->defaultPaginationPageOption(25) ->defaultPaginationPageOption(25)
->columns([ ->columns([
TextColumn::make('event') TextColumn::make('event')
@ -98,7 +99,7 @@ class ListActivities extends ListRecords
DateTimePicker::make('timestamp'), DateTimePicker::make('timestamp'),
KeyValue::make('properties') KeyValue::make('properties')
->label('Metadata') ->label('Metadata')
->formatStateUsing(fn ($state) => collect($state)->filter(fn ($item) => !is_array($item))->all()), ->formatStateUsing(fn ($state) => Arr::dot($state)),
]), ]),
]) ])
->filters([ ->filters([

View File

@ -2,6 +2,7 @@
namespace App\Filament\Server\Resources\BackupResource\Pages; namespace App\Filament\Server\Resources\BackupResource\Pages;
use App\Enums\BackupStatus;
use App\Enums\ServerState; use App\Enums\ServerState;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Server\Resources\BackupResource; use App\Filament\Server\Resources\BackupResource;
@ -70,13 +71,14 @@ class ListBackups extends ListRecords
->label('Created') ->label('Created')
->since() ->since()
->sortable(), ->sortable(),
IconColumn::make('is_successful') TextColumn::make('status')
->label('Successful') ->label('Status')
->boolean(), ->badge(),
IconColumn::make('is_locked') IconColumn::make('is_locked')
->visibleFrom('md') ->visibleFrom('md')
->label('Lock Status') ->label('Lock Status')
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock-open' : 'tabler-lock'), ->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'),
]) ])
->actions([ ->actions([
ActionGroup::make([ ActionGroup::make([
@ -84,12 +86,14 @@ class ListBackups extends ListRecords
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open') ->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? 'Lock' : 'Unlock') ->label(fn (Backup $backup) => !$backup->is_locked ? 'Lock' : 'Unlock')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup)), ->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download') Action::make('download')
->color('primary') ->color('primary')
->icon('tabler-download') ->icon('tabler-download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true), ->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') Action::make('restore')
->color('success') ->color('success')
->icon('tabler-folder-up') ->icon('tabler-folder-up')
@ -138,12 +142,14 @@ class ListBackups extends ListRecords
return Notification::make() return Notification::make()
->title('Restoring Backup') ->title('Restoring Backup')
->send(); ->send();
}), })
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete') DeleteAction::make('delete')
->disabled(fn (Backup $backup): bool => $backup->is_locked) ->disabled(fn (Backup $backup) => $backup->is_locked)
->modalDescription(fn (Backup $backup) => 'Do you wish to delete, ' . $backup->name . '?') ->modalDescription(fn (Backup $backup) => 'Do you wish to delete, ' . $backup->name . '?')
->modalSubmitActionLabel('Delete Backup') ->modalSubmitActionLabel('Delete Backup')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->delete($request, $server, $backup)), ->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->delete($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status !== BackupStatus::InProgress),
]), ]),
]); ]);
} }
@ -180,7 +186,6 @@ class ListBackups extends ListRecords
->body($backup->name . ' created.') ->body($backup->name . ' created.')
->success() ->success()
->send(); ->send();
} catch (HttpException $e) { } catch (HttpException $e) {
return Notification::make() return Notification::make()
->danger() ->danger()

View File

@ -4,6 +4,8 @@ namespace App\Filament\Server\Resources\FileResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor; use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Enums\EditorLanguages; use App\Enums\EditorLanguages;
use App\Exceptions\Http\Server\FileSizeTooLargeException;
use App\Exceptions\Repository\FileNotEditableException;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Server\Resources\FileResource; use App\Filament\Server\Resources\FileResource;
use App\Livewire\AlertBanner; use App\Livewire\AlertBanner;
@ -45,6 +47,8 @@ class EditFiles extends Page
#[Locked] #[Locked]
public string $path; public string $path;
private DaemonFileRepository $fileRepository;
/** @var array<mixed> */ /** @var array<mixed> */
public ?array $data = []; public ?array $data = [];
@ -66,12 +70,8 @@ class EditFiles extends Page
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy') ->icon('tabler-device-floppy')
->keyBindings('mod+shift+s') ->keyBindings('mod+shift+s')
->action(function (DaemonFileRepository $fileRepository) use ($server) { ->action(function () {
$data = $this->form->getState(); $this->getDaemonFileRepository()->putContent($this->path, $this->data['editor'] ?? '');
$fileRepository
->setServer($server)
->putContent($this->path, $data['editor'] ?? '');
Activity::event('server:file.write') Activity::event('server:file.write')
->property('file', $this->path) ->property('file', $this->path)
@ -90,12 +90,8 @@ class EditFiles extends Page
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy') ->icon('tabler-device-floppy')
->keyBindings('mod+s') ->keyBindings('mod+s')
->action(function (DaemonFileRepository $fileRepository) use ($server) { ->action(function () {
$data = $this->form->getState(); $this->getDaemonFileRepository()->putContent($this->path, $this->data['editor'] ?? '');
$fileRepository
->setServer($server)
->putContent($this->path, $data['editor'] ?? '');
Activity::event('server:file.write') Activity::event('server:file.write')
->property('file', $this->path) ->property('file', $this->path)
@ -117,21 +113,46 @@ class EditFiles extends Page
->schema([ ->schema([
Select::make('lang') Select::make('lang')
->label('Syntax Highlighting') ->label('Syntax Highlighting')
->searchable()
->native(false)
->live() ->live()
->options(EditorLanguages::class) ->options(EditorLanguages::class)
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->afterStateUpdated(fn ($state) => $this->dispatch('setLanguage', lang: $state)) ->afterStateUpdated(fn ($state) => $this->dispatch('setLanguage', lang: $state))
->default(fn () => EditorLanguages::fromWithAlias(pathinfo($this->path, PATHINFO_EXTENSION))), ->default(fn () => EditorLanguages::fromWithAlias(pathinfo($this->path, PATHINFO_EXTENSION))),
MonacoEditor::make('editor') MonacoEditor::make('editor')
->label('') ->hiddenLabel()
->placeholderText('') ->showPlaceholder(false)
->default(function (DaemonFileRepository $fileRepository) use ($server) { ->default(function () {
try { try {
return $fileRepository return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
->setServer($server) } catch (FileSizeTooLargeException) {
->getContent($this->path, config('panel.files.max_edit_size')); AlertBanner::make()
->title('File too large!')
->body('<code>' . $this->path . '</code> Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl());
} catch (FileNotFoundException) { } catch (FileNotFoundException) {
abort(404, $this->path . ' not found.'); AlertBanner::make()
->title('File Not found!')
->body('<code>' . $this->path . '</code>')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl());
} catch (FileNotEditableException) {
AlertBanner::make()
->title('Could not edit directory!')
->body('<code>' . $this->path . '</code>')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl());
} }
}) })
->language(fn (Get $get) => $get('lang')) ->language(fn (Get $get) => $get('lang'))
@ -149,7 +170,7 @@ class EditFiles extends Page
$this->form->fill(); $this->form->fill();
if (str($path)->endsWith('.pelicanignore')) { if (str($path)->endsWith('.pelicanignore')) {
AlertBanner::make() AlertBanner::make('.pelicanignore_info')
->title('You\'re editing a <code>.pelicanignore</code> file!') ->title('You\'re editing a <code>.pelicanignore</code> file!')
->body('Any files or directories listed in here will be excluded from backups. Wildcards are supported by using an asterisk (<code>*</code>).<br>You can negate a prior rule by prepending an exclamation point (<code>!</code>).') ->body('Any files or directories listed in here will be excluded from backups. Wildcards are supported by using an asterisk (<code>*</code>).<br>You can negate a prior rule by prepending an exclamation point (<code>!</code>).')
->info() ->info()
@ -200,6 +221,15 @@ class EditFiles extends Page
return $breadcrumbs; return $breadcrumbs;
} }
private function getDaemonFileRepository(): DaemonFileRepository
{
/** @var Server $server */
$server = Filament::getTenant();
$this->fileRepository ??= (new DaemonFileRepository())->setServer($server);
return $this->fileRepository;
}
public static function route(string $path): PageRegistration public static function route(string $path): PageRegistration
{ {
return new PageRegistration( return new PageRegistration(

View File

@ -40,6 +40,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -57,13 +58,18 @@ class ListFiles extends ListRecords
public function mount(?string $path = null): void public function mount(?string $path = null): void
{ {
parent::mount(); parent::mount();
$this->path = $path ?? '/'; $this->path = $path ?? '/';
try { try {
$this->getDaemonFileRepository()->getDirectory('/'); $this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) { } catch (ConnectionException) {
$this->isDisabled = true; $this->isDisabled = true;
$this->getFailureNotification();
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
} }
} }
@ -92,8 +98,8 @@ class ListFiles extends ListRecords
$files = File::get($server, $this->path); $files = File::get($server, $this->path);
return $table return $table
->paginated([25, 50, 100, 250]) ->paginated([25, 50])
->defaultPaginationPageOption(50) ->defaultPaginationPageOption(25)
->query(fn () => $files->orderByDesc('is_directory')) ->query(fn () => $files->orderByDesc('is_directory'))
->defaultSort('name') ->defaultSort('name')
->columns([ ->columns([
@ -290,16 +296,24 @@ class ListFiles extends ListRecords
->disabled($this->isDisabled) ->disabled($this->isDisabled)
->label('Archive') ->label('Archive')
->icon('tabler-archive') ->icon('tabler-archive')
->action(function (File $file) { ->form([
$this->getDaemonFileRepository()->compressFiles($this->path, [$file->name]); TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, File $file) {
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, [$file->name], $data['name']);
Activity::event('server:file.compress') Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path) ->property('directory', $this->path)
->property('files', [$file->name]) ->property('files', [$file->name])
->log(); ->log();
Notification::make() Notification::make()
->title('Archive created') ->title('Archive created')
->body($archive['name'])
->success() ->success()
->send(); ->send();
@ -378,18 +392,26 @@ class ListFiles extends ListRecords
BulkAction::make('archive') BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled) ->disabled($this->isDisabled)
->action(function (Collection $files) { ->form([
TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray(); $files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->compressFiles($this->path, $files); $archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
Activity::event('server:file.compress') Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path) ->property('directory', $this->path)
->property('files', $files) ->property('files', $files)
->log(); ->log();
Notification::make() Notification::make()
->title('Archive created') ->title('Archive created')
->body($archive['name'])
->success() ->success()
->send(); ->send();
@ -442,6 +464,8 @@ class ListFiles extends ListRecords
->required(), ->required(),
Select::make('lang') Select::make('lang')
->label('Syntax Highlighting') ->label('Syntax Highlighting')
->searchable()
->native(false)
->live() ->live()
->options(EditorLanguages::class) ->options(EditorLanguages::class)
->selectablePlaceholder(false) ->selectablePlaceholder(false)
@ -563,14 +587,6 @@ class ListFiles extends ListRecords
return $this->fileRepository; return $this->fileRepository;
} }
public function getFailureNotification(): AlertBanner
{
return AlertBanner::make()
->title('Could not connect to the node!')
->danger()
->send();
}
public static function route(string $path): PageRegistration public static function route(string $path): PageRegistration
{ {
return new PageRegistration( return new PageRegistration(

View File

@ -4,9 +4,12 @@ namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\ScheduleResource\Pages; use App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager; use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager;
use App\Helpers\Utilities;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Schedule; use App\Models\Schedule;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon;
use Exception;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
@ -17,7 +20,9 @@ use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Set; use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ScheduleResource extends Resource class ScheduleResource extends Resource
@ -314,4 +319,18 @@ class ScheduleResource extends Resource
'edit' => Pages\EditSchedule::route('/{record}/edit'), 'edit' => Pages\EditSchedule::route('/{record}/edit'),
]; ];
} }
public static function getNextRun(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
{
try {
return Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek);
} catch (Exception) {
Notification::make()
->title('The cron data provided does not evaluate to a valid expression')
->danger()
->send();
throw new Halt();
}
}
} }

View File

@ -2,14 +2,10 @@
namespace App\Filament\Server\Resources\ScheduleResource\Pages; namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Exceptions\DisplayException;
use App\Facades\Activity; use App\Facades\Activity;
use App\Filament\Server\Resources\ScheduleResource; use App\Filament\Server\Resources\ScheduleResource;
use App\Helpers\Utilities;
use App\Models\Schedule; use App\Models\Schedule;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon;
use Exception;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
@ -39,27 +35,18 @@ class CreateSchedule extends CreateRecord
} }
if (!isset($data['next_run_at'])) { if (!isset($data['next_run_at'])) {
$data['next_run_at'] = $this->getNextRunAt($data['cron_minute'], $data['cron_hour'], $data['cron_day_of_month'], $data['cron_month'], $data['cron_day_of_week']); $data['next_run_at'] = ScheduleResource::getNextRun(
$data['cron_minute'],
$data['cron_hour'],
$data['cron_day_of_month'],
$data['cron_month'],
$data['cron_day_of_week']
);
} }
return $data; return $data;
} }
protected function getNextRunAt(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
{
try {
return Utilities::getScheduleNextRunDate(
$minute,
$hour,
$dayOfMonth,
$month,
$dayOfWeek
);
} catch (Exception) {
throw new DisplayException('The cron data provided does not evaluate to a valid expression.');
}
}
public function getBreadcrumbs(): array public function getBreadcrumbs(): array
{ {
return []; return [];

View File

@ -22,6 +22,19 @@ class EditSchedule extends EditRecord
->log(); ->log();
} }
protected function mutateFormDataBeforeSave(array $data): array
{
$data['next_run_at'] = ScheduleResource::getNextRun(
$data['cron_minute'],
$data['cron_hour'],
$data['cron_day_of_month'],
$data['cron_month'],
$data['cron_day_of_week']
);
return $data;
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [

View File

@ -39,8 +39,10 @@ class ListSchedules extends ListRecords
->sortable(), ->sortable(),
DateTimeColumn::make('next_run_at') DateTimeColumn::make('next_run_at')
->label('Next run') ->label('Next run')
->placeholder('Never')
->since() ->since()
->sortable(), ->sortable()
->state(fn (Schedule $schedule) => $schedule->is_active ? $schedule->next_run_at : null),
]) ])
->actions([ ->actions([
ViewAction::make(), ViewAction::make(),

View File

@ -5,7 +5,6 @@ namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\UserResource\Pages; use App\Filament\Server\Resources\UserResource\Pages;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use App\Models\Subuser;
use App\Models\User; use App\Models\User;
use App\Services\Subusers\SubuserDeletionService; use App\Services\Subusers\SubuserDeletionService;
use App\Services\Subusers\SubuserUpdateService; use App\Services\Subusers\SubuserUpdateService;
@ -91,21 +90,21 @@ class UserResource extends Resource
ImageColumn::make('picture') ImageColumn::make('picture')
->visibleFrom('lg') ->visibleFrom('lg')
->label('') ->label('')
->extraImgAttributes(['class' => 'rounded-full']) ->alignCenter()->circular()
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))), ->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username') TextColumn::make('username')
->searchable(), ->searchable(),
TextColumn::make('email') TextColumn::make('email')
->searchable(), ->searchable(),
TextColumn::make('permissions') TextColumn::make('permissions')
->state(fn (User $user) => count(Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first()->permissions)), ->state(fn (User $user) => count($server->subusers->where('user_id', $user->id)->first()->permissions)),
]) ])
->actions([ ->actions([
DeleteAction::make() DeleteAction::make()
->label('Remove User') ->label('Remove User')
->hidden(fn (User $user) => auth()->user()->id === $user->id) ->hidden(fn (User $user) => auth()->user()->id === $user->id)
->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) { ->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuser = Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first(); $subuser = $server->subusers->where('user_id', $user->id)->first();
$subuserDeletionService->handle($subuser, $server); $subuserDeletionService->handle($subuser, $server);
Notification::make() Notification::make()
@ -119,7 +118,7 @@ class UserResource extends Resource
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server))
->modalHeading(fn (User $user) => 'Editing ' . $user->email) ->modalHeading(fn (User $user) => 'Editing ' . $user->email)
->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) { ->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) {
$subuser = Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first(); $subuser = $server->subusers->where('user_id', $user->id)->first();
$permissions = collect($data) $permissions = collect($data)
->forget('email') ->forget('email')
@ -217,7 +216,9 @@ class UserResource extends Resource
'rename', 'rename',
'description', 'description',
'reinstall', 'reinstall',
'activity', ],
'activity' => [
'read',
], ],
]; ];
@ -244,11 +245,7 @@ class UserResource extends Resource
->schema([ ->schema([
CheckboxList::make('control') CheckboxList::make('control')
->formatStateUsing(function (User $user, Set $set) use ($server) { ->formatStateUsing(function (User $user, Set $set) use ($server) {
$permissionsArray = Subuser::query() $permissionsArray = $server->subusers->where('user_id', $user->id)->first()->permissions;
->where('user_id', $user->id)
->where('server_id', $server->id)
->first()
->permissions;
$transformedPermissions = []; $transformedPermissions = [];
@ -265,6 +262,7 @@ class UserResource extends Resource
}) })
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'console' => 'Console', 'console' => 'Console',
'start' => 'Start', 'start' => 'Start',
@ -288,6 +286,7 @@ class UserResource extends Resource
CheckboxList::make('user') CheckboxList::make('user')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'create' => 'Create', 'create' => 'Create',
@ -311,6 +310,7 @@ class UserResource extends Resource
CheckboxList::make('file') CheckboxList::make('file')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'read-content' => 'Read Content', 'read-content' => 'Read Content',
@ -340,6 +340,7 @@ class UserResource extends Resource
CheckboxList::make('backup') CheckboxList::make('backup')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'create' => 'Create', 'create' => 'Create',
@ -365,6 +366,7 @@ class UserResource extends Resource
CheckboxList::make('allocation') CheckboxList::make('allocation')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'create' => 'Create', 'create' => 'Create',
@ -388,6 +390,7 @@ class UserResource extends Resource
CheckboxList::make('startup') CheckboxList::make('startup')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'update' => 'Update', 'update' => 'Update',
@ -409,6 +412,7 @@ class UserResource extends Resource
CheckboxList::make('database') CheckboxList::make('database')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'create' => 'Create', 'create' => 'Create',
@ -434,6 +438,7 @@ class UserResource extends Resource
CheckboxList::make('schedule') CheckboxList::make('schedule')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'read' => 'Read', 'read' => 'Read',
'create' => 'Create', 'create' => 'Create',
@ -457,17 +462,34 @@ class UserResource extends Resource
CheckboxList::make('settings') CheckboxList::make('settings')
->bulkToggleable() ->bulkToggleable()
->label('') ->label('')
->columns(2)
->options([ ->options([
'rename' => 'Rename', 'rename' => 'Rename',
'description' => 'Description', 'description' => 'Description',
'reinstall' => 'Reinstall', 'reinstall' => 'Reinstall',
'activity' => 'Activity',
]) ])
->descriptions([ ->descriptions([
'rename' => trans('server/users.permissions.setting_rename'), 'rename' => trans('server/users.permissions.setting_rename'),
'description' => trans('server/users.permissions.setting_description'), 'description' => trans('server/users.permissions.setting_description'),
'reinstall' => trans('server/users.permissions.setting_reinstall'), 'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.activity_desc'), ]),
]),
]),
Tab::make('Activity')
->schema([
Section::make()
->description(trans('server/users.permissions.activity_desc'))
->icon('tabler-stack')
->schema([
CheckboxList::make('activity')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
])
->descriptions([
'read' => trans('server/users.permissions.activity_read'),
]), ]),
]), ]),
]), ]),

View File

@ -16,6 +16,7 @@ use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Forms\Set; use Filament\Forms\Set;
@ -139,7 +140,7 @@ class ListUsers extends ListRecords
Tabs::make() Tabs::make()
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Tabs\Tab::make('Console') Tab::make('Console')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.control_desc')) ->description(trans('server/users.permissions.control_desc'))
@ -163,7 +164,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('User') Tab::make('User')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.user_desc')) ->description(trans('server/users.permissions.user_desc'))
@ -187,7 +188,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('File') Tab::make('File')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.file_desc')) ->description(trans('server/users.permissions.file_desc'))
@ -217,7 +218,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Backup') Tab::make('Backup')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.backup_desc')) ->description(trans('server/users.permissions.backup_desc'))
@ -243,7 +244,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Allocation') Tab::make('Allocation')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.allocation_desc')) ->description(trans('server/users.permissions.allocation_desc'))
@ -267,7 +268,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Startup') Tab::make('Startup')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.startup_desc')) ->description(trans('server/users.permissions.startup_desc'))
@ -289,7 +290,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Database') Tab::make('Database')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.database_desc')) ->description(trans('server/users.permissions.database_desc'))
@ -315,7 +316,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Schedule') Tab::make('Schedule')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.schedule_desc')) ->description(trans('server/users.permissions.schedule_desc'))
@ -339,7 +340,7 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Settings') Tab::make('Settings')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.settings_desc')) ->description(trans('server/users.permissions.settings_desc'))
@ -353,17 +354,15 @@ class ListUsers extends ListRecords
'rename' => 'Rename', 'rename' => 'Rename',
'description' => 'Description', 'description' => 'Description',
'reinstall' => 'Reinstall', 'reinstall' => 'Reinstall',
'activity' => 'Activity',
]) ])
->descriptions([ ->descriptions([
'rename' => trans('server/users.permissions.setting_rename'), 'rename' => trans('server/users.permissions.setting_rename'),
'description' => trans('server/users.permissions.setting_description'), 'description' => trans('server/users.permissions.setting_description'),
'reinstall' => trans('server/users.permissions.setting_reinstall'), 'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.activity_desc'),
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Activity') Tab::make('Activity')
->schema([ ->schema([
Section::make() Section::make()
->description(trans('server/users.permissions.activity_desc')) ->description(trans('server/users.permissions.activity_desc'))

View File

@ -11,6 +11,7 @@ use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService; use App\Services\Servers\GetUserPermissionsService;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Livewire\Attributes\Session;
use Livewire\Attributes\On; use Livewire\Attributes\On;
class ServerConsole extends Widget class ServerConsole extends Widget
@ -26,6 +27,7 @@ class ServerConsole extends Widget
public ?User $user = null; public ?User $user = null;
/** @var string[] */ /** @var string[] */
#[Session(key: 'server.{server.id}.history')]
public array $history = []; public array $history = [];
public int $historyIndex = 0; public int $historyIndex = 0;
@ -130,7 +132,7 @@ class ServerConsole extends Widget
#[On('websocket-error')] #[On('websocket-error')]
public function websocketError(): void public function websocketError(): void
{ {
AlertBanner::make() AlertBanner::make('websocket_error')
->title('Could not connect to websocket!') ->title('Could not connect to websocket!')
->body('Check your browser console for more details.') ->body('Check your browser console for more details.')
->danger() ->danger()

View File

@ -4,7 +4,6 @@ namespace App\Filament\Server\Widgets;
use App\Enums\ContainerStatus; use App\Enums\ContainerStatus;
use App\Filament\Server\Components\SmallStatBlock; use App\Filament\Server\Components\SmallStatBlock;
use App\Filament\Server\Components\StatBlock;
use App\Models\Server; use App\Models\Server;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
@ -19,13 +18,12 @@ class ServerOverview extends StatsOverviewWidget
protected function getStats(): array protected function getStats(): array
{ {
return [ return [
StatBlock::make('Name', $this->server->name) SmallStatBlock::make('Name', $this->server->name)
->description($this->server->description)
->extraAttributes([ ->extraAttributes([
'class' => 'overflow-x-auto', 'class' => 'overflow-x-auto',
]), ]),
StatBlock::make('Status', $this->status()), SmallStatBlock::make('Status', $this->status()),
StatBlock::make('Address', $this->server->allocation->address) SmallStatBlock::make('Address', $this->server->allocation->address)
->extraAttributes([ ->extraAttributes([
'class' => 'overflow-x-auto', 'class' => 'overflow-x-auto',
]), ]),

View File

@ -13,6 +13,7 @@ use App\Services\Servers\TransferServerService;
use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Arr;
#[Group('Server', weight: 4)] #[Group('Server', weight: 4)]
class ServerManagementController extends ApplicationApiController class ServerManagementController extends ApplicationApiController
@ -82,15 +83,24 @@ class ServerManagementController extends ApplicationApiController
$validatedData = $request->validate([ $validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id', 'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id', 'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable', 'allocation_additional' => 'nullable|array',
'allocation_additional.*' => 'integer|exists:allocations,id',
]); ]);
if ($this->transferServerService->handle($server, $validatedData)) { if ($this->transferServerService->handle($server, Arr::get($validatedData, 'node_id'), Arr::get($validatedData, 'allocation_id'), Arr::get($validatedData, 'allocation_additional', []))) {
// Transfer started /**
* Transfer started
*
* @status 204
*/
return $this->returnNoContent(); return $this->returnNoContent();
} }
// Node was not viable /**
* Node was not viable
*
* @status 406
*/
return $this->returnNotAcceptable(); return $this->returnNotAcceptable();
} }
@ -104,7 +114,11 @@ class ServerManagementController extends ApplicationApiController
public function cancelTransfer(ServerWriteRequest $request, Server $server): Response public function cancelTransfer(ServerWriteRequest $request, Server $server): Response
{ {
if (!$transfer = $server->transfer) { if (!$transfer = $server->transfer) {
// Server is not transferring /**
* Server is not transferring
*
* @status 406
*/
return $this->returnNotAcceptable(); return $this->returnNotAcceptable();
} }
@ -113,6 +127,11 @@ class ServerManagementController extends ApplicationApiController
$this->daemonServerRepository->setServer($server)->cancelTransfer(); $this->daemonServerRepository->setServer($server)->cancelTransfer();
/**
* Transfer cancelled
*
* @status 204
*/
return $this->returnNoContent(); return $this->returnNoContent();
} }
} }

View File

@ -172,8 +172,8 @@ class FileController extends ClientApiController
Activity::event('server:file.rename') Activity::event('server:file.rename')
->property('directory', $request->input('root')) ->property('directory', $request->input('root'))
->property('files', $files) ->property('files', $files)
->property('to', $files['to']) ->property('to', $files[0]['to'])
->property('from', $files['from']) ->property('from', $files[0]['from'])
->log(); ->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
@ -210,10 +210,12 @@ class FileController extends ClientApiController
{ {
$file = $this->fileRepository->setServer($server)->compressFiles( $file = $this->fileRepository->setServer($server)->compressFiles(
$request->input('root'), $request->input('root'),
$request->input('files') $request->input('files'),
$request->input('name')
); );
Activity::event('server:file.compress') Activity::event('server:file.compress')
->property('name', $file['name'])
->property('directory', $request->input('root')) ->property('directory', $request->input('root'))
->property('files', $request->input('files')) ->property('files', $request->input('files'))
->log(); ->log();

View File

@ -37,7 +37,7 @@ class StartupController extends ClientApiController
$startup = $this->startupCommandService->handle($server); $startup = $this->startupCommandService->handle($server);
return $this->fractal->collection( return $this->fractal->collection(
$server->variables()->orderBy('sort')->where('user_viewable', true)->get() $server->variables()->where('user_viewable', true)->orderBy('sort')->get()
) )
->transformWith($this->getTransformer(EggVariableTransformer::class)) ->transformWith($this->getTransformer(EggVariableTransformer::class))
->addMeta([ ->addMeta([

View File

@ -5,10 +5,8 @@ namespace App\Http\Controllers\Api\Remote;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\User; use App\Models\User;
use Webmozart\Assert\Assert;
use App\Models\Server; use App\Models\Server;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\ActivityLogSubject;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ActivityEventRequest; use App\Http\Requests\Api\Remote\ActivityEventRequest;
@ -16,8 +14,6 @@ class ActivityProcessingController extends Controller
{ {
public function __invoke(ActivityEventRequest $request): void public function __invoke(ActivityEventRequest $request): void
{ {
$tz = Carbon::now()->getTimezone();
/** @var \App\Models\Node $node */ /** @var \App\Models\Node $node */
$node = $request->attributes->get('node'); $node = $request->attributes->get('node');
@ -51,11 +47,8 @@ class ActivityProcessingController extends Controller
$log = [ $log = [
'ip' => empty($datum['ip']) ? '127.0.0.1' : $datum['ip'], 'ip' => empty($datum['ip']) ? '127.0.0.1' : $datum['ip'],
'event' => $datum['event'], 'event' => $datum['event'],
'properties' => json_encode($datum['metadata'] ?? []), 'properties' => $datum['metadata'] ?? [],
// We have to change the time to the current timezone due to the way Laravel is handling 'timestamp' => $when,
// the date casting internally. If we just leave it in UTC it ends up getting double-cast
// and the time is way off.
'timestamp' => $when->setTimezone($tz),
]; ];
if ($user = $users->get($datum['user'])) { if ($user = $users->get($datum['user'])) {
@ -71,19 +64,17 @@ class ActivityProcessingController extends Controller
} }
foreach ($logs as $key => $data) { foreach ($logs as $key => $data) {
Assert::isInstanceOf($server = $servers->get($key), Server::class); $server = $servers->get($key);
assert($server instanceof Server);
$batch = [];
foreach ($data as $datum) { foreach ($data as $datum) {
$id = ActivityLog::insertGetId($datum); /** @var ActivityLog $activityLog */
$batch[] = [ $activityLog = ActivityLog::forceCreate($datum);
'activity_log_id' => $id, $activityLog->subjects()->create([
'subject_id' => $server->id, 'subject_id' => $server->id,
'subject_type' => $server->getMorphClass(), 'subject_type' => $server->getMorphClass(),
]; ]);
} }
ActivityLogSubject::insert($batch);
} }
} }
} }

View File

@ -44,6 +44,20 @@ class OAuthController extends Controller
return redirect()->route('auth.login'); return redirect()->route('auth.login');
} }
// Check for errors (https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/)
if ($request->get('error')) {
report($request->get('error_description') ?? $request->get('error'));
Notification::make()
->title('Something went wrong')
->body($request->get('error'))
->danger()
->persistent()
->send();
return redirect()->route('auth.login');
}
$oauthUser = Socialite::driver($driver)->user(); $oauthUser = Socialite::driver($driver)->user();
// User is already logged in and wants to link a new OAuth Provider // User is already logged in and wants to link a new OAuth Provider
@ -53,7 +67,7 @@ class OAuthController extends Controller
$this->updateService->handle($request->user(), ['oauth' => $oauth]); $this->updateService->handle($request->user(), ['oauth' => $oauth]);
return redirect(EditProfile::getUrl(['tab' => '-oauth-tab'])); return redirect(EditProfile::getUrl(['tab' => '-oauth-tab'], panel: 'app'));
} }
try { try {

View File

@ -5,6 +5,9 @@ namespace App\Http\Middleware;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Exceptions\Http\TwoFactorAuthRequiredException; use App\Exceptions\Http\TwoFactorAuthRequiredException;
use App\Filament\Pages\Auth\EditProfile;
use App\Livewire\AlertBanner;
use App\Models\User;
class RequireTwoFactorAuthentication class RequireTwoFactorAuthentication
{ {
@ -14,11 +17,6 @@ class RequireTwoFactorAuthentication
public const LEVEL_ALL = 2; public const LEVEL_ALL = 2;
/**
* The route to redirect a user to enable 2FA.
*/
protected string $redirectRoute = '/account';
/** /**
* Check the user state on the incoming request to determine if they should be allowed to * Check the user state on the incoming request to determine if they should be allowed to
* proceed or not. This checks if the Panel is configured to require 2FA on an account in * proceed or not. This checks if the Panel is configured to require 2FA on an account in
@ -29,31 +27,37 @@ class RequireTwoFactorAuthentication
*/ */
public function handle(Request $request, \Closure $next): mixed public function handle(Request $request, \Closure $next): mixed
{ {
/** @var ?User $user */
$user = $request->user(); $user = $request->user();
$uri = rtrim($request->getRequestUri(), '/') . '/'; $uri = rtrim($request->getRequestUri(), '/') . '/';
$current = $request->route()->getName(); $current = $request->route()->getName();
if (!$user || Str::startsWith($uri, ['/auth/']) || Str::startsWith($current, ['auth.', 'account.'])) { if (!$user || Str::startsWith($uri, ['/auth/', '/profile']) || Str::startsWith($current, ['auth.', 'account.', 'filament.app.auth.'])) {
return $next($request); return $next($request);
} }
/** @var \App\Models\User $user */
$level = (int) config('panel.auth.2fa_required'); $level = (int) config('panel.auth.2fa_required');
// If this setting is not configured, or the user is already using 2FA then we can just
// send them right through, nothing else needs to be checked.
//
// If the level is set as admin and the user is not an admin, pass them through as well.
if ($level === self::LEVEL_NONE || $user->use_totp) { if ($level === self::LEVEL_NONE || $user->use_totp) {
// If this setting is not configured, or the user is already using 2FA then we can just send them right through, nothing else needs to be checked.
return $next($request); return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) { } elseif ($level === self::LEVEL_ADMIN && !$user->isAdmin()) {
// If the level is set as admin and the user is not an admin, pass them through as well.
return $next($request); return $next($request);
} }
// For API calls return an exception which gets rendered nicely in the API response. // For API calls return an exception which gets rendered nicely in the API response...
if ($request->isJson() || Str::startsWith($uri, '/api/')) { if ($request->isJson() || Str::startsWith($uri, '/api/')) {
throw new TwoFactorAuthRequiredException(); throw new TwoFactorAuthRequiredException();
} }
return redirect()->to($this->redirectRoute); // ... otherwise display banner and redirect to profile
AlertBanner::make('2fa_must_be_enabled')
->body(trans('auth.2fa_must_be_enabled'))
->warning()
->send();
return redirect(EditProfile::getUrl(['tab' => '-2fa-tab'], panel: 'app'));
} }
} }

View File

@ -12,7 +12,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
*/ */
public function rules(): array public function rules(): array
{ {
$rules = Server::getRulesForUpdate($this->parameter('server', Server::class)); $rules = $this->route() ? Server::getRulesForUpdate($this->parameter('server', Server::class)) : Server::getRules();
return [ return [
'allocation' => $rules['allocation_id'], 'allocation' => $rules['allocation_id'],
@ -26,13 +26,17 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'limits.threads' => $this->requiredToOptional('threads', $rules['threads'], true), 'limits.threads' => $this->requiredToOptional('threads', $rules['threads'], true),
'limits.disk' => $this->requiredToOptional('disk', $rules['disk'], true), 'limits.disk' => $this->requiredToOptional('disk', $rules['disk'], true),
// Legacy rules to maintain backwards compatable API support without requiring // Deprecated - use limits.memory
// a major version bump.
'memory' => $this->requiredToOptional('memory', $rules['memory']), 'memory' => $this->requiredToOptional('memory', $rules['memory']),
// Deprecated - use limits.swap
'swap' => $this->requiredToOptional('swap', $rules['swap']), 'swap' => $this->requiredToOptional('swap', $rules['swap']),
// Deprecated - use limits.io
'io' => $this->requiredToOptional('io', $rules['io']), 'io' => $this->requiredToOptional('io', $rules['io']),
// Deprecated - use limits.cpu
'cpu' => $this->requiredToOptional('cpu', $rules['cpu']), 'cpu' => $this->requiredToOptional('cpu', $rules['cpu']),
// Deprecated - use limits.threads
'threads' => $this->requiredToOptional('threads', $rules['threads']), 'threads' => $this->requiredToOptional('threads', $rules['threads']),
// Deprecated - use limits.disk
'disk' => $this->requiredToOptional('disk', $rules['disk']), 'disk' => $this->requiredToOptional('disk', $rules['disk']),
'add_allocations' => 'bail|array', 'add_allocations' => 'bail|array',

View File

@ -11,7 +11,7 @@ class UpdateServerDetailsRequest extends ServerWriteRequest
*/ */
public function rules(): array public function rules(): array
{ {
$rules = Server::getRulesForUpdate($this->parameter('server', Server::class)); $rules = $this->route() ? Server::getRulesForUpdate($this->parameter('server', Server::class)) : Server::getRules();
return [ return [
'external_id' => $rules['external_id'], 'external_id' => $rules['external_id'],

View File

@ -17,12 +17,12 @@ class UpdateServerStartupRequest extends ApplicationApiRequest
*/ */
public function rules(): array public function rules(): array
{ {
$data = Server::getRulesForUpdate($this->parameter('server', Server::class)); $rules = $this->route() ? Server::getRulesForUpdate($this->parameter('server', Server::class)) : Server::getRules();
return [ return [
'startup' => 'sometimes|string', 'startup' => 'sometimes|string',
'environment' => 'present|array', 'environment' => 'present|array',
'egg' => $data['egg_id'], 'egg' => $rules['egg_id'],
'image' => 'sometimes|string', 'image' => 'sometimes|string',
'skip_scripts' => 'present|boolean', 'skip_scripts' => 'present|boolean',
]; ];

View File

@ -21,6 +21,7 @@ class CompressFilesRequest extends ClientApiRequest
'root' => 'sometimes|nullable|string', 'root' => 'sometimes|nullable|string',
'files' => 'required|array', 'files' => 'required|array',
'files.*' => 'string', 'files.*' => 'string',
'name' => 'sometimes|nullable|string',
]; ];
} }
} }

View File

@ -44,9 +44,8 @@ final class AlertBanner implements Wireable
public static function fromLivewire(mixed $value): AlertBanner public static function fromLivewire(mixed $value): AlertBanner
{ {
$static = AlertBanner::make(); $static = AlertBanner::make($value['id']);
$static->id($value['id']);
$static->title($value['title']); $static->title($value['title']);
$static->body($value['body']); $static->body($value['body']);
$static->status($value['status']); $static->status($value['status']);

View File

@ -3,6 +3,7 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
class AlertBannerContainer extends Component class AlertBannerContainer extends Component
@ -16,6 +17,7 @@ class AlertBannerContainer extends Component
$this->pullFromSession(); $this->pullFromSession();
} }
#[On('alertBannerSent')]
public function pullFromSession(): void public function pullFromSession(): void
{ {
foreach (session()->pull('alert-banners', []) as $alertBanner) { foreach (session()->pull('alert-banners', []) as $alertBanner) {

View File

@ -19,6 +19,7 @@ class DatabaseStep
'sqlite' => 'SQLite', 'sqlite' => 'SQLite',
'mariadb' => 'MariaDB', 'mariadb' => 'MariaDB',
'mysql' => 'MySQL', 'mysql' => 'MySQL',
'pgsql' => 'PostgreSQL',
]; ];
public static function make(PanelInstaller $installer): Step public static function make(PanelInstaller $installer): Step
@ -39,15 +40,24 @@ class DatabaseStep
->afterStateUpdated(function ($state, Set $set, Get $get) { ->afterStateUpdated(function ($state, Set $set, Get $get) {
$set('env_database.DB_DATABASE', $state === 'sqlite' ? 'database.sqlite' : 'panel'); $set('env_database.DB_DATABASE', $state === 'sqlite' ? 'database.sqlite' : 'panel');
if ($state === 'sqlite') { switch ($state) {
$set('env_database.DB_HOST', null); case 'sqlite':
$set('env_database.DB_PORT', null); $set('env_database.DB_HOST', null);
$set('env_database.DB_USERNAME', null); $set('env_database.DB_PORT', null);
$set('env_database.DB_PASSWORD', null); $set('env_database.DB_USERNAME', null);
} else { $set('env_database.DB_PASSWORD', null);
$set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1'); break;
$set('env_database.DB_PORT', $get('env_database.DB_PORT') ?? '3306'); case 'mariadb':
$set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican'); case 'mysql':
$set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1');
$set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican');
$set('env_database.DB_PORT', '3306');
break;
case 'pgsql':
$set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1');
$set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican');
$set('env_database.DB_PORT', '5432');
break;
} }
}), }),
TextInput::make('env_database.DB_DATABASE') TextInput::make('env_database.DB_DATABASE')
@ -114,7 +124,6 @@ class DatabaseStep
'database' => $database, 'database' => $database,
'username' => $username, 'username' => $username,
'password' => $password, 'password' => $password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci', 'collation' => 'utf8mb4_unicode_ci',
'strict' => true, 'strict' => true,
]); ]);

View File

@ -6,6 +6,7 @@ use App\Traits\HasValidation;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use App\Events\ActivityLogged; use App\Events\ActivityLogged;
use Filament\Facades\Filament;
use Filament\Support\Contracts\HasIcon; use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel; use Filament\Support\Contracts\HasLabel;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -120,11 +121,6 @@ class ActivityLog extends Model implements HasIcon, HasLabel
return $builder->whereMorphedTo('actor', $actor); return $builder->whereMorphedTo('actor', $actor);
} }
/**
* Returns models to be pruned.
*
* @see https://laravel.com/docs/9.x/eloquent#pruning-models
*/
public function prunable(): Builder public function prunable(): Builder
{ {
if (is_null(config('activity.prune_days'))) { if (is_null(config('activity.prune_days'))) {
@ -134,10 +130,6 @@ class ActivityLog extends Model implements HasIcon, HasLabel
return static::where('timestamp', '<=', Carbon::now()->subDays(config('activity.prune_days'))); return static::where('timestamp', '<=', Carbon::now()->subDays(config('activity.prune_days')));
} }
/**
* Boots the model event listeners. This will trigger an activity log event every
* time a new model is inserted which can then be captured and worked with as needed.
*/
protected static function boot(): void protected static function boot(): void
{ {
parent::boot(); parent::boot();
@ -181,9 +173,11 @@ class ActivityLog extends Model implements HasIcon, HasLabel
]); ]);
} }
$avatarUrl = Filament::getUserAvatarUrl($user);
return " return "
<div style='display: flex; align-items: center;'> <div style='display: flex; align-items: center;'>
<img width='50px' height='50px' src='{$user->getFilamentAvatarUrl()}' style='margin-right: 15px' /> <img width='50px' height='50px' src='{$avatarUrl}' style='margin-right: 15px' />
<div> <div>
<p>$user->username $this->event</p> <p>$user->username $this->event</p>

View File

@ -72,6 +72,20 @@ class Allocation extends Model
static::deleting(function (self $allocation) { static::deleting(function (self $allocation) {
throw_if($allocation->server_id, new ServerUsingAllocationException(trans('exceptions.allocations.server_using'))); throw_if($allocation->server_id, new ServerUsingAllocationException(trans('exceptions.allocations.server_using')));
}); });
static::updating(function ($allocation) {
$originalServerId = $allocation->getOriginal('server_id');
if (!$originalServerId) {
return;
}
$server = Server::find($originalServerId);
if (!$server) {
return;
}
if ($allocation->isDirty('server_id') && is_null($allocation->server_id) && $allocation->id === $server->allocation_id) {
return false;
}
});
} }
protected function casts(): array protected function casts(): array

View File

@ -7,6 +7,8 @@ use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Eloquent\BackupQueryBuilder; use App\Eloquent\BackupQueryBuilder;
use App\Enums\BackupStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -23,6 +25,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $bytes * @property int $bytes
* @property string|null $upload_id * @property string|null $upload_id
* @property \Carbon\CarbonImmutable|null $completed_at * @property \Carbon\CarbonImmutable|null $completed_at
* @property BackupStatus $status
* @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at * @property \Carbon\CarbonImmutable $updated_at
* @property \Carbon\CarbonImmutable|null $deleted_at * @property \Carbon\CarbonImmutable|null $deleted_at
@ -79,6 +82,13 @@ class Backup extends Model implements Validatable
]; ];
} }
protected function status(): Attribute
{
return Attribute::make(
get: fn () => !$this->completed_at ? BackupStatus::InProgress : ($this->is_successful ? BackupStatus::Successful : BackupStatus::Failed),
);
}
public function server(): BelongsTo public function server(): BelongsTo
{ {
return $this->belongsTo(Server::class); return $this->belongsTo(Server::class);

View File

@ -8,7 +8,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
/** /**
* @property int $id * @property int $id
@ -36,8 +35,6 @@ class Database extends Model implements Validatable
*/ */
public const RESOURCE_NAME = 'server_database'; public const RESOURCE_NAME = 'server_database';
public const DEFAULT_CONNECTION_NAME = 'dynamic';
/** /**
* The attributes excluded from the model's JSON form. * The attributes excluded from the model's JSON form.
*/ */
@ -104,7 +101,7 @@ class Database extends Model implements Validatable
*/ */
private function run(string $statement): bool private function run(string $statement): bool
{ {
return DB::connection(self::DEFAULT_CONNECTION_NAME)->statement($statement); return $this->host->buildConnection()->statement($statement);
} }
/** /**

View File

@ -4,10 +4,12 @@ namespace App\Models;
use App\Contracts\Validatable; use App\Contracts\Validatable;
use App\Traits\HasValidation; use App\Traits\HasValidation;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
/** /**
* @property int $id * @property int $id
@ -82,4 +84,21 @@ class DatabaseHost extends Model implements Validatable
{ {
return $this->hasMany(Database::class); return $this->hasMany(Database::class);
} }
public function buildConnection(string $database = 'mysql', string $charset = 'utf8', string $collation = 'utf8_unicode_ci'): Connection
{
/** @var Connection $connection */
$connection = DB::build([
'driver' => 'mysql',
'host' => $this->host,
'port' => $this->port,
'database' => $database,
'username' => $this->username,
'password' => $this->password,
'charset' => $charset,
'collation' => $collation,
]);
return $connection;
}
} }

View File

@ -33,6 +33,8 @@ class File extends Model
protected $keyType = 'string'; protected $keyType = 'string';
protected int $sushiInsertChunkSize = 100;
public const ARCHIVE_MIMES = [ public const ARCHIVE_MIMES = [
'application/vnd.rar', // .rar 'application/vnd.rar', // .rar
'application/x-rar-compressed', // .rar (2) 'application/x-rar-compressed', // .rar (2)
@ -167,7 +169,7 @@ class File extends Model
throw new Exception($contents['error']); throw new Exception($contents['error']);
} }
return array_map(function ($file) { $rows = array_map(function ($file) {
return [ return [
'name' => $file['name'], 'name' => $file['name'],
'created_at' => Carbon::parse($file['created'])->timezone('UTC'), 'created_at' => Carbon::parse($file['created'])->timezone('UTC'),
@ -181,6 +183,14 @@ class File extends Model
'mime_type' => $file['mime'], 'mime_type' => $file['mime'],
]; ];
}, $contents); }, $contents);
$rowCount = count($rows);
$limit = 999;
if ($rowCount > $limit) {
$this->sushiInsertChunkSize = min(floor($limit / count($this->getSchema())), $rowCount);
}
return $rows;
} catch (Exception $exception) { } catch (Exception $exception) {
report($exception); report($exception);

View File

@ -89,7 +89,7 @@ class Node extends Model implements Validatable
'name' => ['required', 'string', 'min:1', 'max:100'], 'name' => ['required', 'string', 'min:1', 'max:100'],
'description' => ['string', 'nullable'], 'description' => ['string', 'nullable'],
'public' => ['boolean'], 'public' => ['boolean'],
'fqdn' => ['required', 'string'], 'fqdn' => ['required', 'string', 'notIn:0.0.0.0,127.0.0.1,localhost'],
'scheme' => ['required', 'string', 'in:http,https'], 'scheme' => ['required', 'string', 'in:http,https'],
'behind_proxy' => ['boolean'], 'behind_proxy' => ['boolean'],
'memory' => ['required', 'numeric', 'min:0'], 'memory' => ['required', 'numeric', 'min:0'],
@ -365,16 +365,20 @@ class Node extends Model implements Validatable
]; ];
try { try {
$this->systemInformation();
return Http::daemon($this) $data = Http::daemon($this)
->connectTimeout(1) ->connectTimeout(1)
->timeout(1) ->timeout(1)
->get('/api/system/utilization') ->get('/api/system/utilization')
->json() ?? $default; ->json();
if ($data['memory_total']) {
return $data;
}
} catch (Exception) { } catch (Exception) {
return $default;
} }
return $default;
} }
/** @return string[] */ /** @return string[] */

View File

@ -19,7 +19,7 @@ class RecoveryToken extends Model implements Validatable
use HasValidation; use HasValidation;
/** /**
* There are no updates to this model, only inserts and deletes. * There are no updates to this model, only creates and deletes.
*/ */
public const UPDATED_AT = null; public const UPDATED_AT = null;

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use Spatie\Permission\Models\Role as BaseRole; use Spatie\Permission\Models\Role as BaseRole;
/** /**
@ -41,6 +43,76 @@ class Role extends BaseRole
], ],
]; ];
/** @var array<string, array<string>> */
protected static array $customPermissions = [];
/** @param array<string, array<string>> $customPermissions */
public static function registerCustomPermissions(array $customPermissions): void
{
static::$customPermissions = [
...static::$customPermissions,
...$customPermissions,
];
}
public static function registerCustomDefaultPermissions(string $model): void
{
$permissions = [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
$permissions[] = $prefix->value;
}
static::registerCustomPermissions([
$model => $permissions,
]);
}
/** @return array<string, array<string>> */
public static function getPermissionList(): array
{
$allPermissions = [];
// Standard permissions for our default model
foreach (RolePermissionModels::cases() as $model) {
$allPermissions[$model->value] ??= [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
array_push($allPermissions[$model->value], $prefix->value);
}
if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) {
foreach (static::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) {
array_push($allPermissions[$model->value], $permission);
}
}
}
// Special permissions for our default models
foreach (static::SPECIAL_PERMISSIONS as $model => $prefixes) {
$allPermissions[$model] ??= [];
foreach ($prefixes as $prefix) {
array_push($allPermissions[$model], $prefix);
}
}
// Custom third party permissions
foreach (static::$customPermissions as $model => $prefixes) {
$allPermissions[$model] ??= [];
foreach ($prefixes as $prefix) {
array_push($allPermissions[$model], $prefix);
}
}
foreach ($allPermissions as $model => $permissions) {
$allPermissions[$model] = array_unique($permissions);
}
return $allPermissions;
}
public function isRootAdmin(): bool public function isRootAdmin(): bool
{ {
return $this->name === self::ROOT_ADMIN; return $this->name === self::ROOT_ADMIN;

View File

@ -310,14 +310,6 @@ class Server extends Model implements Validatable
return $this->hasMany(ServerVariable::class); return $this->hasMany(ServerVariable::class);
} }
/** @deprecated use serverVariables */
public function viewableServerVariables(): HasMany
{
return $this->serverVariables()
->join('egg_variables', 'egg_variables.id', '=', 'server_variables.variable_id')
->where('egg_variables.user_viewable', true);
}
/** /**
* Gets information for the node associated with this server. * Gets information for the node associated with this server.
*/ */
@ -480,7 +472,7 @@ class Server extends Model implements Validatable
} }
if ($resourceAmount === 0 & $limit) { if ($resourceAmount === 0 & $limit) {
return 'Unlimited'; return "\u{221E}";
} }
if ($type === ServerResourceType::Percentage) { if ($type === ServerResourceType::Percentage) {

View File

@ -4,12 +4,12 @@ namespace App\Models;
use App\Contracts\Validatable; use App\Contracts\Validatable;
use App\Exceptions\DisplayException; use App\Exceptions\DisplayException;
use App\Extensions\Avatar\AvatarProvider;
use App\Rules\Username; use App\Rules\Username;
use App\Facades\Activity; use App\Facades\Activity;
use App\Traits\HasValidation; use App\Traits\HasValidation;
use DateTimeZone; use DateTimeZone;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasName; use Filament\Models\Contracts\HasName;
use Filament\Models\Contracts\HasTenants; use Filament\Models\Contracts\HasTenants;
use Filament\Panel; use Filament\Panel;
@ -24,6 +24,7 @@ use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use App\Models\Traits\HasAccessTokens; use App\Models\Traits\HasAccessTokens;
use Filament\Models\Contracts\HasAvatar;
use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Foundation\Auth\Access\Authorizable;
@ -31,7 +32,7 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification; use Illuminate\Support\Facades\Storage;
use ResourceBundle; use ResourceBundle;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
@ -51,7 +52,6 @@ use Spatie\Permission\Traits\HasRoles;
* @property string|null $totp_secret * @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at * @property \Illuminate\Support\Carbon|null $totp_authenticated_at
* @property string[]|null $oauth * @property string[]|null $oauth
* @property bool $gravatar
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\ApiKey[] $apiKeys * @property \Illuminate\Database\Eloquent\Collection|\App\Models\ApiKey[] $apiKeys
@ -69,6 +69,7 @@ use Spatie\Permission\Traits\HasRoles;
* @property int|null $tokens_count * @property int|null $tokens_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Role[] $roles * @property \Illuminate\Database\Eloquent\Collection|\App\Models\Role[] $roles
* @property int|null $roles_count * @property int|null $roles_count
* @property string|null $customization
* *
* @method static \Database\Factories\UserFactory factory(...$parameters) * @method static \Database\Factories\UserFactory factory(...$parameters)
* @method static Builder|User newModelQuery() * @method static Builder|User newModelQuery()
@ -77,7 +78,6 @@ use Spatie\Permission\Traits\HasRoles;
* @method static Builder|User whereCreatedAt($value) * @method static Builder|User whereCreatedAt($value)
* @method static Builder|User whereEmail($value) * @method static Builder|User whereEmail($value)
* @method static Builder|User whereExternalId($value) * @method static Builder|User whereExternalId($value)
* @method static Builder|User whereGravatar($value)
* @method static Builder|User whereId($value) * @method static Builder|User whereId($value)
* @method static Builder|User whereLanguage($value) * @method static Builder|User whereLanguage($value)
* @method static Builder|User whereTimezone($value) * @method static Builder|User whereTimezone($value)
@ -124,8 +124,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'use_totp', 'use_totp',
'totp_secret', 'totp_secret',
'totp_authenticated_at', 'totp_authenticated_at',
'gravatar',
'oauth', 'oauth',
'customization',
]; ];
/** /**
@ -143,6 +143,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'use_totp' => false, 'use_totp' => false,
'totp_secret' => null, 'totp_secret' => null,
'oauth' => '[]', 'oauth' => '[]',
'customization' => null,
]; ];
/** @var array<array-key, string[]> */ /** @var array<array-key, string[]> */
@ -157,16 +158,20 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'use_totp' => ['boolean'], 'use_totp' => ['boolean'],
'totp_secret' => ['nullable', 'string'], 'totp_secret' => ['nullable', 'string'],
'oauth' => ['array', 'nullable'], 'oauth' => ['array', 'nullable'],
'customization' => ['array', 'nullable'],
'customization.console_rows' => ['integer', 'min:1'],
'customization.console_font' => ['string'],
'customization.console_font_size' => ['integer', 'min:1'],
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'use_totp' => 'boolean', 'use_totp' => 'boolean',
'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime', 'totp_authenticated_at' => 'datetime',
'totp_secret' => 'encrypted', 'totp_secret' => 'encrypted',
'oauth' => 'array', 'oauth' => 'array',
'customization' => 'array',
]; ];
} }
@ -179,6 +184,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return true; return true;
}); });
static::saving(function (self $user) {
$user->email = mb_strtolower($user->email);
});
static::deleting(function (self $user) { static::deleting(function (self $user) {
throw_if($user->servers()->count() > 0, new DisplayException(trans('exceptions.users.has_servers'))); throw_if($user->servers()->count() > 0, new DisplayException(trans('exceptions.users.has_servers')));
@ -201,21 +210,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $rules; return $rules;
} }
/**
* Send the password reset notification.
*
* @param string $token
*/
public function sendPasswordResetNotification($token): void
{
Activity::event('auth:reset-password')
->withRequestMetadata()
->subject($this)
->log('sending password reset email');
$this->notify(new ResetPasswordNotification($token));
}
public function username(): Attribute public function username(): Attribute
{ {
return Attribute::make( return Attribute::make(
@ -383,7 +377,17 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function getFilamentAvatarUrl(): ?string public function getFilamentAvatarUrl(): ?string
{ {
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email)); if (config('panel.filament.uploadable-avatars')) {
$path = "avatars/$this->id.png";
if (Storage::disk('public')->exists($path)) {
return Storage::url($path);
}
}
$provider = AvatarProvider::getProvider(config('panel.filament.avatar-provider'));
return $provider?->get($this);
} }
public function canTarget(Model $user): bool public function canTarget(Model $user): bool
@ -414,4 +418,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return false; return false;
} }
/** @return array<mixed> */
public function getCustomization(): array
{
return json_decode($this->customization, true) ?? [];
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -29,7 +30,7 @@ class AccountCreated extends Notification implements ShouldQueue
->line('Email: ' . $notifiable->email); ->line('Email: ' . $notifiable->email);
if (!is_null($this->token)) { if (!is_null($this->token)) {
return $message->action('Setup Your Account', url('/auth/password/reset/' . $this->token . '?email=' . urlencode($notifiable->email))); return $message->action('Setup Your Account', Filament::getResetPasswordUrl($this->token, $notifiable));
} }
return $message; return $message;

View File

@ -2,6 +2,7 @@
namespace App\Notifications; namespace App\Notifications;
use App\Filament\Server\Pages\Console;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -27,6 +28,6 @@ class AddedToServer extends Notification implements ShouldQueue
->greeting('Hello ' . $notifiable->username . '!') ->greeting('Hello ' . $notifiable->username . '!')
->line('You have been added as a subuser for the following server, allowing you certain control over the server.') ->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
->line('Server Name: ' . $this->server->name) ->line('Server Name: ' . $this->server->name)
->action('Visit Server', url('/server/' . $this->server->uuid_short)); ->action('Visit Server', Console::getUrl(panel: 'server', tenant: $this->server));
} }
} }

View File

@ -28,6 +28,6 @@ class RemovedFromServer extends Notification implements ShouldQueue
->greeting('Hello ' . $notifiable->username . '.') ->greeting('Hello ' . $notifiable->username . '.')
->line('You have been removed as a subuser for the following server.') ->line('You have been removed as a subuser for the following server.')
->line('Server Name: ' . $this->server->name) ->line('Server Name: ' . $this->server->name)
->action('Visit Panel', config('app.url')); ->action('Visit Panel', url(''));
} }
} }

View File

@ -1,31 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
class SendPasswordReset extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public string $token) {}
/** @return string[] */
public function via(): array
{
return ['mail'];
}
public function toMail(User $notifiable): MailMessage
{
return (new MailMessage())
->subject('Reset Password')
->line('You are receiving this email because we received a password reset request for your account.')
->action('Reset Password', url('/auth/password/reset/' . $this->token . '?email=' . urlencode($notifiable->email)))
->line('If you did not request a password reset, no further action is required.');
}
}

View File

@ -2,10 +2,10 @@
namespace App\Notifications; namespace App\Notifications;
use App\Filament\Server\Pages\Console;
use App\Models\User; use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use App\Models\Server; use App\Models\Server;
use App\Filament\App\Resources\ServerResource\Pages\ListServers;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
@ -28,6 +28,6 @@ class ServerInstalled extends Notification implements ShouldQueue
->greeting('Hello ' . $notifiable->username . '.') ->greeting('Hello ' . $notifiable->username . '.')
->line('Your server has finished installing and is now ready for you to use.') ->line('Your server has finished installing and is now ready for you to use.')
->line('Server Name: ' . $this->server->name) ->line('Server Name: ' . $this->server->name)
->action('Login and Begin Using', ListServers::getUrl()); ->action('Login and Begin Using', Console::getUrl(panel: 'server', tenant: $this->server));
} }
} }

View File

@ -10,6 +10,9 @@ use App\Checks\NodeVersionsCheck;
use App\Checks\PanelVersionCheck; use App\Checks\PanelVersionCheck;
use App\Checks\ScheduleCheck; use App\Checks\ScheduleCheck;
use App\Checks\UsedDiskSpaceCheck; use App\Checks\UsedDiskSpaceCheck;
use App\Extensions\Avatar\Providers\GravatarProvider;
use App\Extensions\Avatar\Providers\UiAvatarsProvider;
use App\Extensions\OAuth\Providers\GitlabProvider;
use App\Models; use App\Models;
use App\Extensions\Captcha\Providers\TurnstileProvider; use App\Extensions\Captcha\Providers\TurnstileProvider;
use App\Extensions\OAuth\Providers\AuthentikProvider; use App\Extensions\OAuth\Providers\AuthentikProvider;
@ -36,8 +39,13 @@ use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Livewire\Component;
use Livewire\Livewire;
use Spatie\Health\Facades\Health; use Spatie\Health\Facades\Health;
use function Livewire\on;
use function Livewire\store;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/** /**
@ -97,7 +105,7 @@ class AppServiceProvider extends ServiceProvider
CommonProvider::register($app, 'linkedin', null, 'tabler-brand-linkedin-f', '#0a66c2'); CommonProvider::register($app, 'linkedin', null, 'tabler-brand-linkedin-f', '#0a66c2');
CommonProvider::register($app, 'google', null, 'tabler-brand-google-f', '#4285f4'); CommonProvider::register($app, 'google', null, 'tabler-brand-google-f', '#4285f4');
GithubProvider::register($app); GithubProvider::register($app);
CommonProvider::register($app, 'gitlab', null, 'tabler-brand-gitlab', '#fca326'); GitlabProvider::register($app);
CommonProvider::register($app, 'bitbucket', null, 'tabler-brand-bitbucket-f', '#205081'); CommonProvider::register($app, 'bitbucket', null, 'tabler-brand-bitbucket-f', '#205081');
CommonProvider::register($app, 'slack', null, 'tabler-brand-slack', '#6ecadc'); CommonProvider::register($app, 'slack', null, 'tabler-brand-slack', '#6ecadc');
@ -109,6 +117,10 @@ class AppServiceProvider extends ServiceProvider
// Default Captcha provider // Default Captcha provider
TurnstileProvider::register($app); TurnstileProvider::register($app);
// Default Avatar providers
GravatarProvider::register();
UiAvatarsProvider::register();
FilamentColor::register([ FilamentColor::register([
'danger' => Color::Red, 'danger' => Color::Red,
'gray' => Color::Zinc, 'gray' => Color::Zinc,
@ -138,6 +150,22 @@ class AppServiceProvider extends ServiceProvider
fn () => Blade::render('filament.layouts.footer'), fn () => Blade::render('filament.layouts.footer'),
); );
on('dehydrate', function (Component $component) {
if (!Livewire::isLivewireRequest()) {
return;
}
if (store($component)->has('redirect')) {
return;
}
if (count(session()->get('alert-banners') ?? []) <= 0) {
return;
}
$component->dispatch('alertBannerSent');
});
// Don't run any health checks during tests // Don't run any health checks during tests
if (!$app->runningUnitTests()) { if (!$app->runningUnitTests()) {
Health::checks([ Health::checks([

View File

@ -5,6 +5,7 @@ namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\LanguageMiddleware; use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Http\Middleware\DispatchServingFilamentEvent;
@ -36,13 +37,16 @@ class AdminPanelProvider extends PanelProvider
->brandLogo(config('app.logo')) ->brandLogo(config('app.logo'))
->brandLogoHeight('2rem') ->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico')) ->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', true)) ->topNavigation(config('panel.filament.top-navigation', false))
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl')) ->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->profile(EditProfile::class, false)
->login(Login::class) ->login(Login::class)
->passwordReset()
->userMenuItems([ ->userMenuItems([
'profile' => MenuItem::make()
->label(fn () => trans('filament-panels::pages/auth/edit-profile.label'))
->url(fn () => EditProfile::getUrl(panel: 'app')),
MenuItem::make() MenuItem::make()
->label(trans('profile.exit_admin')) ->label(fn () => trans('profile.exit_admin'))
->url('/') ->url('/')
->icon('tabler-arrow-back') ->icon('tabler-arrow-back')
->sort(24), ->sort(24),
@ -57,6 +61,7 @@ class AdminPanelProvider extends PanelProvider
->sidebarCollapsibleOnDesktop() ->sidebarCollapsibleOnDesktop()
->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources') ->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources')
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages')
->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\\Filament\\Admin\\Widgets')
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
@ -68,6 +73,7 @@ class AdminPanelProvider extends PanelProvider
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
LanguageMiddleware::class, LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
]) ])
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,

View File

@ -4,6 +4,8 @@ namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -32,11 +34,12 @@ class AppPanelProvider extends PanelProvider
->brandLogo(config('app.logo')) ->brandLogo(config('app.logo'))
->brandLogoHeight('2rem') ->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico')) ->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', true)) ->topNavigation(config('panel.filament.top-navigation', false))
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl')) ->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->navigation(false) ->navigation(false)
->profile(EditProfile::class, false) ->profile(EditProfile::class, false)
->login(Login::class) ->login(Login::class)
->passwordReset()
->userMenuItems([ ->userMenuItems([
MenuItem::make() MenuItem::make()
->label('Admin') ->label('Admin')
@ -56,6 +59,8 @@ class AppPanelProvider extends PanelProvider
SubstituteBindings::class, SubstituteBindings::class,
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
]) ])
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,

View File

@ -7,6 +7,8 @@ use App\Filament\Pages\Auth\Login;
use App\Filament\Admin\Resources\ServerResource\Pages\EditServer; use App\Filament\Admin\Resources\ServerResource\Pages\EditServer;
use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\EditProfile;
use App\Http\Middleware\Activity\ServerSubject; use App\Http\Middleware\Activity\ServerSubject;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use App\Models\Server; use App\Models\Server;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
@ -39,11 +41,14 @@ class ServerPanelProvider extends PanelProvider
->brandLogo(config('app.logo')) ->brandLogo(config('app.logo'))
->brandLogoHeight('2rem') ->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico')) ->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', true)) ->topNavigation(config('panel.filament.top-navigation', false))
->maxContentWidth(config('panel.filament.display-width', 'screen-2xl')) ->maxContentWidth(config('panel.filament.display-width', 'screen-2xl'))
->login(Login::class) ->login(Login::class)
->passwordReset()
->userMenuItems([ ->userMenuItems([
'profile' => MenuItem::make()->label('Profile')->url(fn () => EditProfile::getUrl(panel: 'app')), 'profile' => MenuItem::make()
->label(fn () => trans('filament-panels::pages/auth/edit-profile.label'))
->url(fn () => EditProfile::getUrl(panel: 'app')),
MenuItem::make() MenuItem::make()
->label('Server List') ->label('Server List')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
@ -58,7 +63,7 @@ class ServerPanelProvider extends PanelProvider
]) ])
->navigationItems([ ->navigationItems([
NavigationItem::make('Open in Admin') NavigationItem::make('Open in Admin')
->url(fn () => EditServer::getUrl(['record' => Filament::getTenant()], panel: 'admin', tenant: null), true) ->url(fn () => EditServer::getUrl(['record' => Filament::getTenant()], panel: 'admin'))
->visible(fn () => auth()->user()->can('view server', Filament::getTenant())) ->visible(fn () => auth()->user()->can('view server', Filament::getTenant()))
->icon('tabler-arrow-back') ->icon('tabler-arrow-back')
->sort(99), ->sort(99),
@ -76,6 +81,8 @@ class ServerPanelProvider extends PanelProvider
SubstituteBindings::class, SubstituteBindings::class,
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
RequireTwoFactorAuthentication::class,
ServerSubject::class, ServerSubject::class,
]) ])
->authMiddleware([ ->authMiddleware([

View File

@ -5,6 +5,7 @@ namespace App\Repositories\Daemon;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use App\Exceptions\Http\Server\FileSizeTooLargeException; use App\Exceptions\Http\Server\FileSizeTooLargeException;
use App\Exceptions\Repository\FileNotEditableException;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
class DaemonFileRepository extends DaemonRepository class DaemonFileRepository extends DaemonRepository
@ -29,6 +30,10 @@ class DaemonFileRepository extends DaemonRepository
throw new FileSizeTooLargeException(); throw new FileSizeTooLargeException();
} }
if ($response->getStatusCode() === 400) {
throw new FileNotEditableException();
}
if ($response->getStatusCode() === 404) { if ($response->getStatusCode() === 404) {
throw new FileNotFoundException(); throw new FileNotFoundException();
} }
@ -133,7 +138,7 @@ class DaemonFileRepository extends DaemonRepository
* *
* @throws ConnectionException * @throws ConnectionException
*/ */
public function compressFiles(?string $root, array $files): array public function compressFiles(?string $root, array $files, ?string $name): array
{ {
return $this->getHttpClient() return $this->getHttpClient()
// Wait for up to 15 minutes for the archive to be completed when calling this endpoint // Wait for up to 15 minutes for the archive to be completed when calling this endpoint
@ -143,6 +148,7 @@ class DaemonFileRepository extends DaemonRepository
[ [
'root' => $root ?? '/', 'root' => $root ?? '/',
'files' => $files, 'files' => $files,
'name' => $name ?? '',
] ]
)->json(); )->json();
} }

View File

@ -10,7 +10,6 @@ use Illuminate\Support\Collection;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use App\Models\ActivityLogSubject;
use App\Models\Server; use App\Models\Server;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
@ -236,16 +235,12 @@ class ActivityLogService
$response = $this->connection->transaction(function () { $response = $this->connection->transaction(function () {
$this->activity->save(); $this->activity->save();
$subjects = Collection::make($this->subjects) foreach ($this->subjects as $subject) {
->map(fn (Model $subject) => [ $this->activity->subjects()->forceCreate([
'activity_log_id' => $this->activity->id,
'subject_id' => $subject->getKey(), 'subject_id' => $subject->getKey(),
'subject_type' => $subject->getMorphClass(), 'subject_type' => $subject->getMorphClass(),
]) ]);
->values() }
->toArray();
ActivityLogSubject::insert($subjects);
return $this->activity; return $this->activity;
}); });

View File

@ -70,7 +70,7 @@ class AssignmentService
throw new InvalidPortMappingException($port); throw new InvalidPortMappingException($port);
} }
$insertData = []; $newAllocations = [];
if (preg_match(self::PORT_RANGE_REGEX, $port, $matches)) { if (preg_match(self::PORT_RANGE_REGEX, $port, $matches)) {
$block = range($matches[1], $matches[2]); $block = range($matches[1], $matches[2]);
@ -83,7 +83,7 @@ class AssignmentService
} }
foreach ($block as $unit) { foreach ($block as $unit) {
$insertData[] = [ $newAllocations[] = [
'node_id' => $node->id, 'node_id' => $node->id,
'ip' => $ip->__toString(), 'ip' => $ip->__toString(),
'port' => (int) $unit, 'port' => (int) $unit,
@ -96,7 +96,7 @@ class AssignmentService
throw new PortOutOfRangeException(); throw new PortOutOfRangeException();
} }
$insertData[] = [ $newAllocations[] = [
'node_id' => $node->id, 'node_id' => $node->id,
'ip' => $ip->__toString(), 'ip' => $ip->__toString(),
'port' => (int) $port, 'port' => (int) $port,
@ -105,8 +105,8 @@ class AssignmentService
]; ];
} }
foreach ($insertData as $insert) { foreach ($newAllocations as $newAllocation) {
$allocation = Allocation::query()->create($insert); $allocation = Allocation::query()->create($newAllocation);
$ids[] = $allocation->id; $ids[] = $allocation->id;
} }
} }

View File

@ -7,7 +7,6 @@ use App\Models\Server;
use App\Models\Database; use App\Models\Database;
use App\Helpers\Utilities; use App\Helpers\Utilities;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use App\Extensions\DynamicDatabaseConnection;
use App\Exceptions\Repository\DuplicateDatabaseNameException; use App\Exceptions\Repository\DuplicateDatabaseNameException;
use App\Exceptions\Service\Database\TooManyDatabasesException; use App\Exceptions\Service\Database\TooManyDatabasesException;
use App\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException; use App\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException;
@ -32,7 +31,6 @@ class DatabaseManagementService
public function __construct( public function __construct(
protected ConnectionInterface $connection, protected ConnectionInterface $connection,
protected DynamicDatabaseConnection $dynamic,
) {} ) {}
/** /**
@ -94,8 +92,6 @@ class DatabaseManagementService
return $this->connection->transaction(function () use ($data) { return $this->connection->transaction(function () use ($data) {
$database = $this->createModel($data); $database = $this->createModel($data);
$this->dynamic->set('dynamic', $data['database_host_id']);
$database->createDatabase($database->database); $database->createDatabase($database->database);
$database->createUser( $database->createUser(
$database->username, $database->username,
@ -122,8 +118,6 @@ class DatabaseManagementService
*/ */
public function delete(Database $database): ?bool public function delete(Database $database): ?bool
{ {
$this->dynamic->set('dynamic', $database->database_host_id);
$database->dropDatabase($database->database); $database->dropDatabase($database->database);
$database->dropUser($database->username, $database->remote); $database->dropUser($database->username, $database->remote);
$database->flush(); $database->flush();

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