Merge remote-tracking branch 'upstream/main' into charles/nuke-node-stats

This commit is contained in:
Boy132 2025-05-16 08:34:16 +02:00
commit 8c52209d5f
277 changed files with 5456 additions and 3156 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

@ -68,4 +68,4 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-scripts run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: PHPStan - name: PHPStan
run: vendor/bin/phpstan --memory-limit=-1 run: vendor/bin/phpstan --memory-limit=-1 --error-format=github

4
.gitignore vendored
View File

@ -1,7 +1,6 @@
/.phpunit.cache /.phpunit.cache
/node_modules /node_modules
/public/build /public/build
/public/hot
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
@ -24,8 +23,7 @@ yarn-error.log
/.vscode /.vscode
public/assets/manifest.json public/assets/manifest.json
/database/*.sqlite /database/*.sqlite*
/database/*.sqlite-journal
filament-monaco-editor/ filament-monaco-editor/
_ide_helper* _ide_helper*
/.phpstorm.meta.php /.phpstorm.meta.php

View File

@ -1,16 +1,9 @@
# syntax=docker.io/docker/dockerfile:1.13-labs # syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Production Dockerfile # Pelican Production Dockerfile
##
# 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". # If you want to build this locally you want to run `docker build -f Dockerfile.dev`
##
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine as base
# 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 rm /usr/local/bin/install-php-extensions
# ================================ # ================================
# Stage 1-1: Composer Install # Stage 1-1: Composer Install
@ -82,13 +75,16 @@ RUN chown root:www-data ./ \
&& chmod 750 ./ \ && chmod 750 ./ \
# Files should not have execute set, but directories need it # Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \ && find ./ -type d -exec chmod 750 {} \; \
# Symlink to env/database path, as www-data won't be able to write to webroot # Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& 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 \
# Create necessary directories && ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \ && ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
# Finally allow www-data write permissions where necessary && ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \ # Allow www-data write permissions where necessary
&& 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

112
Dockerfile.dev Normal file
View File

@ -0,0 +1,112 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Development Dockerfile
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/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN rm /usr/local/bin/install-php-extensions
# ================================
# Stage 1-1: Composer Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS composer
WORKDIR /build
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Copy bare minimum to install Composer dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
# Copy bare minimum to install Yarn dependencies
COPY package.json yarn.lock ./
RUN yarn config set network-timeout 300000 \
&& yarn install --frozen-lockfile
# ================================
# Stage 2-1: Composer Optimize
# ================================
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
# Copy full code to optimize autoload
COPY --exclude=Caddyfile --exclude=docker/ . ./
RUN composer dump-autoload --optimize
# ================================
# Stage 2-2: Build Frontend Assets
# ================================
FROM --platform=$TARGETOS/$TARGETARCH yarn AS yarnbuild
WORKDIR /build
# Copy full code
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --from=composer /build .
RUN yarn run build
# ================================
# Stage 5: Build Final Application Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS final
WORKDIR /var/www/html
# Install additional required libraries
RUN apk update && apk add --no-cache \
caddy ca-certificates supervisor supercronic
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
# Set permissions
# First ensure all files are owned by root and restrict www-data to read access
RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary
&& 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
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh ./docker/entrypoint.sh
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/up || exit 1
EXPOSE 80 443
VOLUME /pelican-data
USER www-data
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

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

@ -30,8 +30,11 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
if (config('cache.default') === 'redis') {
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags // https://laravel.com/docs/10.x/upgrade#redis-cache-tags
// This only needs to run when using redis. anything else throws an error.
$schedule->command('cache:prune-stale-tags')->hourly(); $schedule->command('cache:prune-stale-tags')->hourly();
}
// Execute scheduled commands for servers every minute, as if there was a normal cron running. // Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping(); $schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();

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

@ -14,4 +14,24 @@ enum RolePermissionModels: string
case Server = 'server'; case Server = 'server';
case User = 'user'; case User = 'user';
case Webhook = 'webhook'; case Webhook = 'webhook';
public function viewAny(): string
{
return RolePermissionPrefixes::ViewAny->value . ' ' . $this->value;
}
public function view(): string
{
return RolePermissionPrefixes::View->value . ' ' . $this->value;
}
public function create(): string
{
return RolePermissionPrefixes::Create->value . ' ' . $this->value;
}
public function update(): string
{
return RolePermissionPrefixes::Update->value . ' ' . $this->value;
}
} }

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

@ -0,0 +1,51 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
abstract class FeatureProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @param string[] $id
* @return self|static[]
*/
public static function getProviders(string|array|null $id = null): array|self
{
if (is_array($id)) {
return array_intersect_key(static::$providers, array_flip($id));
}
return $id ? static::$providers[$id] : static::$providers;
}
protected function __construct(protected Application $app)
{
if (array_key_exists($this->getId(), static::$providers)) {
if (!$this->app->runningUnitTests()) {
logger()->warning("Tried to create duplicate Feature provider with id '{$this->getId()}'");
}
return;
}
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
/**
* A matching subset string (case-insensitive) from the console output
*
* @return array<string>
*/
abstract public function getListeners(): array;
abstract public function getAction(): Action;
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Extensions\Features;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Repositories\Daemon\DaemonPowerRepository;
use Closure;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\HtmlString;
class GSLToken extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'(gsl token expired)',
'(account not found)',
];
}
public function getId(): string
{
return 'gsl_token';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var ServerVariable $serverVariable */
$serverVariable = $server->serverVariables()->whereHas('variable', function (Builder $query) {
$query->where('env_variable', 'STEAM_ACC');
})->first();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->form([
Placeholder::make('info')
->label(new HtmlString(Blade::render('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'))),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
fn (): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $serverVariable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message);
}
},
])
->hintIcon('tabler-code')
->label(fn () => $serverVariable->variable->name)
->hintIconTooltip(fn () => implode('|', $serverVariable->variable->rules))
->prefix(fn () => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn () => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description),
])
->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server, $serverVariable) {
/** @var Server $server */
$server = Filament::getTenant();
try {
$new = $data['gsltoken'] ?? '';
$original = $serverVariable->variable_value;
$serverVariable->update([
'variable_value' => $new,
]);
if ($original !== $new) {
Activity::event('server:startup.edit')
->property([
'variable' => $serverVariable->variable->env_variable,
'old' => $original,
'new' => $new,
])
->log();
}
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('GSL Token updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update GSL Token')
->body($exception->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Extensions\Features;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
class JavaVersion extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'java.lang.UnsupportedClassVersionError',
'unsupported major.minor version',
'has been compiled by a more recent version of the java runtime',
'minecraft 1.17 requires running the server with java 16 or above',
'minecraft 1.18 requires running the server with java 17 or above',
'minecraft 1.19 requires running the server with java 17 or above',
];
}
public function getId(): string
{
return 'java_version';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->form([
Placeholder::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),
Select::make('image')
->label('Docker Image')
->disabled(fn () => !in_array($server->image, $server->egg->docker_images))
->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value]))
->selectablePlaceholder(false)
->default(fn () => $server->image)
->notIn(fn () => $server->image)
->required()
->preload()
->native(false),
])
->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server) {
try {
$new = $data['image'];
$original = $server->image;
$server->forceFill(['image' => $new])->saveOrFail();
if ($original !== $server->image) {
Activity::event('server:startup.image')
->property(['old' => $original, 'new' => $new])
->log();
}
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('Docker image updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update docker image')
->body($exception->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Extensions\Features;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonPowerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class MinecraftEula extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'you need to agree to the eula in order to run the server',
];
}
public function getId(): string
{
return 'eula';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>.')))
->modalSubmitActionLabel('I Accept')
->action(function (DaemonFileRepository $fileRepository, DaemonPowerRepository $powerRepository) {
try {
/** @var Server $server */
$server = Filament::getTenant();
$fileRepository->setServer($server)->putContent('eula.txt', 'eula=true');
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('Minecraft EULA accepted')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not accept Minecraft EULA')
->body($exception->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimit extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'pthread_create failed',
'failed to create thread',
'unable to create thread',
'unable to create native thread',
'unable to create new native thread',
'exception in thread "craft async scheduler management thread"',
];
}
public function getId(): string
{
return 'pid_limit';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->icon('tabler-alert-triangle')
->modalHeading(fn () => auth()->user()->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>
<p class="mt-4">
Increasing <code>container_pid_limit</code> in the wings
configuration, <code>config.yml</code>, might help resolve
this issue.
</p>
<p class="mt-4">
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
</p>
HTML
:
<<<'HTML'
<p>
This server is attempting to use more resources than allocated. Please contact the administrator
and give them the error below.
</p>
<p class="mt-4">
<code>
pthread_create failed, Possibly out of memory or process/resource limits reached
</code>
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpace extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'steamcmd needs 250mb of free disk space to update',
'0x202 after update job',
];
}
public function getId(): string
{
return 'steam_disk_space';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.
</p>
<p class="mt-4">
Ensure the machine has enough disk space by typing{' '}
<code class="rounded py-1 px-2">df -h</code> on the machine hosting
this server. Delete files or increase the available disk space to resolve the issue.
</p>
HTML
:
<<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process. Please get in touch with the administrator(s) and inform them of disk space issues.
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
public static function register(Application $app): self
{
return new self($app);
}
}

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

@ -8,7 +8,6 @@ use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade; 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 GitlabProvider extends OAuthProvider final class GitlabProvider extends OAuthProvider
@ -54,8 +53,8 @@ final class GitlabProvider extends OAuthProvider
->label('Redirect URI') ->label('Redirect URI')
->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/gitlab'), ->default(fn () => url('/auth/oauth/callback/gitlab')),
]), ]),
], parent::getSetupSteps()); ], parent::getSetupSteps());
} }

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;
@ -12,6 +13,7 @@ use Filament\Actions\Action;
use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action as FormAction; use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Component; use Filament\Forms\Components\Component;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Group; use Filament\Forms\Components\Group;
use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
@ -32,6 +34,7 @@ use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth; use Filament\Support\Enums\MaxWidth;
use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\Factory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification; use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -134,6 +137,9 @@ class Settings extends Page implements HasForms
->default(env('APP_FAVICON', '/pelican.ico')) ->default(env('APP_FAVICON', '/pelican.ico'))
->placeholder('/pelican.ico'), ->placeholder('/pelican.ico'),
]), ]),
Group::make()
->columns(2)
->schema([
Toggle::make('APP_DEBUG') Toggle::make('APP_DEBUG')
->label(trans('admin/setting.general.debug_mode')) ->label(trans('admin/setting.general.debug_mode'))
->inline(false) ->inline(false)
@ -154,6 +160,27 @@ class Settings extends Page implements HasForms
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))), ->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
]),
Group::make()
->columns(2)
->schema([
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()
@ -175,12 +202,18 @@ class Settings extends Page implements HasForms
->formatStateUsing(fn ($state): int => (int) $state) ->formatStateUsing(fn ($state): int => (int) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state))
->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))), ->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))),
Select::make('FILAMENT_WIDTH')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(MaxWidth::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
TagsInput::make('TRUSTED_PROXIES') TagsInput::make('TRUSTED_PROXIES')
->label(trans('admin/setting.general.trusted_proxies')) ->label(trans('admin/setting.general.trusted_proxies'))
->separator() ->separator()
->splitKeys(['Tab', ' ']) ->splitKeys(['Tab', ' '])
->placeholder(trans('admin/setting.general.trusted_proxies_help')) ->placeholder(trans('admin/setting.general.trusted_proxies_help'))
->default(env('TRUSTED_PROXIES', implode(',', config('trustedproxy.proxies')))) ->default(env('TRUSTED_PROXIES', implode(',', Arr::wrap(config('trustedproxy.proxies')))))
->hintActions([ ->hintActions([
FormAction::make('clear') FormAction::make('clear')
->label(trans('admin/setting.general.clear')) ->label(trans('admin/setting.general.clear'))
@ -215,12 +248,6 @@ class Settings extends Page implements HasForms
$set('TRUSTED_PROXIES', $ips->values()->all()); $set('TRUSTED_PROXIES', $ips->values()->all());
}), }),
]), ]),
Select::make('FILAMENT_WIDTH')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(MaxWidth::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
]; ];
} }
@ -702,10 +729,17 @@ class Settings extends Page implements HasForms
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->columnSpanFull() ->columnSpan(1)
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state))
->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))), ->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))),
FileUpload::make('ConsoleFonts')
->hint(trans('admin/setting.misc.server.console_font_hint'))
->label(trans('admin/setting.misc.server.console_font_upload'))
->directory('fonts')
->columnSpan(1)
->maxFiles(1)
->preserveFilenames(),
]), ]),
Section::make(trans('admin/setting.misc.webhook.title')) Section::make(trans('admin/setting.misc.webhook.title'))
->description(trans('admin/setting.misc.webhook.helper')) ->description(trans('admin/setting.misc.webhook.helper'))
@ -734,6 +768,7 @@ class Settings extends Page implements HasForms
{ {
try { try {
$data = $this->form->getState(); $data = $this->form->getState();
unset($data['ConsoleFonts']);
// Convert bools to a string, so they are correctly written to the .env file // Convert bools to a string, so they are correctly written to the .env file
$data = array_map(fn ($value) => is_bool($value) ? ($value ? 'true' : 'false') : $value, $data); $data = array_map(fn ($value) => is_bool($value) ? ($value ? 'true' : 'false') : $value, $data);

View File

@ -16,6 +16,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction; use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class DatabaseHostResource extends Resource class DatabaseHostResource extends Resource
{ {
@ -27,7 +28,7 @@ class DatabaseHostResource extends Resource
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
{ {
return static::getModel()::count() ?: null; return (string) static::getEloquentQuery()->count() ?: null;
} }
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
@ -144,7 +145,7 @@ class DatabaseHostResource extends Resource
->preload() ->preload()
->helperText(trans('admin/databasehost.linked_nodes_help')) ->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes')) ->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name'), ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
]), ]),
]); ]);
} }
@ -158,4 +159,13 @@ class DatabaseHostResource extends Resource
'edit' => Pages\EditDatabaseHost::route('/{record}/edit'), 'edit' => Pages\EditDatabaseHost::route('/{record}/edit'),
]; ];
} }
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
}
} }

View File

@ -4,14 +4,30 @@ 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\Builder;
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 +39,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', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
]),
]; ];
} }
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

@ -18,6 +18,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction; use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class MountResource extends Resource class MountResource extends Resource
{ {
@ -44,7 +45,7 @@ class MountResource extends Resource
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
{ {
return static::getModel()::count() ?: null; return (string) static::getEloquentQuery()->count() ?: null;
} }
public static function getNavigationGroup(): ?string public static function getNavigationGroup(): ?string
@ -147,7 +148,7 @@ class MountResource extends Resource
->preload(), ->preload(),
Select::make('nodes')->multiple() Select::make('nodes')->multiple()
->label(trans('admin/mount.nodes')) ->label(trans('admin/mount.nodes'))
->relationship('nodes', 'name') ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')))
->searchable(['name', 'fqdn']) ->searchable(['name', 'fqdn'])
->preload(), ->preload(),
]), ]),
@ -170,4 +171,13 @@ class MountResource extends Resource
'edit' => Pages\EditMount::route('/{record}/edit'), 'edit' => Pages\EditMount::route('/{record}/edit'),
]; ];
} }
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
}
} }

View File

@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource\RelationManagers; use App\Filament\Admin\Resources\NodeResource\RelationManagers;
use App\Models\Node; use App\Models\Node;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
class NodeResource extends Resource class NodeResource extends Resource
{ {
@ -32,12 +33,12 @@ 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
{ {
return static::getModel()::count() ?: null; return (string) static::getEloquentQuery()->count() ?: null;
} }
public static function getRelations(): array public static function getRelations(): array
@ -56,4 +57,11 @@ class NodeResource extends Resource
'edit' => Pages\EditNode::route('/{record}/edit'), 'edit' => Pages\EditNode::route('/{record}/edit'),
]; ];
} }
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'));
}
} }

View File

@ -3,9 +3,11 @@
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;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
@ -44,7 +46,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) {
@ -147,14 +150,15 @@ class CreateNode extends CreateRecord
->required() ->required()
->maxLength(100), ->maxLength(100),
ToggleButtons::make('scheme') Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl')) ->label(trans('admin/node.ssl'))
->columnSpan([ ->columnSpan(1)
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->inline() ->inline()
->helperText(function (Get $get) { ->helperText(function (Get $get) {
if (request()->isSecure()) { if (request()->isSecure()) {
@ -167,20 +171,29 @@ class CreateNode extends CreateRecord
return ''; return '';
}) })
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure()) ->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([ ->options([
'http' => 'HTTP', 'http' => 'HTTP',
'https' => 'HTTPS (SSL)', 'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
]) ])
->colors([ ->colors([
'http' => 'warning', 'http' => 'warning',
'https' => 'success', 'https' => 'success',
'https_proxy' => 'success',
]) ])
->icons([ ->icons([
'http' => 'tabler-lock-open-off', 'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock', 'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
]) ])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ->default(fn () => request()->isSecure() ? 'https' : 'http')
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy');
}),
]), ]),
Step::make('advanced') Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings')) ->label(trans('admin/node.tabs.advanced_settings'))

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;
@ -13,6 +14,7 @@ use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions; use Filament\Forms\Components\Actions as FormActions;
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\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab; use Filament\Forms\Components\Tabs\Tab;
@ -26,7 +28,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 +36,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 +111,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) {
@ -196,7 +200,9 @@ class EditNode extends EditRecord
]) ])
->required() ->required()
->maxLength(100), ->maxLength(100),
ToggleButtons::make('scheme') Hidden::make('scheme'),
Hidden::make('behind_proxy'),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl')) ->label(trans('admin/node.ssl'))
->columnSpan(1) ->columnSpan(1)
->inline() ->inline()
@ -211,20 +217,30 @@ class EditNode extends EditRecord
return ''; return '';
}) })
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure()) ->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([ ->options([
'http' => 'HTTP', 'http' => 'HTTP',
'https' => 'HTTPS (SSL)', 'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
]) ])
->colors([ ->colors([
'http' => 'warning', 'http' => 'warning',
'https' => 'success', 'https' => 'success',
'https_proxy' => 'success',
]) ])
->icons([ ->icons([
'http' => 'tabler-lock-open-off', 'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock', 'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
]) ])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]), ->formatStateUsing(fn (Get $get) => $get('scheme') === 'http' ? 'http' : ($get('behind_proxy') ? 'https_proxy' : 'https'))
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy');
}),
]),
Tab::make('adv') Tab::make('adv')
->label(trans('admin/node.tabs.advanced_settings')) ->label(trans('admin/node.tabs.advanced_settings'))
->columns([ ->columns([
@ -596,39 +612,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 +630,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

@ -12,8 +12,7 @@ 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\Tables\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\SelectColumn; use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -32,18 +31,12 @@ class AllocationsRelationManager extends RelationManager
public function setTitle(): string public function setTitle(): string
{ {
return trans('admin/server.allocations'); return trans('admin/server.allocations');
} }
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->recordTitleAttribute('ip') ->recordTitleAttribute('address')
// Non Primary Allocations
// ->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->id !== $allocation->server?->allocation_id)
// All assigned allocations
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null) ->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
->paginationPageOptions(['10', '20', '50', '100', '200', '500']) ->paginationPageOptions(['10', '20', '50', '100', '200', '500'])
->searchable() ->searchable()
@ -72,14 +65,14 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/node.table.ip')), ->label(trans('admin/node.table.ip')),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\Action::make('create new allocation') Action::make('create new allocation')
->label(trans('admin/node.create_allocation')) ->label(trans('admin/node.create_allocation'))
->form(fn () => [ ->form(fn () => [
Select::make('allocation_ip') Select::make('allocation_ip')
->options(collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip])) ->options(collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/node.ip_address')) ->label(trans('admin/node.ip_address'))
->inlineLabel() ->inlineLabel()
->ipv4() ->ip()
->helperText(trans('admin/node.ip_help')) ->helperText(trans('admin/node.ip_help'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', [])) ->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->live() ->live()
@ -96,19 +89,15 @@ class AllocationsRelationManager extends RelationManager
->inlineLabel() ->inlineLabel()
->live() ->live()
->disabled(fn (Get $get) => empty($get('allocation_ip'))) ->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', ->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', CreateServer::retrieveValidPorts($this->getOwnerRecord(), $state, $get('allocation_ip'))))
CreateServer::retrieveValidPorts($this->getOwnerRecord(), $state, $get('allocation_ip')))
)
->splitKeys(['Tab', ' ', ',']) ->splitKeys(['Tab', ' ', ','])
->required(), ->required(),
]) ])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord(), $data)), ->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord(), $data)),
]) ])
->bulkActions([ ->groupedBulkActions([
BulkActionGroup::make([
DeleteBulkAction::make() DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('update node')), ->authorize(fn () => auth()->user()->can('update node')),
]),
]); ]);
} }
} }

View File

@ -27,7 +27,7 @@ class NodeCpuChart extends ChartWidget
$this->cpuHistory = session()->get('cpuHistory', []); $this->cpuHistory = session()->get('cpuHistory', []);
$this->cpuHistory[] = [ $this->cpuHistory[] = [
'cpu' => Number::format($data['cpu_percent'] * $threads, maxPrecision: 2), 'cpu' => round($data['cpu_percent'] * $threads, 2),
'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'), 'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
]; ];

View File

@ -27,9 +27,9 @@ class NodeMemoryChart extends ChartWidget
$this->memoryHistory = session()->get('memoryHistory', []); $this->memoryHistory = session()->get('memoryHistory', []);
$this->memoryHistory[] = [ $this->memoryHistory[] = [
'memory' => Number::format(config('panel.use_binary_prefix') 'memory' => round(config('panel.use_binary_prefix')
? $value / 1024 / 1024 / 1024 ? $value / 1024 / 1024 / 1024
: $value / 1000 / 1000 / 1000, maxPrecision: 2), : $value / 1000 / 1000 / 1000, 2),
'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'), 'timestamp' => now(auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
]; ];

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;
@ -12,6 +10,7 @@ use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Get; use Filament\Forms\Get;
@ -50,7 +49,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
@ -71,6 +70,11 @@ class RoleResource extends Resource
->badge() ->badge()
->counts('permissions') ->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? trans('admin/role.all') : $state), ->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? trans('admin/role.all') : $state),
TextColumn::make('nodes.name')
->icon('tabler-server-2')
->label(trans('admin/role.nodes'))
->badge()
->placeholder(trans('admin/role.all')),
TextColumn::make('users_count') TextColumn::make('users_count')
->label(trans('admin/role.users')) ->label(trans('admin/role.users'))
->counts('users') ->counts('users')
@ -95,32 +99,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,12 +125,20 @@ 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'))
->content(trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN])) ->content(trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]))
->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), ->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Select::make('nodes')
->label(trans('admin/role.nodes'))
->multiple()
->relationship('nodes', 'name')
->searchable(['name', 'fqdn'])
->preload()
->hint(trans('admin/role.nodes_hint'))
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
]); ]);
} }

View File

@ -3,8 +3,12 @@
namespace App\Filament\Admin\Resources; namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\ServerResource\Pages; use App\Filament\Admin\Resources\ServerResource\Pages;
use App\Models\Mount;
use App\Models\Server; use App\Models\Server;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Get;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
class ServerResource extends Resource class ServerResource extends Resource
{ {
@ -31,12 +35,35 @@ 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
{ {
return static::getModel()::count() ?: null; return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getMountCheckboxList(Get $get): CheckboxList
{
$allowedMounts = Mount::all();
$node = $get('node_id');
$egg = $get('egg_id');
if ($node && $egg) {
$allowedMounts = $allowedMounts->filter(fn (Mount $mount) => ($mount->nodes->isEmpty() || $mount->nodes->contains($node)) &&
($mount->eggs->isEmpty() || $mount->eggs->contains($egg))
);
}
return CheckboxList::make('mounts')
->label('')
->relationship('mounts')
->live()
->options(fn () => $allowedMounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn () => $allowedMounts->isEmpty() ? trans('admin/server.no_mounts') : null)
->bulkToggleable()
->columnSpanFull();
} }
public static function getPages(): array public static function getPages(): array
@ -47,4 +74,13 @@ class ServerResource extends Resource
'edit' => Pages\EditServer::route('/{record}/edit'), 'edit' => Pages\EditServer::route('/{record}/edit'),
]; ];
} }
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereHas('node', function (Builder $query) {
$query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'));
});
}
} }

View File

@ -15,7 +15,6 @@ use Closure;
use Exception; use Exception;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component; 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;
@ -109,14 +108,20 @@ class CreateServer extends CreateRecord
->disabledOn('edit') ->disabledOn('edit')
->prefixIcon('tabler-server-2') ->prefixIcon('tabler-server-2')
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->default(fn () => ($this->node = Node::query()->latest()->first())?->id) ->default(function () {
/** @var ?Node $latestNode */
$latestNode = auth()->user()->accessibleNodes()->latest()->first();
$this->node = $latestNode;
return $this->node?->id;
})
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 2, 'sm' => 2,
'md' => 2, 'md' => 2,
]) ])
->live() ->live()
->relationship('node', 'name') ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->searchable() ->searchable()
->preload() ->preload()
->afterStateUpdated(function (Set $set, $state) { ->afterStateUpdated(function (Set $set, $state) {
@ -183,10 +188,7 @@ class CreateServer extends CreateRecord
$set('allocation_additional', null); $set('allocation_additional', null);
$set('allocation_additional.needstobeastringhere.extra_allocations', null); $set('allocation_additional.needstobeastringhere.extra_allocations', null);
}) })
->getOptionLabelFromRecordUsing( ->getOptionLabelFromRecordUsing(fn (Allocation $allocation) => $allocation->address)
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(function (Get $get) { ->placeholder(function (Get $get) {
$node = Node::find($get('node_id')); $node = Node::find($get('node_id'));
@ -212,7 +214,7 @@ class CreateServer extends CreateRecord
->label(trans('admin/server.ip_address'))->inlineLabel() ->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper')) ->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', [])) ->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->ipv4() ->ip()
->live() ->live()
->required(), ->required(),
TextInput::make('allocation_alias') TextInput::make('allocation_alias')
@ -263,10 +265,7 @@ class CreateServer extends CreateRecord
->columnSpan(2) ->columnSpan(2)
->disabled(fn (Get $get) => $get('../../node_id') === null) ->disabled(fn (Get $get) => $get('../../node_id') === null)
->searchable(['ip', 'port', 'ip_alias']) ->searchable(['ip', 'port', 'ip_alias'])
->getOptionLabelFromRecordUsing( ->getOptionLabelFromRecordUsing(fn (Allocation $allocation) => $allocation->address)
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(trans('admin/server.select_additional')) ->placeholder(trans('admin/server.select_additional'))
->disableOptionsWhenSelectedInSiblingRepeaterItems() ->disableOptionsWhenSelectedInSiblingRepeaterItems()
->relationship( ->relationship(
@ -426,7 +425,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)
@ -744,7 +743,7 @@ class CreateServer extends CreateRecord
'lg' => 4, 'lg' => 4,
]) ])
->columnSpan(6) ->columnSpan(6)
->schema([ ->schema(fn (Get $get) => [
Select::make('select_image') Select::make('select_image')
->label(trans('admin/server.image_name')) ->label(trans('admin/server.image_name'))
->live() ->live()
@ -792,19 +791,13 @@ 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'))
->columnSpanFull(), ->columnSpanFull(),
CheckboxList::make('mounts') ServerResource::getMountCheckboxList($get),
->label('Mounts')
->live()
->relationship('mounts')
->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? [])
->descriptions(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]) ?? [])
->helperText(fn () => $this->node?->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->columnSpanFull(),
]), ]),
]), ]),
]) ])

View File

@ -2,16 +2,18 @@
namespace App\Filament\Admin\Resources\ServerResource\Pages; namespace App\Filament\Admin\Resources\ServerResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
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\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 +30,9 @@ 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\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;
@ -48,10 +51,12 @@ use Filament\Forms\Get;
use Filament\Forms\Set; 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 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 +64,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
@ -133,7 +136,39 @@ class EditServer extends EditRecord
'sm' => 1, 'sm' => 1,
'md' => 1, 'md' => 1,
'lg' => 1, 'lg' => 1,
]), ])
->hintAction(
Action::make('view_install_log')
->label(trans('admin/server.view_install_log'))
//->visible(fn (Server $server) => $server->isFailedInstall())
->modalHeading('')
->modalSubmitAction(false)
->modalFooterActionsAlignment(Alignment::Right)
->modalCancelActionLabel(trans('filament::components/modal.actions.close.label'))
->form([
MonacoEditor::make('logs')
->hiddenLabel()
->placeholderText(trans('admin/server.no_log'))
->formatStateUsing(function (Server $server, DaemonServerRepository $serverRepository) {
try {
return $serverRepository->setServer($server)->getInstallLogs();
} catch (ConnectionException) {
Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.notifications.log_failed'))
->color('warning')
->warning()
->send();
} catch (Exception) {
return '';
}
return '';
})
->language('shell')
->view('filament.plugins.monaco-editor-logs'),
])
),
Textarea::make('description') Textarea::make('description')
->label(trans('admin/server.description')) ->label(trans('admin/server.description'))
@ -173,7 +208,7 @@ class EditServer extends EditRecord
->maxLength(255), ->maxLength(255),
Select::make('node_id') Select::make('node_id')
->label(trans('admin/server.node')) ->label(trans('admin/server.node'))
->relationship('node', 'name') ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
'sm' => 1, 'sm' => 1,
@ -482,6 +517,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 +627,7 @@ class EditServer extends EditRecord
]); ]);
} }
return $query; return $query->orderByPowerJoins('variable.sort');
}) })
->grid() ->grid()
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array { ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
@ -646,14 +682,8 @@ class EditServer extends EditRecord
]), ]),
Tab::make(trans('admin/server.mounts')) Tab::make(trans('admin/server.mounts'))
->icon('tabler-layers-linked') ->icon('tabler-layers-linked')
->schema([ ->schema(fn (Get $get) => [
CheckboxList::make('mounts') ServerResource::getMountCheckboxList($get),
->label('')
->relationship('mounts')
->options(fn (Server $server) => $server->node->mounts->filter(fn (Mount $mount) => $mount->eggs->contains($server->egg))->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn (Server $server) => $server->node->mounts->mapWithKeys(fn (Mount $mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : trans('admin/server.no_mounts'))
->columnSpanFull(),
]), ]),
Tab::make(trans('admin/server.databases')) Tab::make(trans('admin/server.databases'))
->hidden(fn () => !auth()->user()->can('viewList database')) ->hidden(fn () => !auth()->user()->can('viewList database'))
@ -686,8 +716,8 @@ class EditServer extends EditRecord
->requiresConfirmation() ->requiresConfirmation()
->modalIcon('tabler-database-x') ->modalIcon('tabler-database-x')
->modalHeading(trans('admin/server.delete_db_heading')) ->modalHeading(trans('admin/server.delete_db_heading'))
->modalSubmitActionLabel(fn (Get $get) => 'Delete ' . $get('database') . '?') ->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->modalDescription(fn (Get $get) => trans('admin/server.delete_db') . $get('database') . '?') ->modalDescription(fn (Get $get) => trans('admin/server.delete_db', ['name' => $get('database')]))
->action(function (DatabaseManagementService $databaseManagementService, $record) { ->action(function (DatabaseManagementService $databaseManagementService, $record) {
$databaseManagementService->delete($record); $databaseManagementService->delete($record);
$this->fillForm(); $this->fillForm();
@ -731,7 +761,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 +829,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->isFailedInstall())
$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->isFailedInstall()) {
try {
$reinstallService->handle($server);
Notification::make()
->title(trans('admin/server.notifications.reinstall_started'))
->success()
->send();
$this->refreshFormData(['status', 'docker']); $this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.notifications.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 +881,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 +889,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()
Notification::make()->success()->title(trans('admin/server.notifications.server_suspended'))->send(); ->title(trans('admin/server.notifications.server_suspended'))
->send();
$this->refreshFormData(['status', 'docker']); $this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.notifications.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 +911,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()
Notification::make()->success()->title(trans('admin/server.notifications.server_unsuspended'))->send(); ->title(trans('admin/server.notifications.server_unsuspended'))
->send();
$this->refreshFormData(['status', 'docker']); $this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->send();
}
}), }),
])->fullWidth(), ])->fullWidth(),
ToggleButtons::make('') ToggleButtons::make('')
@ -855,42 +937,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 +974,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.notifications.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 +1002,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) {
try {
$service->handle($server); $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'))
@ -956,46 +1103,39 @@ class EditServer extends EditRecord
$data['description'] = ''; $data['description'] = '';
} }
unset($data['docker'], $data['status']); unset($data['docker'], $data['status'], $data['allocation_id']);
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
{ {
return [ return [

View File

@ -12,14 +12,17 @@ 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\DissociateAction;
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()
@ -32,15 +35,18 @@ class AllocationsRelationManager extends RelationManager
{ {
return $table return $table
->selectCurrentPageOnly() ->selectCurrentPageOnly()
->recordTitleAttribute('ip') ->recordTitleAttribute('address')
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port") ->recordTitle(fn (Allocation $allocation) => $allocation->address)
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id) ->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server') ->inverseRelationship('server')
->heading(trans('admin/server.allocations')) ->heading(trans('admin/server.allocations'))
->columns([ ->columns([
TextColumn::make('ip')->label(trans('admin/server.ip_address')), TextColumn::make('ip')
TextColumn::make('port')->label(trans('admin/server.port')), ->label(trans('admin/server.ip_address')),
TextInputColumn::make('ip_alias')->label(trans('admin/server.alias')), TextColumn::make('port')
->label(trans('admin/server.port')),
TextInputColumn::make('ip_alias')
->label(trans('admin/server.alias')),
IconColumn::make('primary') IconColumn::make('primary')
->icon(fn ($state) => match ($state) { ->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled', true => 'tabler-star-filled',
@ -56,8 +62,11 @@ class AllocationsRelationManager extends RelationManager
]) ])
->actions([ ->actions([
Action::make('make-primary') Action::make('make-primary')
->label(trans('admin/server.make_primary'))
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : trans('admin/server.make_primary')), ->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
DissociateAction::make()
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
]) ])
->headerActions([ ->headerActions([
CreateAction::make()->label(trans('admin/server.create_allocation')) CreateAction::make()->label(trans('admin/server.create_allocation'))
@ -67,7 +76,8 @@ class AllocationsRelationManager extends RelationManager
->options(collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip])) ->options(collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address')) ->label(trans('admin/server.ip_address'))
->inlineLabel() ->inlineLabel()
->ipv4() ->ip()
->live()
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', [])) ->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(), ->required(),
TextInput::make('allocation_alias') TextInput::make('allocation_alias')
@ -81,9 +91,8 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/server.ports')) ->label(trans('admin/server.ports'))
->inlineLabel() ->inlineLabel()
->live() ->live()
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', ->disabled(fn (Get $get) => empty($get('allocation_ip')))
CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip'))) ->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip'))))
)
->splitKeys(['Tab', ' ', ',']) ->splitKeys(['Tab', ' ', ','])
->required(), ->required(),
]) ])
@ -96,10 +105,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,52 +2,179 @@
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\Permission;
use App\Models\Server; use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository;
use AymanAlhattami\FilamentContextMenu\Columns\ContextMenuTextColumn;
use Filament\Notifications\Notification;
use Filament\Resources\Components\Tab; use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
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;
use Illuminate\Http\Client\ConnectionException;
use Livewire\Attributes\On;
class ListServers extends ListRecords 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;
private DaemonPowerRepository $daemonPowerRepository;
public function boot(): void
{
$this->daemonPowerRepository = new DaemonPowerRepository();
}
public function table(Table $table): Table public function table(Table $table): Table
{ {
$baseQuery = auth()->user()->accessibleServers(); $baseQuery = auth()->user()->accessibleServers();
$menuOptions = function (Server $server) {
$status = $server->retrieveStatus();
return [
Action::make('start')
->color('primary')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->visible(fn () => $status->isStartable())
->dispatch('powerAction', ['server' => $server, 'action' => 'start'])
->icon('tabler-player-play-filled'),
Action::make('restart')
->color('gray')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->visible(fn () => $status->isRestartable())
->dispatch('powerAction', ['server' => $server, 'action' => 'restart'])
->icon('tabler-refresh'),
Action::make('stop')
->color('danger')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $status->isStoppable())
->dispatch('powerAction', ['server' => $server, 'action' => 'stop'])
->icon('tabler-player-stop-filled'),
Action::make('kill')
->color('danger')
->tooltip('This can result in data corruption and/or data loss!')
->dispatch('powerAction', ['server' => $server, 'action' => 'kill'])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $status->isKillable())
->icon('tabler-alert-square'),
];
};
$viewOne = [
ContextMenuTextColumn::make('condition')
->label('')
->default('unknown')
->wrap()
->badge()
->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())
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
];
$viewTwo = [
ContextMenuTextColumn::make('name')
->label('')
->size('md')
->searchable()
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
ContextMenuTextColumn::make('allocation.address')
->label('')
->badge()
->copyable(request()->isSecure())
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
];
$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(
(auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid'
? [
Stack::make([ Stack::make([
ServerEntryColumn::make('server_entry') ServerEntryColumn::make('server_entry')
->searchable(['name']), ->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 +194,71 @@ 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;
}
#[On('powerAction')]
public function powerAction(Server $server, string $action): void
{
try {
$this->daemonPowerRepository->setServer($server)->send($action);
Notification::make()
->title('Power Action')
->body($action . ' sent to ' . $server->name)
->success()
->send();
$this->redirect(self::getUrl(['activeTab' => $this->activeTab]));
} catch (ConnectionException) {
Notification::make()
->title(trans('exceptions.node.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
}
} }

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;
@ -38,6 +40,7 @@ use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
@ -125,6 +128,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 +260,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 +280,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 +327,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 +346,105 @@ 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')
->columns(4)
->schema([
TextInput::make('console_font_size')
->label(trans('profile.font_size'))
->columnSpan(1)
->minValue(1)
->numeric()
->required()
->default(14),
Select::make('console_font')
->label(trans('profile.font'))
->required()
->options(function () {
$fonts = [
'monospace' => 'monospace', //default
];
if (!Storage::disk('public')->exists('fonts')) {
Storage::disk('public')->makeDirectory('fonts');
$this->fillForm();
}
foreach (Storage::disk('public')->allFiles('fonts') as $file) {
$fileInfo = pathinfo($file);
if ($fileInfo['extension'] === 'ttf') {
$fonts[$fileInfo['filename']] = $fileInfo['filename'];
}
}
return $fonts;
})
->reactive()
->default('monospace')
->afterStateUpdated(fn ($state, callable $set) => $set('font_preview', $state)),
Placeholder::make('font_preview')
->label(trans('profile.font_preview'))
->columnSpan(2)
->content(function (Get $get) {
$fontName = $get('console_font') ?? 'monospace';
$fontSize = $get('console_font_size') . 'px';
$fontUrl = asset("storage/fonts/{$fontName}.ttf");
return new HtmlString(<<<HTML
<style>
@font-face {
font-family: "CustomPreviewFont";
src: url("$fontUrl");
}
.preview-text {
font-family: "CustomPreviewFont";
font-size: $fontSize;
margin-top: 10px;
display: block;
}
</style>
<span class="preview-text">The quick blue pelican jumps over the lazy pterodactyl. :)</span>
HTML);
}),
TextInput::make('console_graph_period')
->label(trans('profile.graph_period'))
->suffix(trans('profile.seconds'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('profile.graph_period_helper'))
->columnSpan(2)
->numeric()
->default(30)
->minValue(10)
->maxValue(120)
->required(),
TextInput::make('console_rows')
->label(trans('profile.rows'))
->minValue(1)
->numeric()
->required()
->columnSpan(2)
->default(30),
]),
]),
]), ]),
]) ])
->operation('edit') ->operation('edit')
@ -345,7 +465,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 +501,33 @@ class EditProfile extends BaseEditProfile
]; ];
} }
protected function mutateFormDataBeforeSave(array $data): array
{
$moarbetterdata = [
'console_font' => $data['console_font'],
'console_font_size' => $data['console_font_size'],
'console_rows' => $data['console_rows'],
'console_graph_period' => $data['console_graph_period'],
'dashboard_layout' => $data['dashboard_layout'],
];
unset($data['console_font'],$data['console_font_size'], $data['console_rows'], $data['dashboard_layout']);
$data['customization'] = json_encode($moarbetterdata);
return $data;
}
protected function mutateFormDataBeforeFill(array $data): array
{
$moarbetterdata = json_decode($data['customization'], true);
$data['console_font'] = $moarbetterdata['console_font'] ?? 'monospace';
$data['console_font_size'] = $moarbetterdata['console_font_size'] ?? 14;
$data['console_rows'] = $moarbetterdata['console_rows'] ?? 30;
$data['console_graph_period'] = $moarbetterdata['console_graph_period'] ?? 30;
$data['dashboard_layout'] = $moarbetterdata['dashboard_layout'] ?? 'grid';
return $data;
}
} }

View File

@ -57,11 +57,22 @@ class Login extends BaseLogin
return null; return null;
} }
$isValidToken = false;
if (strlen($token) === $this->google2FA->getOneTimePasswordLength()) {
$isValidToken = $this->google2FA->verifyKey( $isValidToken = $this->google2FA->verifyKey(
$user->totp_secret, $user->totp_secret,
$token, $token,
Config::integer('panel.auth.2fa.window'), Config::integer('panel.auth.2fa.window'),
); );
} else {
foreach ($user->recoveryTokens as $recoveryToken) {
if (password_verify($token, $recoveryToken->token)) {
$isValidToken = true;
$recoveryToken->delete();
break;
}
}
}
if (!$isValidToken) { if (!$isValidToken) {
// Buffer to prevent bruteforce // Buffer to prevent bruteforce
@ -108,7 +119,9 @@ class Login extends BaseLogin
{ {
return TextInput::make('2fa') return TextInput::make('2fa')
->label(trans('auth.two-factor-code')) ->label(trans('auth.two-factor-code'))
->hidden(fn () => !$this->verifyTwoFactor) ->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('auth.two-factor-hint'))
->visible(fn () => $this->verifyTwoFactor)
->required() ->required()
->live(); ->live();
} }

View File

@ -2,18 +2,21 @@
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\Extensions\Features\FeatureProvider;
use App\Filament\Server\Widgets\ServerConsole; use App\Filament\Server\Widgets\ServerConsole;
use App\Filament\Server\Widgets\ServerCpuChart; use App\Filament\Server\Widgets\ServerCpuChart;
use App\Filament\Server\Widgets\ServerMemoryChart; use App\Filament\Server\Widgets\ServerMemoryChart;
// use App\Filament\Server\Widgets\ServerNetworkChart; use App\Filament\Server\Widgets\ServerNetworkChart;
use App\Filament\Server\Widgets\ServerOverview; use App\Filament\Server\Widgets\ServerOverview;
use App\Livewire\AlertBanner; use App\Livewire\AlertBanner;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use Filament\Actions\Action; use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Actions\Action;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Support\Enums\ActionSize; use Filament\Support\Enums\ActionSize;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
@ -22,6 +25,8 @@ use Livewire\Attributes\On;
class Console extends Page class Console extends Page
{ {
use InteractsWithActions;
protected static ?string $navigationIcon = 'tabler-brand-tabler'; protected static ?string $navigationIcon = 'tabler-brand-tabler';
protected static ?int $navigationSort = 1; protected static ?int $navigationSort = 1;
@ -38,14 +43,38 @@ 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();
} }
} }
public function boot(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var FeatureProvider $feature */
foreach ($server->egg->features() as $feature) {
$this->cacheAction($feature->getAction());
}
}
#[On('mount-feature')]
public function mountFeature(string $data): void
{
$data = json_decode($data);
$feature = data_get($data, 'key');
$feature = FeatureProvider::getProviders($feature);
if ($this->getMountedAction()) {
return;
}
$this->mountAction($feature->getId());
sleep(2); // TODO find a better way
}
public function getWidgetData(): array public function getWidgetData(): array
{ {
return [ return [
@ -54,18 +83,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,
]; ]);
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::Bottom->value] ?? []);
return array_unique($allWidgets);
} }
/** /**
@ -102,32 +154,33 @@ class Console extends Page
Action::make('start') Action::make('start')
->color('primary') ->color('primary')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid)) ->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)) ->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)) ->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() ->tooltip('This can result in data corruption and/or data loss!')
->modalHeading('Do you wish to kill this server?')
->modalDescription('This can result in data corruption and/or data loss!')
->modalSubmitActionLabel('Kill Server')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid)) ->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

@ -2,7 +2,6 @@
namespace App\Filament\Server\Resources\DatabaseResource\Pages; namespace App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Facades\Activity;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction; use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn; use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\DatabaseResource; use App\Filament\Server\Resources\DatabaseResource;
@ -82,12 +81,7 @@ class ListDatabases extends ListRecords
ViewAction::make() ViewAction::make()
->modalHeading(fn (Database $database) => 'Viewing ' . $database->database), ->modalHeading(fn (Database $database) => 'Viewing ' . $database->database),
DeleteAction::make() DeleteAction::make()
->after(function (Database $database) { ->using(fn (Database $database, DatabaseManagementService $service) => $service->delete($database)),
Activity::event('server:database.delete')
->subject($database)
->property('name', $database->database)
->log();
}),
]); ]);
} }

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;
@ -24,6 +26,8 @@ use Filament\Resources\Pages\Page;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Support\Enums\Alignment; use Filament\Support\Enums\Alignment;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -45,6 +49,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 +72,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 +92,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 +115,48 @@ 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('<code>' . basename($this->path) . '</code> is too large!')
->body('Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (FileNotFoundException) { } catch (FileNotFoundException) {
abort(404, $this->path . ' not found.'); AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> not found!')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (FileNotEditableException) {
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> is a directory')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (ConnectionException) {
// Alert banner for this one will be handled by ListFiles
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} }
}) })
->language(fn (Get $get) => $get('lang')) ->language(fn (Get $get) => $get('lang'))
@ -149,12 +174,21 @@ 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()
->closable() ->closable()
->send(); ->send();
try {
$this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) {
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
}
} }
} }
@ -200,6 +234,23 @@ 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;
}
/**
* @param array<string, mixed> $parameters
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
{
return parent::getUrl($parameters, $isAbsolute, $panel, $tenant) . '/';
}
public static function route(string $path): PageRegistration public static function route(string $path): PageRegistration
{ {
return new PageRegistration( return new PageRegistration(

View File

@ -12,7 +12,6 @@ use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository; use App\Repositories\Daemon\DaemonFileRepository;
use App\Filament\Components\Tables\Columns\BytesColumn; use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn; use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Livewire\AlertBanner;
use Filament\Actions\Action as HeaderAction; use Filament\Actions\Action as HeaderAction;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
@ -30,16 +29,15 @@ use Filament\Resources\Pages\PageRegistration;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction; use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
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;
@ -48,25 +46,10 @@ class ListFiles extends ListRecords
protected static string $resource = FileResource::class; protected static string $resource = FileResource::class;
#[Locked] #[Locked]
public string $path; public string $path = '/';
private DaemonFileRepository $fileRepository; private DaemonFileRepository $fileRepository;
private bool $isDisabled = false;
public function mount(?string $path = null): void
{
parent::mount();
$this->path = $path ?? '/';
try {
$this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) {
$this->isDisabled = true;
$this->getFailureNotification();
}
}
public function getBreadcrumbs(): array public function getBreadcrumbs(): array
{ {
$resource = static::getResource(); $resource = static::getResource();
@ -92,8 +75,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([
@ -124,21 +107,18 @@ class ListFiles extends ListRecords
->actions([ ->actions([
Action::make('view') Action::make('view')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->disabled($this->isDisabled)
->label('Open') ->label('Open')
->icon('tabler-eye') ->icon('tabler-eye')
->visible(fn (File $file) => $file->is_directory) ->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])), ->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])),
EditAction::make('edit') EditAction::make('edit')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->disabled($this->isDisabled)
->icon('tabler-edit') ->icon('tabler-edit')
->visible(fn (File $file) => $file->canEdit()) ->visible(fn (File $file) => $file->canEdit())
->url(fn (File $file) => EditFiles::getUrl(['path' => join_paths($this->path, $file->name)])), ->url(fn (File $file) => EditFiles::getUrl(['path' => join_paths($this->path, $file->name)])),
ActionGroup::make([ ActionGroup::make([
Action::make('rename') Action::make('rename')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Rename') ->label('Rename')
->icon('tabler-forms') ->icon('tabler-forms')
->form([ ->form([
@ -167,7 +147,6 @@ class ListFiles extends ListRecords
}), }),
Action::make('copy') Action::make('copy')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('Copy') ->label('Copy')
->icon('tabler-copy') ->icon('tabler-copy')
->visible(fn (File $file) => $file->is_file) ->visible(fn (File $file) => $file->is_file)
@ -187,14 +166,12 @@ class ListFiles extends ListRecords
}), }),
Action::make('download') Action::make('download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->disabled($this->isDisabled)
->label('Download') ->label('Download')
->icon('tabler-download') ->icon('tabler-download')
->visible(fn (File $file) => $file->is_file) ->visible(fn (File $file) => $file->is_file)
->url(fn (File $file) => DownloadFiles::getUrl(['path' => join_paths($this->path, $file->name)]), true), ->url(fn (File $file) => DownloadFiles::getUrl(['path' => join_paths($this->path, $file->name)]), true),
Action::make('move') Action::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Move') ->label('Move')
->icon('tabler-replace') ->icon('tabler-replace')
->form([ ->form([
@ -230,7 +207,6 @@ class ListFiles extends ListRecords
}), }),
Action::make('permissions') Action::make('permissions')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Permissions') ->label('Permissions')
->icon('tabler-license') ->icon('tabler-license')
->form([ ->form([
@ -287,19 +263,26 @@ class ListFiles extends ListRecords
}), }),
Action::make('archive') Action::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)
->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();
@ -307,7 +290,6 @@ class ListFiles extends ListRecords
}), }),
Action::make('unarchive') Action::make('unarchive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->label('Unarchive') ->label('Unarchive')
->icon('tabler-archive') ->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive()) ->visible(fn (File $file) => $file->isArchive())
@ -329,7 +311,6 @@ class ListFiles extends ListRecords
]), ]),
DeleteAction::make() DeleteAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->disabled($this->isDisabled)
->label('') ->label('')
->icon('tabler-trash') ->icon('tabler-trash')
->requiresConfirmation() ->requiresConfirmation()
@ -344,11 +325,9 @@ class ListFiles extends ListRecords
->log(); ->log();
}), }),
]) ])
->bulkActions([ ->groupedBulkActions([
BulkActionGroup::make([
BulkAction::make('move') BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->form([ ->form([
TextInput::make('location') TextInput::make('location')
->label('Directory') ->label('Directory')
@ -362,8 +341,7 @@ class ListFiles extends ListRecords
$location = rtrim($data['location'], '/'); $location = rtrim($data['location'], '/');
$files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray(); $files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray();
$this->getDaemonFileRepository() $this->getDaemonFileRepository()->renameFiles($this->path, $files);
->renameFiles($this->path, $files);
Activity::event('server:file.rename') Activity::event('server:file.rename')
->property('directory', $this->path) ->property('directory', $this->path)
@ -377,19 +355,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) ->form([
->action(function (Collection $files) { 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();
@ -397,7 +382,6 @@ class ListFiles extends ListRecords
}), }),
DeleteBulkAction::make() DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->disabled($this->isDisabled)
->action(function (Collection $files) { ->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray(); $files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files); $this->getDaemonFileRepository()->deleteFiles($this->path, $files);
@ -412,7 +396,6 @@ class ListFiles extends ListRecords
->success() ->success()
->send(); ->send();
}), }),
]),
]); ]);
} }
@ -424,7 +407,6 @@ class ListFiles extends ListRecords
return [ return [
HeaderAction::make('new_file') HeaderAction::make('new_file')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('New File') ->label('New File')
->color('gray') ->color('gray')
->keyBindings('') ->keyBindings('')
@ -442,6 +424,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)
@ -454,7 +438,6 @@ class ListFiles extends ListRecords
]), ]),
HeaderAction::make('new_folder') HeaderAction::make('new_folder')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('New Folder') ->label('New Folder')
->color('gray') ->color('gray')
->action(function ($data) { ->action(function ($data) {
@ -471,7 +454,6 @@ class ListFiles extends ListRecords
]), ]),
HeaderAction::make('upload') HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('Upload') ->label('Upload')
->action(function ($data) { ->action(function ($data) {
if (count($data['files']) > 0 && !isset($data['url'])) { if (count($data['files']) > 0 && !isset($data['url'])) {
@ -521,14 +503,14 @@ class ListFiles extends ListRecords
]), ]),
HeaderAction::make('search') HeaderAction::make('search')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server)) ->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->disabled($this->isDisabled)
->label('Global Search') ->label('Global Search')
->modalSubmitActionLabel('Search') ->modalSubmitActionLabel('Search')
->form([ ->form([
TextInput::make('searchTerm') TextInput::make('searchTerm')
->placeholder('Enter a search term, e.g. *.txt') ->placeholder('Enter a search term, e.g. *.txt')
->required()
->regex('/^[^*]*\*?[^*]*$/') ->regex('/^[^*]*\*?[^*]*$/')
->minLength(3), ->minValue(3),
]) ])
->action(fn ($data) => redirect(SearchFiles::getUrl([ ->action(fn ($data) => redirect(SearchFiles::getUrl([
'searchTerm' => $data['searchTerm'], 'searchTerm' => $data['searchTerm'],
@ -563,14 +545,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

@ -12,6 +12,7 @@ use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
class SearchFiles extends ListRecords class SearchFiles extends ListRecords
{ {
@ -22,15 +23,8 @@ class SearchFiles extends ListRecords
#[Locked] #[Locked]
public string $searchTerm; public string $searchTerm;
#[Locked] #[Url]
public string $path; public string $path = '/';
public function mount(?string $searchTerm = null, ?string $path = null): void
{
parent::mount();
$this->searchTerm = $searchTerm;
$this->path = $path ?? '/';
}
public function getBreadcrumbs(): array public function getBreadcrumbs(): array
{ {

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;
@ -20,8 +19,8 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Set; use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Tables\Actions\DeleteAction;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -84,6 +83,35 @@ class UserResource extends Resource
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
$tabs = [];
$permissionsArray = [];
foreach (Permission::permissionData() as $data) {
$options = [];
$descriptions = [];
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/users.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission;
}
$tabs[] = Tab::make(str($data['name'])->headline())
->schema([
Section::make()
->description(trans('server/users.permissions.' . $data['name'] . '_desc'))
->icon($data['icon'])
->schema([
CheckboxList::make($data['name'])
->label('')
->bulkToggleable()
->columns(2)
->options($options)
->descriptions($descriptions),
]),
]);
}
return $table return $table
->paginated(false) ->paginated(false)
->searchable(false) ->searchable(false)
@ -91,21 +119,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 +147,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')
@ -159,67 +187,8 @@ class UserResource extends Resource
Actions::make([ Actions::make([
Action::make('assignAll') Action::make('assignAll')
->label('Assign All') ->label('Assign All')
->action(function (Set $set) { ->action(function (Set $set) use ($permissionsArray) {
$permissions = [ $permissions = $permissionsArray;
'control' => [
'console',
'start',
'stop',
'restart',
],
'user' => [
'read',
'create',
'update',
'delete',
],
'file' => [
'read',
'read-content',
'create',
'update',
'delete',
'archive',
'sftp',
],
'backup' => [
'read',
'create',
'delete',
'download',
'restore',
],
'allocation' => [
'read',
'create',
'update',
'delete',
],
'startup' => [
'read',
'update',
'docker-image',
],
'database' => [
'read',
'create',
'update',
'delete',
'view_password',
],
'schedule' => [
'read',
'create',
'update',
'delete',
],
'settings' => [
'rename',
'reinstall',
'activity',
],
];
foreach ($permissions as $key => $value) { foreach ($permissions as $key => $value) {
$allValues = array_unique($value); $allValues = array_unique($value);
$set($key, $allValues); $set($key, $allValues);
@ -234,20 +203,11 @@ class UserResource extends Resource
]), ]),
Tabs::make() Tabs::make()
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema($tabs),
Tab::make('Console') ]),
->schema([ ])
Section::make() ->mutateRecordDataUsing(function ($data, User $user) use ($server) {
->description(trans('server/users.permissions.control_desc')) $permissionsArray = $server->subusers->where('user_id', $user->id)->first()->permissions;
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->formatStateUsing(function (User $user, Set $set) use ($server) {
$permissionsArray = Subuser::query()
->where('user_id', $user->id)
->where('server_id', $server->id)
->first()
->permissions;
$transformedPermissions = []; $transformedPermissions = [];
@ -257,220 +217,11 @@ class UserResource extends Resource
} }
foreach ($transformedPermissions as $key => $value) { foreach ($transformedPermissions as $key => $value) {
$set($key, $value); $data[$key] = $value;
} }
return $transformedPermissions['control'] ?? []; return $data;
}) }),
->bulkToggleable()
->label('')
->options([
'console' => 'Console',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
])
->descriptions([
'console' => trans('server/users.permissions.control_console'),
'start' => trans('server/users.permissions.control_start'),
'stop' => trans('server/users.permissions.control_stop'),
'restart' => trans('server/users.permissions.control_restart'),
]),
]),
]),
Tab::make('User')
->schema([
Section::make()
->description(trans('server/users.permissions.user_desc'))
->icon('tabler-users')
->schema([
CheckboxList::make('user')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'create' => trans('server/users.permissions.user_create'),
'read' => trans('server/users.permissions.user_read'),
'update' => trans('server/users.permissions.user_update'),
'delete' => trans('server/users.permissions.user_delete'),
]),
]),
]),
Tab::make('File')
->schema([
Section::make()
->description(trans('server/users.permissions.file_desc'))
->icon('tabler-folders')
->schema([
CheckboxList::make('file')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'read-content' => 'Read Content',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'archive' => 'Archive',
'sftp' => 'SFTP',
])
->descriptions([
'create' => trans('server/users.permissions.file_create'),
'read' => trans('server/users.permissions.file_read'),
'read-content' => trans('server/users.permissions.file_read_content'),
'update' => trans('server/users.permissions.file_update'),
'delete' => trans('server/users.permissions.file_delete'),
'archive' => trans('server/users.permissions.file_archive'),
'sftp' => trans('server/users.permissions.file_sftp'),
]),
]),
]),
Tab::make('Backup')
->schema([
Section::make()
->description(trans('server/users.permissions.backup_desc'))
->icon('tabler-download')
->schema([
CheckboxList::make('backup')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'delete' => 'Delete',
'download' => 'Download',
'restore' => 'Restore',
])
->descriptions([
'create' => trans('server/users.permissions.backup_create'),
'read' => trans('server/users.permissions.backup_read'),
'delete' => trans('server/users.permissions.backup_delete'),
'download' => trans('server/users.permissions.backup_download'),
'restore' => trans('server/users.permissions.backup_restore'),
]),
]),
]),
Tab::make('Allocation')
->schema([
Section::make()
->description(trans('server/users.permissions.allocation_desc'))
->icon('tabler-network')
->schema([
CheckboxList::make('allocation')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.allocation_read'),
'create' => trans('server/users.permissions.allocation_create'),
'update' => trans('server/users.permissions.allocation_update'),
'delete' => trans('server/users.permissions.allocation_delete'),
]),
]),
]),
Tab::make('Startup')
->schema([
Section::make()
->description(trans('server/users.permissions.startup_desc'))
->icon('tabler-question-mark')
->schema([
CheckboxList::make('startup')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'update' => 'Update',
'docker-image' => 'Docker Image',
])
->descriptions([
'read' => trans('server/users.permissions.startup_read'),
'update' => trans('server/users.permissions.startup_update'),
'docker-image' => trans('server/users.permissions.startup_docker_image'),
]),
]),
]),
Tab::make('Database')
->schema([
Section::make()
->description(trans('server/users.permissions.database_desc'))
->icon('tabler-database')
->schema([
CheckboxList::make('database')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'view_password' => 'View Password',
])
->descriptions([
'read' => trans('server/users.permissions.database_read'),
'create' => trans('server/users.permissions.database_create'),
'update' => trans('server/users.permissions.database_update'),
'delete' => trans('server/users.permissions.database_delete'),
'view_password' => trans('server/users.permissions.database_view_password'),
]),
]),
]),
Tab::make('Schedule')
->schema([
Section::make()
->description(trans('server/users.permissions.schedule_desc'))
->icon('tabler-clock')
->schema([
CheckboxList::make('schedule')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.schedule_read'),
'create' => trans('server/users.permissions.schedule_create'),
'update' => trans('server/users.permissions.schedule_update'),
'delete' => trans('server/users.permissions.schedule_delete'),
]),
]),
]),
Tab::make('Settings')
->schema([
Section::make()
->description(trans('server/users.permissions.settings_desc'))
->icon('tabler-settings')
->schema([
CheckboxList::make('settings')
->bulkToggleable()
->label('')
->options([
'rename' => 'Rename',
'reinstall' => 'Reinstall',
'activity' => 'Activity',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.activity_desc'),
]),
]),
]),
]),
]),
]),
]); ]);
} }

View File

@ -10,12 +10,13 @@ use App\Services\Subusers\SubuserCreationService;
use Exception; use Exception;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Actions as assignAll;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Actions as assignAll;
use Filament\Forms\Components\CheckboxList; 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;
@ -31,6 +32,35 @@ class ListUsers extends ListRecords
/** @var Server $server */ /** @var Server $server */
$server = Filament::getTenant(); $server = Filament::getTenant();
$tabs = [];
$permissionsArray = [];
foreach (Permission::permissionData() as $data) {
$options = [];
$descriptions = [];
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/users.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission;
}
$tabs[] = Tab::make(str($data['name'])->headline())
->schema([
Section::make()
->description(trans('server/users.permissions.' . $data['name'] . '_desc'))
->icon($data['icon'])
->schema([
CheckboxList::make($data['name'])
->label('')
->bulkToggleable()
->columns(2)
->options($options)
->descriptions($descriptions),
]),
]);
}
return [ return [
Actions\CreateAction::make('invite') Actions\CreateAction::make('invite')
->label('Invite User') ->label('Invite User')
@ -59,72 +89,10 @@ class ListUsers extends ListRecords
assignAll::make([ assignAll::make([
Action::make('assignAll') Action::make('assignAll')
->label('Assign All') ->label('Assign All')
->action(function (Set $set, Get $get) { ->action(function (Set $set, Get $get) use ($permissionsArray) {
$permissions = [ $permissions = $permissionsArray;
'control' => [
'console',
'start',
'stop',
'restart',
],
'user' => [
'read',
'create',
'update',
'delete',
],
'file' => [
'read',
'read-content',
'create',
'update',
'delete',
'archive',
'sftp',
],
'backup' => [
'read',
'create',
'delete',
'download',
'restore',
],
'allocation' => [
'read',
'create',
'update',
'delete',
],
'startup' => [
'read',
'update',
'docker-image',
],
'database' => [
'read',
'create',
'update',
'delete',
'view_password',
],
'schedule' => [
'read',
'create',
'update',
'delete',
],
'settings' => [
'rename',
'reinstall',
],
'activity' => [
'read',
],
];
foreach ($permissions as $key => $value) { foreach ($permissions as $key => $value) {
$currentValues = $get($key) ?? []; $allValues = array_unique($value);
$allValues = array_unique(array_merge($currentValues, $value));
$set($key, $allValues); $set($key, $allValues);
} }
}), }),
@ -137,249 +105,7 @@ class ListUsers extends ListRecords
]), ]),
Tabs::make() Tabs::make()
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema($tabs),
Tabs\Tab::make('Console')
->schema([
Section::make()
->description(trans('server/users.permissions.control_desc'))
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->bulkToggleable()
->label('')
->columns(2)
->options([
'console' => 'Console',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
])
->descriptions([
'console' => trans('server/users.permissions.control_console'),
'start' => trans('server/users.permissions.control_start'),
'stop' => trans('server/users.permissions.control_stop'),
'restart' => trans('server/users.permissions.control_restart'),
]),
]),
]),
Tabs\Tab::make('User')
->schema([
Section::make()
->description(trans('server/users.permissions.user_desc'))
->icon('tabler-users')
->schema([
CheckboxList::make('user')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'create' => trans('server/users.permissions.user_create'),
'read' => trans('server/users.permissions.user_read'),
'update' => trans('server/users.permissions.user_update'),
'delete' => trans('server/users.permissions.user_delete'),
]),
]),
]),
Tabs\Tab::make('File')
->schema([
Section::make()
->description(trans('server/users.permissions.file_desc'))
->icon('tabler-folders')
->schema([
CheckboxList::make('file')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'read-content' => 'Read Content',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'archive' => 'Archive',
'sftp' => 'SFTP',
])
->descriptions([
'create' => trans('server/users.permissions.file_create'),
'read' => trans('server/users.permissions.file_read'),
'read-content' => trans('server/users.permissions.file_read_content'),
'update' => trans('server/users.permissions.file_update'),
'delete' => trans('server/users.permissions.file_delete'),
'archive' => trans('server/users.permissions.file_archive'),
'sftp' => trans('server/users.permissions.file_sftp'),
]),
]),
]),
Tabs\Tab::make('Backup')
->schema([
Section::make()
->description(trans('server/users.permissions.backup_desc'))
->icon('tabler-download')
->schema([
CheckboxList::make('backup')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'delete' => 'Delete',
'download' => 'Download',
'restore' => 'Restore',
])
->descriptions([
'create' => trans('server/users.permissions.backup_create'),
'read' => trans('server/users.permissions.backup_read'),
'delete' => trans('server/users.permissions.backup_delete'),
'download' => trans('server/users.permissions.backup_download'),
'restore' => trans('server/users.permissions.backup_restore'),
]),
]),
]),
Tabs\Tab::make('Allocation')
->schema([
Section::make()
->description(trans('server/users.permissions.allocation_desc'))
->icon('tabler-network')
->schema([
CheckboxList::make('allocation')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.allocation_read'),
'create' => trans('server/users.permissions.allocation_create'),
'update' => trans('server/users.permissions.allocation_update'),
'delete' => trans('server/users.permissions.allocation_delete'),
]),
]),
]),
Tabs\Tab::make('Startup')
->schema([
Section::make()
->description(trans('server/users.permissions.startup_desc'))
->icon('tabler-question-mark')
->schema([
CheckboxList::make('startup')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'update' => 'Update',
'docker-image' => 'Docker Image',
])
->descriptions([
'read' => trans('server/users.permissions.startup_read'),
'update' => trans('server/users.permissions.startup_update'),
'docker-image' => trans('server/users.permissions.startup_docker_image'),
]),
]),
]),
Tabs\Tab::make('Database')
->schema([
Section::make()
->description(trans('server/users.permissions.database_desc'))
->icon('tabler-database')
->schema([
CheckboxList::make('database')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'view_password' => 'View Password',
])
->descriptions([
'read' => trans('server/users.permissions.database_read'),
'create' => trans('server/users.permissions.database_create'),
'update' => trans('server/users.permissions.database_update'),
'delete' => trans('server/users.permissions.database_delete'),
'view_password' => trans('server/users.permissions.database_view_password'),
]),
]),
]),
Tabs\Tab::make('Schedule')
->schema([
Section::make()
->description(trans('server/users.permissions.schedule_desc'))
->icon('tabler-clock')
->schema([
CheckboxList::make('schedule')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.schedule_read'),
'create' => trans('server/users.permissions.schedule_create'),
'update' => trans('server/users.permissions.schedule_update'),
'delete' => trans('server/users.permissions.schedule_delete'),
]),
]),
]),
Tabs\Tab::make('Settings')
->schema([
Section::make()
->description(trans('server/users.permissions.settings_desc'))
->icon('tabler-settings')
->schema([
CheckboxList::make('settings')
->bulkToggleable()
->label('')
->columns(2)
->options([
'rename' => 'Rename',
'reinstall' => 'Reinstall',
'activity' => 'Activity',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.activity_desc'),
]),
]),
]),
Tabs\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'),
]),
]),
]),
]),
]), ]),
]) ])
->modalHeading('Invite User') ->modalHeading('Invite User')

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,6 +4,7 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number; use Illuminate\Support\Number;
@ -16,10 +17,19 @@ class ServerCpuChart extends ChartWidget
public ?Server $server = null; public ?Server $server = null;
public static function canView(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
return !$server->isInConflictState() && !$server->retrieveStatus()->isOffline();
}
protected function getData(): array protected function getData(): array
{ {
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute")) $cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))
->slice(-10) ->slice(-$period)
->map(fn ($value, $key) => [ ->map(fn ($value, $key) => [
'cpu' => Number::format($value, maxPrecision: 2), 'cpu' => Number::format($value, maxPrecision: 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'),

View File

@ -4,6 +4,7 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number; use Illuminate\Support\Number;
@ -16,9 +17,19 @@ class ServerMemoryChart extends ChartWidget
public ?Server $server = null; public ?Server $server = null;
public static function canView(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
return !$server->isInConflictState() && !$server->retrieveStatus()->isOffline();
}
protected function getData(): array protected function getData(): array
{ {
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))->slice(-10) $period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))
->slice(-$period)
->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' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 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'),

View File

@ -4,61 +4,72 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
class ServerNetworkChart extends ChartWidget class ServerNetworkChart extends ChartWidget
{ {
protected static ?string $heading = 'Network';
protected static ?string $pollingInterval = '1s'; protected static ?string $pollingInterval = '1s';
protected static ?string $maxHeight = '300px'; protected static ?string $maxHeight = '200px';
public ?Server $server = null; public ?Server $server = null;
public static function canView(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
return !$server->isInConflictState() && !$server->retrieveStatus()->isOffline();
}
protected function getData(): array protected function getData(): array
{ {
$data = cache()->get("servers.{$this->server->id}.network"); $previous = null;
$rx = collect($data) $period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
->slice(-10) $net = collect(cache()->get("servers.{$this->server->id}.network"))
->map(fn ($value, $key) => [ ->slice(-$period)
'rx' => $value->rx_bytes, ->map(function ($current, $timestamp) use (&$previous) {
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'), $net = null;
])
->all();
$tx = collect($data) if ($previous !== null) {
->slice(-10) $net = [
->map(fn ($value, $key) => [ 'rx' => max(0, $current->rx_bytes - $previous->rx_bytes),
'tx' => $value->rx_bytes, 'tx' => max(0, $current->tx_bytes - $previous->tx_bytes),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'), 'timestamp' => Carbon::createFromTimestamp($timestamp, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
]) ];
}
$previous = $current;
return $net;
})
->all(); ->all();
return [ return [
'datasets' => [ 'datasets' => [
[ [
'label' => 'Inbound', 'label' => 'Inbound',
'data' => array_column($rx, 'rx'), 'data' => array_column($net, 'rx'),
'backgroundColor' => [ 'backgroundColor' => [
'rgba(96, 165, 250, 0.3)', 'rgba(100, 255, 105, 0.5)',
], ],
'tension' => '0.3', 'tension' => '0.3',
'fill' => true, 'fill' => true,
], ],
[ [
'label' => 'Outbound', 'label' => 'Outbound',
'data' => array_column($tx, 'tx'), 'data' => array_column($net, 'tx'),
'backgroundColor' => [ 'backgroundColor' => [
'rgba(165, 96, 250, 0.3)', 'rgba(96, 165, 250, 0.3)',
], ],
'tension' => '0.3', 'tension' => '0.3',
'fill' => true, 'fill' => true,
], ],
], ],
'labels' => array_column($rx, 'timestamp'), 'labels' => array_column($net, 'timestamp'),
]; ];
} }
@ -69,25 +80,38 @@ class ServerNetworkChart extends ChartWidget
protected function getOptions(): RawJs protected function getOptions(): RawJs
{ {
// TODO: use "panel.use_binary_prefix" config value
return RawJs::make(<<<'JS' return RawJs::make(<<<'JS'
{ {
scales: { scales: {
x: { x: {
grid: {
display: false, display: false,
}, },
ticks: {
display: true,
},
display: false, //debug
},
y: { y: {
min: 0,
ticks: { ticks: {
display: true, display: true,
callback(value) {
const bytes = typeof value === 'string' ? parseInt(value, 10) : value;
if (bytes < 1) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const number = Number((bytes / Math.pow(1024, i)).toFixed(2));
return `${number} ${['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'][i]}`;
},
}, },
}, },
} }
} }
JS); JS);
} }
public function getHeading(): string
{
$lastData = collect(cache()->get("servers.{$this->server->id}.network"))->last();
return 'Network - ↓' . convert_bytes_to_readable($lastData->rx_bytes ?? 0) . ' - ↑' . convert_bytes_to_readable($lastData->tx_bytes ?? 0);
}
} }

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

@ -9,6 +9,7 @@ use App\Repositories\Daemon\DaemonPowerRepository;
use App\Http\Controllers\Api\Client\ClientApiController; use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\SendPowerRequest; use App\Http\Requests\Api\Client\Servers\SendPowerRequest;
use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\Client\ConnectionException;
#[Group('Server', weight: 2)] #[Group('Server', weight: 2)]
class PowerController extends ClientApiController class PowerController extends ClientApiController
@ -25,6 +26,8 @@ class PowerController extends ClientApiController
* Send power action * Send power action
* *
* Send a power action to a server. * Send a power action to a server.
*
* @throws ConnectionException
*/ */
public function index(SendPowerRequest $request, Server $server): Response public function index(SendPowerRequest $request, Server $server): Response
{ {

View File

@ -36,26 +36,22 @@ class SettingsController extends ClientApiController
$name = $request->input('name'); $name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description; $description = $request->has('description') ? (string) $request->input('description') : $server->description;
$server->name = $name;
if (config('panel.editable_server_descriptions')) {
$server->description = $description;
}
$server->save();
if ($server->name !== $name) { if ($server->name !== $name) {
Activity::event('server:settings.rename') Activity::event('server:settings.rename')
->property(['old' => $server->name, 'new' => $name]) ->property(['old' => $server->name, 'new' => $name])
->log(); ->log();
$server->name = $name;
} }
if ($server->description !== $description) { if ($server->description !== $description && config('panel.editable_server_descriptions')) {
Activity::event('server:settings.description') Activity::event('server:settings.description')
->property(['old' => $server->description, 'new' => $description]) ->property(['old' => $server->description, 'new' => $description])
->log(); ->log();
$server->description = $description;
} }
$server->save();
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }

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

@ -18,26 +18,7 @@ class StoreNodeRequest extends ApplicationApiRequest
*/ */
public function rules(?array $rules = null): array public function rules(?array $rules = null): array
{ {
return collect($rules ?? Node::getRules())->only([ return collect($rules ?? Node::getRules())->mapWithKeys(function ($value, $key) {
'public',
'name',
'description',
'fqdn',
'scheme',
'behind_proxy',
'maintenance_mode',
'memory',
'memory_overallocate',
'disk',
'disk_overallocate',
'cpu',
'cpu_overallocate',
'upload_size',
'daemon_listen',
'daemon_sftp',
'daemon_sftp_alias',
'daemon_base',
])->mapWithKeys(function ($value, $key) {
return [snake_case($key) => $value]; return [snake_case($key) => $value];
})->toArray(); })->toArray();
} }

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) {
case 'sqlite':
$set('env_database.DB_HOST', null); $set('env_database.DB_HOST', null);
$set('env_database.DB_PORT', null); $set('env_database.DB_PORT', null);
$set('env_database.DB_USERNAME', null); $set('env_database.DB_USERNAME', null);
$set('env_database.DB_PASSWORD', null); $set('env_database.DB_PASSWORD', null);
} else { break;
case 'mariadb':
case 'mysql':
$set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1'); $set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1');
$set('env_database.DB_PORT', $get('env_database.DB_PORT') ?? '3306');
$set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican'); $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>

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