Compare commits

..

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

200 changed files with 2940 additions and 4474 deletions

View File

@ -35,7 +35,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [ 8.2, 8.3, 8.4 ]
php: [8.2, 8.3, 8.4]
steps:
- name: Code Checkout
uses: actions/checkout@v4
@ -68,4 +68,4 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: PHPStan
run: vendor/bin/phpstan --memory-limit=-1 --error-format=github
run: vendor/bin/phpstan --memory-limit=-1

4
.gitignore vendored
View File

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

View File

@ -1,9 +1,16 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Production Dockerfile
##
# If you want to build this locally you want to run `docker build -f Dockerfile.dev`
##
# For those who want to build this Dockerfile themselves, uncomment lines 6-12 and replace "localhost:5000/base-php:$TARGETARCH" on lines 17 and 67 with "base".
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine as base
# 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
@ -75,16 +82,13 @@ 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
# Symlink to env/database path, as www-data won't be able to write to webroot
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/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 \
# Create necessary directories
&& mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \
# Finally allow www-data write permissions where necessary
&& chown -R www-data:www-data /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

View File

@ -1,10 +1,10 @@
# ================================
# Stage 0: Build PHP Base Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
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 install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql
RUN rm /usr/local/bin/install-php-extensions
RUN rm /usr/local/bin/install-php-extensions

View File

@ -1,112 +0,0 @@
# 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
{
$all = Node::all();
$all = Node::query()->count();
if ($all->isEmpty()) {
if ($all === 0) {
$result = Result::make()
->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created'))
->shortSummary(trans('admin/health.results.nodeversions.no_nodes'));
@ -25,18 +25,16 @@ class NodeVersionsCheck extends Check
return $result;
}
$outdated = $all
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && !$this->versionService->isLatestWings($node->systemInformation()['version']))
->count();
$all = $all->count();
$latestVersion = $this->versionService->latestWingsVersion();
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
->count();
$result = Result::make()
->meta([
'all' => $all,
'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]));

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Jobs\NodeStatistics;
use App\Models\ActivityLog;
use App\Models\Webhook;
use Illuminate\Console\Scheduling\Schedule;
@ -30,11 +31,8 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
if (config('cache.default') === 'redis') {
// 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();
}
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags
$schedule->command('cache:prune-stale-tags')->hourly();
// Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();
@ -43,6 +41,8 @@ class Kernel extends ConsoleKernel
$schedule->command(PruneImagesCommand::class)->daily();
$schedule->command(CheckEggUpdatesCommand::class)->hourly();
$schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping();
if (config('backups.prune_age')) {
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted.
$schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes();

View File

@ -1,37 +0,0 @@
<?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

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

View File

@ -14,24 +14,4 @@ enum RolePermissionModels: string
case Server = 'server';
case User = 'user';
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

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

View File

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

View File

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

View File

@ -2,11 +2,11 @@
namespace App\Extensions\Avatar;
use App\Models\User;
use Filament\AvatarProviders\Contracts\AvatarProvider as AvatarProviderContract;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
abstract class AvatarProvider
abstract class AvatarProvider implements AvatarProviderContract
{
/**
* @var array<string, static>
@ -33,8 +33,6 @@ abstract class AvatarProvider
abstract public function getId(): string;
abstract public function get(User $user): ?string;
public function getName(): string
{
return Str::title($this->getId());

View File

@ -4,6 +4,8 @@ namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
class GravatarProvider extends AvatarProvider
{
@ -12,9 +14,10 @@ class GravatarProvider extends AvatarProvider
return 'gravatar';
}
public function get(User $user): string
public function get(Model|Authenticatable $record): string
{
return 'https://gravatar.com/avatar/' . md5($user->email);
/** @var User $record */
return 'https://gravatar.com/avatar/' . md5($record->email);
}
public static function register(): self

View File

@ -0,0 +1,29 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use Filament\AvatarProviders\UiAvatarsProvider as FilamentUiAvatarsProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class LocalAvatarProvider extends AvatarProvider
{
public function getId(): string
{
return 'local';
}
public function get(Model|Authenticatable $record): string
{
$path = 'avatars/' . $record->getKey() . '.png';
return Storage::disk('public')->exists($path) ? Storage::url($path) : (new FilamentUiAvatarsProvider())->get($record);
}
public static function register(): self
{
return new self();
}
}

View File

@ -3,7 +3,9 @@
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
use Filament\AvatarProviders\UiAvatarsProvider as FilamentUiAvatarsProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
class UiAvatarsProvider extends AvatarProvider
{
@ -17,10 +19,9 @@ class UiAvatarsProvider extends AvatarProvider
return 'UI Avatars';
}
public function get(User $user): ?string
public function get(Model|Authenticatable $record): string
{
// UI Avatars is the default of filament so just return null here
return null;
return (new FilamentUiAvatarsProvider())->get($record);
}
public static function register(): self

View File

@ -0,0 +1,35 @@
<?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

@ -1,51 +0,0 @@
<?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

@ -1,127 +0,0 @@
<?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

@ -1,100 +0,0 @@
<?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

@ -1,71 +0,0 @@
<?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

@ -1,76 +0,0 @@
<?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

@ -1,64 +0,0 @@
<?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

@ -13,7 +13,6 @@ use Filament\Actions\Action;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
@ -34,7 +33,6 @@ use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Str;
@ -138,7 +136,8 @@ class Settings extends Page implements HasForms
->placeholder('/pelican.ico'),
]),
Group::make()
->columns(2)
->columnSpan(2)
->columns(4)
->schema([
Toggle::make('APP_DEBUG')
->label(trans('admin/setting.general.debug_mode'))
@ -160,26 +159,13 @@ class Settings extends Page implements HasForms
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
]),
Group::make()
->columns(2)
->schema([
Select::make('FILAMENT_AVATAR_PROVIDER')
->label(trans('admin/setting.general.avatar_provider'))
->columnSpan(2)
->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')
->label(trans('admin/setting.general.unit_prefix'))
@ -202,18 +188,12 @@ class Settings extends Page implements HasForms
->formatStateUsing(fn ($state): int => (int) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state))
->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')
->label(trans('admin/setting.general.trusted_proxies'))
->separator()
->splitKeys(['Tab', ' '])
->placeholder(trans('admin/setting.general.trusted_proxies_help'))
->default(env('TRUSTED_PROXIES', implode(',', Arr::wrap(config('trustedproxy.proxies')))))
->default(env('TRUSTED_PROXIES', implode(',', config('trustedproxy.proxies'))))
->hintActions([
FormAction::make('clear')
->label(trans('admin/setting.general.clear'))
@ -248,6 +228,12 @@ class Settings extends Page implements HasForms
$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'))),
];
}
@ -630,6 +616,7 @@ class Settings extends Page implements HasForms
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))),
@ -640,6 +627,7 @@ class Settings extends Page implements HasForms
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),
@ -727,17 +715,10 @@ class Settings extends Page implements HasForms
->onColor('success')
->offColor('danger')
->live()
->columnSpan(1)
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (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'))),
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'))
->description(trans('admin/setting.misc.webhook.helper'))
@ -766,7 +747,6 @@ class Settings extends Page implements HasForms
{
try {
$data = $this->form->getState();
unset($data['ConsoleFonts']);
// 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);

View File

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

View File

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

View File

@ -17,7 +17,6 @@ use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
@ -146,7 +145,7 @@ class CreateDatabaseHost extends CreateRecord
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
->relationship('nodes', 'name'),
]),
];
}

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ use App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource\RelationManagers;
use App\Models\Node;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
class NodeResource extends Resource
{
@ -33,12 +32,12 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string
{
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
return trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->count() ?: null;
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
@ -57,11 +56,4 @@ class NodeResource extends Resource
'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

@ -7,7 +7,6 @@ use App\Models\Node;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
@ -124,10 +123,15 @@ class CreateNode extends CreateRecord
'lg' => 1,
]),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help'))
TextInput::make('daemon_listen')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('admin/node.port'))
->helperText(trans('admin/node.port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
@ -145,15 +149,14 @@ class CreateNode extends CreateRecord
->required()
->maxLength(100),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
ToggleButtons::make('connection')
ToggleButtons::make('scheme')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->inline()
->helperText(function (Get $get) {
if (request()->isSecure()) {
@ -166,43 +169,20 @@ class CreateNode extends CreateRecord
return '';
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
])
->colors([
'http' => 'warning',
'https' => 'success',
'https_proxy' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
])
->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');
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
->default(fn () => request()->isSecure() ? 'https' : 'http'),
]),
Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings'))
@ -418,13 +398,4 @@ class CreateNode extends CreateRecord
{
return [];
}
protected function mutateFormDataBeforeCreate(array $data): array
{
if (!$data['behind_proxy']) {
$data['daemon_listen'] = $data['daemon_connect'];
}
return $data;
}
}

View File

@ -14,7 +14,6 @@ use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
@ -181,10 +180,10 @@ class EditNode extends EditRecord
false => 'danger',
])
->columnSpan(1),
TextInput::make('daemon_connect')
TextInput::make('daemon_listen')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help'))
->label(trans('admin/node.port'))
->helperText(trans('admin/node.port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
@ -200,9 +199,7 @@ class EditNode extends EditRecord
])
->required()
->maxLength(100),
Hidden::make('scheme'),
Hidden::make('behind_proxy'),
ToggleButtons::make('connection')
ToggleButtons::make('scheme')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->inline()
@ -217,43 +214,20 @@ class EditNode extends EditRecord
return '';
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
])
->colors([
'http' => 'warning',
'https' => 'success',
'https_proxy' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
])
->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');
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
]),
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
Tab::make('adv')
->label(trans('admin/node.tabs.advanced_settings'))
->columns([
@ -640,15 +614,6 @@ class EditNode extends EditRecord
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (!$data['behind_proxy']) {
$data['daemon_listen'] = $data['daemon_connect'];
}
return $data;
}
protected function afterSave(): void
{
$this->fillForm();

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
@ -49,7 +48,7 @@ class RoleResource extends Resource
public static function getNavigationGroup(): ?string
{
return config('panel.filament.top-navigation', false) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
return trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@ -70,11 +69,6 @@ class RoleResource extends Resource
->badge()
->counts('permissions')
->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')
->label(trans('admin/role.users'))
->counts('users')
@ -131,14 +125,6 @@ class RoleResource extends Resource
->label(trans('admin/role.permissions'))
->content(trans('admin/role.root_admin', ['role' => 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),
]);
}
@ -147,8 +133,6 @@ class RoleResource extends Resource
*/
private static function makeSection(string $model, array $options): Section
{
$model = ucwords($model);
$icon = null;
if (class_exists('\App\Filament\Admin\Resources\\' . $model . 'Resource')) {

View File

@ -3,12 +3,8 @@
namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\ServerResource\Pages;
use App\Models\Mount;
use App\Models\Server;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
class ServerResource extends Resource
{
@ -35,35 +31,12 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string
{
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
return trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
{
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();
return static::getModel()::count() ?: null;
}
public static function getPages(): array
@ -74,11 +47,4 @@ class ServerResource extends Resource
'edit' => Pages\EditServer::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereIn('node_id', auth()->user()->accessibleNodes()->pluck('id'));
}
}

View File

@ -15,6 +15,7 @@ use Closure;
use Exception;
use Filament\Forms;
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\Grid;
@ -108,20 +109,14 @@ class CreateServer extends CreateRecord
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->selectablePlaceholder(false)
->default(function () {
/** @var ?Node $latestNode */
$latestNode = auth()->user()->accessibleNodes()->latest()->first();
$this->node = $latestNode;
return $this->node?->id;
})
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->live()
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->relationship('node', 'name')
->searchable()
->preload()
->afterStateUpdated(function (Set $set, $state) {
@ -144,7 +139,6 @@ class CreateServer extends CreateRecord
->relationship('user', 'username')
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)")
->createOptionAction(fn (Action $action) => $action->authorize(fn () => auth()->user()->can('create', User::class)))
->createOptionForm([
TextInput::make('username')
->label(trans('admin/user.username'))
@ -189,7 +183,10 @@ class CreateServer extends CreateRecord
$set('allocation_additional', null);
$set('allocation_additional.needstobeastringhere.extra_allocations', null);
})
->getOptionLabelFromRecordUsing(fn (Allocation $allocation) => $allocation->address)
->getOptionLabelFromRecordUsing(
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(function (Get $get) {
$node = Node::find($get('node_id'));
@ -206,7 +203,6 @@ class CreateServer extends CreateRecord
->where('node_id', $get('node_id'))
->whereNull('server_id'),
)
->createOptionAction(fn (Action $action) => $action->authorize(fn (Get $get) => auth()->user()->can('create', Node::find($get('node_id')))))
->createOptionForm(function (Get $get) {
$getPage = $get;
@ -216,7 +212,7 @@ class CreateServer extends CreateRecord
->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->ip()
->ipv4()
->live()
->required(),
TextInput::make('allocation_alias')
@ -267,7 +263,10 @@ class CreateServer extends CreateRecord
->columnSpan(2)
->disabled(fn (Get $get) => $get('../../node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
->getOptionLabelFromRecordUsing(fn (Allocation $allocation) => $allocation->address)
->getOptionLabelFromRecordUsing(
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(trans('admin/server.select_additional'))
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->relationship(
@ -427,7 +426,7 @@ class CreateServer extends CreateRecord
Repeater::make('server_variables')
->label('')
->relationship('serverVariables', fn (Builder $query) => $query->orderByPowerJoins('variable.sort'))
->relationship('serverVariables')
->saveRelationshipsBeforeChildrenUsing(null)
->saveRelationshipsUsing(null)
->grid(2)
@ -745,7 +744,7 @@ class CreateServer extends CreateRecord
'lg' => 4,
])
->columnSpan(6)
->schema(fn (Get $get) => [
->schema([
Select::make('select_image')
->label(trans('admin/server.image_name'))
->live()
@ -793,13 +792,19 @@ class CreateServer extends CreateRecord
]),
KeyValue::make('docker_labels')
->live()
->label('Container Labels')
->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description'))
->columnSpanFull(),
ServerResource::getMountCheckboxList($get),
CheckboxList::make('mounts')
->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,7 +2,7 @@
namespace App\Filament\Admin\Resources\ServerResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Enums\ServerState;
use App\Enums\SuspendAction;
use App\Filament\Admin\Resources\ServerResource;
use App\Filament\Admin\Resources\ServerResource\RelationManagers\AllocationsRelationManager;
@ -13,6 +13,7 @@ use App\Models\Allocation;
use App\Models\Database;
use App\Models\DatabaseHost;
use App\Models\Egg;
use App\Models\Mount;
use App\Models\Node;
use App\Models\Server;
use App\Models\ServerVariable;
@ -32,6 +33,7 @@ use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
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\Grid;
@ -51,7 +53,6 @@ use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
@ -136,39 +137,7 @@ class EditServer extends EditRecord
'sm' => 1,
'md' => 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')
->label(trans('admin/server.description'))
@ -208,7 +177,7 @@ class EditServer extends EditRecord
->maxLength(255),
Select::make('node_id')
->label(trans('admin/server.node'))
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->relationship('node', 'name')
->columnSpan([
'default' => 2,
'sm' => 1,
@ -517,7 +486,6 @@ class EditServer extends EditRecord
]),
KeyValue::make('docker_labels')
->live()
->label(trans('admin/server.container_labels'))
->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description'))
@ -627,7 +595,9 @@ class EditServer extends EditRecord
]);
}
return $query->orderByPowerJoins('variable.sort');
return $query
->join('egg_variables', 'server_variables.variable_id', '=', 'egg_variables.id')
->orderBy('egg_variables.sort');
})
->grid()
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
@ -682,11 +652,17 @@ class EditServer extends EditRecord
]),
Tab::make(trans('admin/server.mounts'))
->icon('tabler-layers-linked')
->schema(fn (Get $get) => [
ServerResource::getMountCheckboxList($get),
->schema([
CheckboxList::make('mounts')
->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'))
->hidden(fn () => !auth()->user()->can('viewAny', Database::class))
->hidden(fn () => !auth()->user()->can('viewList database'))
->icon('tabler-database')
->columns(4)
->schema([
@ -710,14 +686,14 @@ class EditServer extends EditRecord
->hintAction(
Action::make('Delete')
->label(trans('filament-actions::delete.single.modal.actions.delete.label'))
->authorize(fn (Database $database) => auth()->user()->can('delete', $database))
->authorize(fn (Database $database) => auth()->user()->can('delete database', $database))
->color('danger')
->icon('tabler-trash')
->requiresConfirmation()
->modalIcon('tabler-database-x')
->modalHeading(trans('admin/server.delete_db_heading'))
->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->modalDescription(fn (Get $get) => trans('admin/server.delete_db', ['name' => $get('database')]))
->modalSubmitActionLabel(fn (Get $get) => 'Delete ' . $get('database') . '?')
->modalDescription(fn (Get $get) => trans('admin/server.delete_db') . $get('database') . '?')
->action(function (DatabaseManagementService $databaseManagementService, $record) {
$databaseManagementService->delete($record);
$this->fillForm();
@ -763,7 +739,7 @@ class EditServer extends EditRecord
->columnSpan(4),
FormActions::make([
Action::make('createDatabase')
->authorize(fn () => auth()->user()->can('create', Database::class))
->authorize(fn () => auth()->user()->can('create database'))
->disabled(fn () => DatabaseHost::query()->count() < 1)
->label(fn () => DatabaseHost::query()->count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database'))
->color(fn () => DatabaseHost::query()->count() < 1 ? 'danger' : 'primary')
@ -833,12 +809,12 @@ class EditServer extends EditRecord
Action::make('toggleInstall')
->label(trans('admin/server.toggle_install'))
->disabled(fn (Server $server) => $server->isSuspended())
->modal(fn (Server $server) => $server->isFailedInstall())
->modal(fn (Server $server) => $server->status === ServerState::InstallFailed)
->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()) {
if ($server->status === ServerState::InstallFailed) {
try {
$reinstallService->handle($server);
@ -851,7 +827,7 @@ class EditServer extends EditRecord
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
@ -900,7 +876,7 @@ class EditServer extends EditRecord
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->send();
}
}),
@ -922,7 +898,7 @@ class EditServer extends EditRecord
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->send();
}
}),
@ -987,7 +963,7 @@ class EditServer extends EditRecord
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
@ -1065,7 +1041,7 @@ class EditServer extends EditRecord
}
})
->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete', $server)),
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('ForceDelete')
->color('danger')
->label(trans('filament-actions::force-delete.single.label'))
@ -1082,7 +1058,7 @@ class EditServer extends EditRecord
}
})
->visible(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete', $server)),
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console')
->label(trans('admin/server.console'))
->icon('tabler-terminal')
@ -1103,7 +1079,7 @@ class EditServer extends EditRecord
$data['description'] = '';
}
unset($data['docker'], $data['status'], $data['allocation_id']);
unset($data['docker'], $data['status']);
return $data;
}

View File

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

View File

@ -16,7 +16,6 @@ use Filament\Support\Exceptions\Halt;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AssociateAction;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DissociateAction;
use Filament\Tables\Actions\DissociateBulkAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
@ -35,18 +34,15 @@ class AllocationsRelationManager extends RelationManager
{
return $table
->selectCurrentPageOnly()
->recordTitleAttribute('address')
->recordTitle(fn (Allocation $allocation) => $allocation->address)
->recordTitleAttribute('ip')
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server')
->heading(trans('admin/server.allocations'))
->columns([
TextColumn::make('ip')
->label(trans('admin/server.ip_address')),
TextColumn::make('port')
->label(trans('admin/server.port')),
TextInputColumn::make('ip_alias')
->label(trans('admin/server.alias')),
TextColumn::make('ip')->label(trans('admin/server.ip_address')),
TextColumn::make('port')->label(trans('admin/server.port')),
TextInputColumn::make('ip_alias')->label(trans('admin/server.alias')),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled',
@ -62,11 +58,8 @@ class AllocationsRelationManager extends RelationManager
])
->actions([
Action::make('make-primary')
->label(trans('admin/server.make_primary'))
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
DissociateAction::make()
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : trans('admin/server.make_primary')),
])
->headerActions([
CreateAction::make()->label(trans('admin/server.create_allocation'))
@ -76,8 +69,7 @@ class AllocationsRelationManager extends RelationManager
->options(collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))
->inlineLabel()
->ip()
->live()
->ipv4()
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')
@ -91,8 +83,9 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/server.ports'))
->inlineLabel()
->live()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', 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', ' ', ','])
->required(),
])

View File

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

View File

@ -6,23 +6,15 @@ use App\Enums\ServerResourceType;
use App\Filament\App\Resources\ServerResource;
use App\Filament\Components\Tables\Columns\ServerEntryColumn;
use App\Filament\Server\Pages\Console;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository;
use Filament\Notifications\Notification;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Columns\Column;
use Filament\Tables\Columns\ColumnGroup;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\ConnectionException;
use Livewire\Attributes\On;
class ListServers extends ListRecords
{
@ -32,80 +24,85 @@ class ListServers extends ListRecords
public const WARNING_THRESHOLD = 0.7;
private DaemonPowerRepository $daemonPowerRepository;
public function boot(): void
public function table(Table $table): Table
{
$this->daemonPowerRepository = new DaemonPowerRepository();
}
$baseQuery = auth()->user()->accessibleServers();
/** @return Stack[] */
protected function gridColumns(): array
{
return [
Stack::make([
ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
];
}
/** @return Column[] */
protected function tableColumns(): array
{
return [
$viewOne = [
TextColumn::make('condition')
->label('Status')
->label('')
->default('unknown')
->wrap()
->badge()
->alignCenter()
->tooltip(fn (Server $server) => $server->formatResource('uptime', type: ServerResourceType::Time))
->icon(fn (Server $server) => $server->condition->getIcon())
->color(fn (Server $server) => $server->condition->getColor()),
];
$viewTwo = [
TextColumn::make('name')
->label('Server')
->description(fn (Server $server) => $server->description)
->grow()
->label('')
->size('md')
->searchable(),
TextColumn::make('allocation.address')
TextColumn::make('')
->label('')
->badge()
->visibleFrom('md')
->copyable(request()->isSecure()),
->copyable(request()->isSecure())
->copyMessage(fn (Server $server, string $state) => 'Copied ' . $server->allocation->address)
->state(fn (Server $server) => $server->allocation->address),
];
$viewThree = [
TextColumn::make('cpuUsage')
->label('Resources')
->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-device-desktop-analytics')
->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-sd-card')
->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')),
];
}
public function table(Table $table): Table
{
$baseQuery = auth()->user()->accessibleServers();
$usingGrid = (auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid';
return $table
->paginated(false)
->query(fn () => $baseQuery)
->poll('15s')
->columns($usingGrid ? $this->gridColumns() : $this->tableColumns())
->recordUrl(!$usingGrid ? (fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server)) : null)
->actions(!$usingGrid ? ActionGroup::make(static::getPowerActions()) : [])
->actionsAlignment(Alignment::Center->value)
->contentGrid($usingGrid ? ['default' => 1, 'md' => 2] : null)
->columns(
(auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid'
? [
Stack::make([
ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
]
: [
ColumnGroup::make('Status')
->label('Status')
->columns($viewOne),
ColumnGroup::make('Server')
->label('Servers')
->columns($viewTwo),
ColumnGroup::make('Resources')
->label('Resources')
->columns($viewThree),
]
)
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->contentGrid([
'default' => 1,
'md' => 2,
])
->emptyStateIcon('tabler-brand-docker')
->emptyStateDescription('')
->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
@ -148,33 +145,36 @@ class ListServers extends ListRecords
];
}
protected function getResourceColor(Server $server, string $resource): ?string
public function getResourceColor(Server $server, string $resource): ?string
{
$current = null;
$limit = null;
switch ($resource) {
case 'cpu':
$current = $server->retrieveResources()['cpu_absolute'] ?? 0;
$current = $server->resources()['cpu_absolute'] ?? 0;
$limit = $server->cpu;
if ($server->cpu === 0) {
return null;
}
break;
case 'memory':
$current = $server->retrieveResources()['memory_bytes'] ?? 0;
$current = $server->resources()['memory_bytes'] ?? 0;
$limit = $server->memory * 2 ** 20;
if ($server->memory === 0) {
return null;
}
break;
case 'disk':
$current = $server->retrieveResources()['disk_bytes'] ?? 0;
$current = $server->resources()['disk_bytes'] ?? 0;
$limit = $server->disk * 2 ** 20;
if ($server->disk === 0) {
return null;
}
break;
default:
return null;
}
@ -188,60 +188,6 @@ class ListServers extends ListRecords
}
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();
cache()->forget("servers.$server->uuid.status");
$this->redirect(self::getUrl(['activeTab' => $this->activeTab]));
} catch (ConnectionException) {
Notification::make()
->title(trans('exceptions.node.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
}
/** @return Action[] */
public static function getPowerActions(): array
{
return [
Action::make('start')
->color('primary')
->icon('tabler-player-play-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isStartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'start']),
Action::make('restart')
->color('gray')
->icon('tabler-reload')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isRestartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'restart']),
Action::make('stop')
->color('danger')
->icon('tabler-player-stop-filled')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isStoppable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'stop']),
Action::make('kill')
->color('danger')
->icon('tabler-alert-square')
->tooltip('This can result in data corruption and/or data loss!')
->authorize(fn (Server $server) => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn (Server $server) => !$server->isInConflictState() & $server->retrieveStatus()->isKillable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'kill']),
];
}
}

View File

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

View File

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

View File

@ -32,7 +32,6 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Pages\Auth\EditProfile as BaseEditProfile;
use Filament\Support\Colors\Color;
@ -41,7 +40,6 @@ use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password;
use Laravel\Socialite\Facades\Socialite;
@ -130,20 +128,10 @@ class EditProfile extends BaseEditProfile
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
->native(false),
FileUpload::make('avatar')
->visible(fn () => config('panel.filament.uploadable-avatars'))
->visible(fn () => config('panel.filament.avatar-provider') === 'local')
->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));
}),
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png'),
]),
Tab::make(trans('profile.tabs.oauth'))
@ -289,8 +277,6 @@ class EditProfile extends BaseEditProfile
);
Activity::event('user:api-key.create')
->actor($user)
->subject($user)
->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier)
->log();
@ -369,8 +355,18 @@ class EditProfile extends BaseEditProfile
Section::make(trans('profile.console'))
->collapsible()
->icon('tabler-brand-tabler')
->columns(4)
->schema([
TextInput::make('console_rows')
->label(trans('profile.rows'))
->minValue(1)
->numeric()
->required()
->columnSpan(1)
->default(30),
// Select::make('console_font')
// ->label(trans('profile.font'))
// ->hidden() //TODO
// ->columnSpan(1),
TextInput::make('console_font_size')
->label(trans('profile.font_size'))
->columnSpan(1)
@ -378,82 +374,6 @@ class EditProfile extends BaseEditProfile
->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, Set $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';
$style = <<<CSS
.preview-text {
font-family: $fontName;
font-size: $fontSize;
margin-top: 10px;
display: block;
}
CSS;
if ($fontName !== 'monospace') {
$fontUrl = asset("storage/fonts/$fontName.ttf");
$style = <<<CSS
@font-face {
font-family: $fontName;
src: url("$fontUrl");
}
$style
CSS;
}
return new HtmlString(<<<HTML
<style>
{$style}
</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),
]),
]),
]),
@ -516,14 +436,12 @@ 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']);
unset($data['dashboard_layout'], $data['console_font_size'], $data['console_rows']);
$data['customization'] = json_encode($moarbetterdata);
return $data;
@ -533,10 +451,8 @@ class EditProfile extends BaseEditProfile
{
$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

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

View File

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

View File

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

View File

@ -5,18 +5,16 @@ namespace App\Filament\Server\Pages;
use App\Enums\ConsoleWidgetPosition;
use App\Enums\ContainerStatus;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Extensions\Features\FeatureProvider;
use App\Filament\Server\Widgets\ServerConsole;
use App\Filament\Server\Widgets\ServerCpuChart;
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\Livewire\AlertBanner;
use App\Models\Permission;
use App\Models\Server;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Facades\Filament;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Support\Enums\ActionSize;
use Filament\Widgets\Widget;
@ -25,8 +23,6 @@ use Livewire\Attributes\On;
class Console extends Page
{
use InteractsWithActions;
protected static ?string $navigationIcon = 'tabler-brand-tabler';
protected static ?int $navigationSort = 1;
@ -51,30 +47,6 @@ class Console extends Page
}
}
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
{
return [
@ -112,7 +84,7 @@ class Console extends Page
$allWidgets = array_merge($allWidgets, [
ServerCpuChart::class,
ServerMemoryChart::class,
ServerNetworkChart::class,
//ServerNetworkChart::class, TODO: convert units.
]);
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::Bottom->value] ?? []);
@ -154,30 +126,33 @@ class Console extends Page
Action::make('start')
->color('primary')
->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'start', 'uuid' => $server->uuid])
->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable())
->icon('tabler-player-play-filled'),
Action::make('restart')
->color('gray')
->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'restart', 'uuid' => $server->uuid])
->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable())
->icon('tabler-reload'),
Action::make('stop')
->color('danger')
->size(ActionSize::ExtraLarge)
->dispatch('setServerState', ['state' => 'stop', 'uuid' => $server->uuid])
->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable())
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable())
->icon('tabler-player-stop-filled'),
Action::make('kill')
->color('danger')
->tooltip('This can result in data corruption and/or data loss!')
->requiresConfirmation()
->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)
->dispatch('setServerState', ['state' => 'kill', 'uuid' => $server->uuid])
->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable())
->icon('tabler-alert-square'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,8 +26,6 @@ use Filament\Resources\Pages\Page;
use Filament\Resources\Pages\PageRegistration;
use Filament\Support\Enums\Alignment;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
@ -130,33 +128,31 @@ class EditFiles extends Page
return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
} catch (FileSizeTooLargeException) {
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> is too large!')
->body('Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
->title('File too large!')
->body('<code>' . $this->path . '</code> Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
$this->redirect(ListFiles::getUrl());
} catch (FileNotFoundException) {
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> not found!')
->title('File Not found!')
->body('<code>' . $this->path . '</code>')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
$this->redirect(ListFiles::getUrl());
} catch (FileNotEditableException) {
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> is a directory')
->title('Could not edit directory!')
->body('<code>' . $this->path . '</code>')
->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)]));
$this->redirect(ListFiles::getUrl());
}
})
->language(fn (Get $get) => $get('lang'))
@ -180,15 +176,6 @@ class EditFiles extends Page
->info()
->closable()
->send();
try {
$this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) {
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
}
}
}
@ -243,14 +230,6 @@ class EditFiles extends Page
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
{
return new PageRegistration(

View File

@ -12,6 +12,7 @@ use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Livewire\AlertBanner;
use Filament\Actions\Action as HeaderAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
@ -29,12 +30,14 @@ use Filament\Resources\Pages\PageRegistration;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Carbon;
@ -46,10 +49,30 @@ class ListFiles extends ListRecords
protected static string $resource = FileResource::class;
#[Locked]
public string $path = '/';
public string $path;
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;
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
}
}
public function getBreadcrumbs(): array
{
$resource = static::getResource();
@ -107,18 +130,21 @@ class ListFiles extends ListRecords
->actions([
Action::make('view')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->disabled($this->isDisabled)
->label('Open')
->icon('tabler-eye')
->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])),
EditAction::make('edit')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->disabled($this->isDisabled)
->icon('tabler-edit')
->visible(fn (File $file) => $file->canEdit())
->url(fn (File $file) => EditFiles::getUrl(['path' => join_paths($this->path, $file->name)])),
ActionGroup::make([
Action::make('rename')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Rename')
->icon('tabler-forms')
->form([
@ -147,6 +173,7 @@ class ListFiles extends ListRecords
}),
Action::make('copy')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('Copy')
->icon('tabler-copy')
->visible(fn (File $file) => $file->is_file)
@ -166,12 +193,14 @@ class ListFiles extends ListRecords
}),
Action::make('download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->disabled($this->isDisabled)
->label('Download')
->icon('tabler-download')
->visible(fn (File $file) => $file->is_file)
->url(fn (File $file) => DownloadFiles::getUrl(['path' => join_paths($this->path, $file->name)]), true),
Action::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Move')
->icon('tabler-replace')
->form([
@ -207,6 +236,7 @@ class ListFiles extends ListRecords
}),
Action::make('permissions')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Permissions')
->icon('tabler-license')
->form([
@ -263,6 +293,7 @@ class ListFiles extends ListRecords
}),
Action::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->label('Archive')
->icon('tabler-archive')
->form([
@ -290,6 +321,7 @@ class ListFiles extends ListRecords
}),
Action::make('unarchive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->label('Unarchive')
->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive())
@ -311,12 +343,13 @@ class ListFiles extends ListRecords
]),
DeleteAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->disabled($this->isDisabled)
->label('')
->icon('tabler-trash')
->requiresConfirmation()
->modalHeading(fn (File $file) => trans('filament-actions::delete.single.modal.heading', ['label' => $file->name . ' ' . ($file->is_directory ? 'folder' : 'file')]))
->modalDescription(fn (File $file) => $file->name)
->modalHeading('Delete file?')
->action(function (File $file) {
$this->deselectAllTableRecords();
$this->getDaemonFileRepository()->deleteFiles($this->path, [$file->name]);
Activity::event('server:file.delete')
@ -325,77 +358,83 @@ class ListFiles extends ListRecords
->log();
}),
])
->groupedBulkActions([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->form([
TextInput::make('location')
->label('Directory')
->hint('Enter the new directory, relative to the current directory.')
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data) {
$location = rtrim($data['location'], '/');
->bulkActions([
BulkActionGroup::make([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->form([
TextInput::make('location')
->label('Directory')
->hint('Enter the new directory, relative to the current directory.')
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data) {
$location = rtrim($data['location'], '/');
$files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray();
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
$files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray();
$this->getDaemonFileRepository()
->renameFiles($this->path, $files);
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files were moved to ' . resolve_path(join_paths($this->path, $location)))
->success()
->send();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->form([
TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
Notification::make()
->title(count($files) . ' Files were moved to ' . resolve_path(join_paths($this->path, $location)))
->success()
->send();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->form([
TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title('Archive created')
->body($archive['name'])
->success()
->send();
Notification::make()
->title('Archive created')
->body($archive['name'])
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->disabled($this->isDisabled)
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
]),
]);
}
@ -407,6 +446,7 @@ class ListFiles extends ListRecords
return [
HeaderAction::make('new_file')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('New File')
->color('gray')
->keyBindings('')
@ -438,6 +478,7 @@ class ListFiles extends ListRecords
]),
HeaderAction::make('new_folder')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('New Folder')
->color('gray')
->action(function ($data) {
@ -454,6 +495,7 @@ class ListFiles extends ListRecords
]),
HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('Upload')
->action(function ($data) {
if (count($data['files']) > 0 && !isset($data['url'])) {
@ -503,14 +545,14 @@ class ListFiles extends ListRecords
]),
HeaderAction::make('search')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->disabled($this->isDisabled)
->label('Global Search')
->modalSubmitActionLabel('Search')
->form([
TextInput::make('searchTerm')
->placeholder('Enter a search term, e.g. *.txt')
->required()
->regex('/^[^*]*\*?[^*]*$/')
->minValue(3),
->minLength(3),
])
->action(fn ($data) => redirect(SearchFiles::getUrl([
'searchTerm' => $data['searchTerm'],

View File

@ -12,7 +12,6 @@ use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
class SearchFiles extends ListRecords
{
@ -23,8 +22,15 @@ class SearchFiles extends ListRecords
#[Locked]
public string $searchTerm;
#[Url]
public string $path = '/';
#[Locked]
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
{

View File

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

View File

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

View File

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

View File

@ -19,8 +19,8 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteAction;
use Filament\Resources\Resource;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
@ -83,35 +83,6 @@ class UserResource extends Resource
/** @var Server $server */
$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
->paginated(false)
->searchable(false)
@ -187,8 +158,69 @@ class UserResource extends Resource
Actions::make([
Action::make('assignAll')
->label('Assign All')
->action(function (Set $set) use ($permissionsArray) {
$permissions = $permissionsArray;
->action(function (Set $set) {
$permissions = [
'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) {
$allValues = array_unique($value);
$set($key, $allValues);
@ -203,25 +235,264 @@ class UserResource extends Resource
]),
Tabs::make()
->columnSpanFull()
->schema($tabs),
->schema([
Tab::make('Console')
->schema([
Section::make()
->description(trans('server/users.permissions.control_desc'))
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->formatStateUsing(function (User $user, Set $set) use ($server) {
$permissionsArray = $server->subusers->where('user_id', $user->id)->first()->permissions;
$transformedPermissions = [];
foreach ($permissionsArray as $permission) {
[$group, $action] = explode('.', $permission, 2);
$transformedPermissions[$group][] = $action;
}
foreach ($transformedPermissions as $key => $value) {
$set($key, $value);
}
return $transformedPermissions['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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
]),
]),
]),
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'),
]),
]),
]),
]),
]),
])
->mutateRecordDataUsing(function ($data, User $user) use ($server) {
$permissionsArray = $server->subusers->where('user_id', $user->id)->first()->permissions;
$transformedPermissions = [];
foreach ($permissionsArray as $permission) {
[$group, $action] = explode('.', $permission, 2);
$transformedPermissions[$group][] = $action;
}
foreach ($transformedPermissions as $key => $value) {
$data[$key] = $value;
}
return $data;
}),
]),
]);
}

View File

@ -10,8 +10,8 @@ use App\Services\Subusers\SubuserCreationService;
use Exception;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Actions as assignAll;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
@ -32,35 +32,6 @@ class ListUsers extends ListRecords
/** @var Server $server */
$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 [
Actions\CreateAction::make('invite')
->label('Invite User')
@ -89,10 +60,72 @@ class ListUsers extends ListRecords
assignAll::make([
Action::make('assignAll')
->label('Assign All')
->action(function (Set $set, Get $get) use ($permissionsArray) {
$permissions = $permissionsArray;
->action(function (Set $set, Get $get) {
$permissions = [
'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) {
$allValues = array_unique($value);
$currentValues = $get($key) ?? [];
$allValues = array_unique(array_merge($currentValues, $value));
$set($key, $allValues);
}
}),
@ -105,7 +138,247 @@ class ListUsers extends ListRecords
]),
Tabs::make()
->columnSpanFull()
->schema($tabs),
->schema([
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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'),
]),
]),
]),
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',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
]),
]),
]),
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')

View File

@ -11,7 +11,6 @@ use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService;
use Filament\Widgets\Widget;
use Illuminate\Support\Arr;
use Livewire\Attributes\Session;
use Livewire\Attributes\On;
class ServerConsole extends Widget
@ -27,7 +26,6 @@ class ServerConsole extends Widget
public ?User $user = null;
/** @var string[] */
#[Session(key: 'server.{server.id}.history')]
public array $history = [];
public int $historyIndex = 0;

View File

@ -4,7 +4,6 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
@ -17,19 +16,10 @@ class ServerCpuChart extends ChartWidget
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
{
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))
->slice(-$period)
->slice(-10)
->map(fn ($value, $key) => [
'cpu' => Number::format($value, maxPrecision: 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),

View File

@ -4,7 +4,6 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
@ -17,19 +16,9 @@ class ServerMemoryChart extends ChartWidget
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
{
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))
->slice(-$period)
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))->slice(-10)
->map(fn ($value, $key) => [
'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'),

View File

@ -4,72 +4,61 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
class ServerNetworkChart extends ChartWidget
{
protected static ?string $heading = 'Network';
protected static ?string $pollingInterval = '1s';
protected static ?string $maxHeight = '200px';
protected static ?string $maxHeight = '300px';
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
{
$previous = null;
$data = cache()->get("servers.{$this->server->id}.network");
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$net = collect(cache()->get("servers.{$this->server->id}.network"))
->slice(-$period)
->map(function ($current, $timestamp) use (&$previous) {
$net = null;
$rx = collect($data)
->slice(-10)
->map(fn ($value, $key) => [
'rx' => $value->rx_bytes,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
if ($previous !== null) {
$net = [
'rx' => max(0, $current->rx_bytes - $previous->rx_bytes),
'tx' => max(0, $current->tx_bytes - $previous->tx_bytes),
'timestamp' => Carbon::createFromTimestamp($timestamp, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
];
}
$previous = $current;
return $net;
})
$tx = collect($data)
->slice(-10)
->map(fn ($value, $key) => [
'tx' => $value->rx_bytes,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
return [
'datasets' => [
[
'label' => 'Inbound',
'data' => array_column($net, 'rx'),
'backgroundColor' => [
'rgba(100, 255, 105, 0.5)',
],
'tension' => '0.3',
'fill' => true,
],
[
'label' => 'Outbound',
'data' => array_column($net, 'tx'),
'data' => array_column($rx, 'rx'),
'backgroundColor' => [
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
[
'label' => 'Outbound',
'data' => array_column($tx, 'tx'),
'backgroundColor' => [
'rgba(165, 96, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
],
'labels' => array_column($net, 'timestamp'),
'labels' => array_column($rx, 'timestamp'),
];
}
@ -80,38 +69,25 @@ class ServerNetworkChart extends ChartWidget
protected function getOptions(): RawJs
{
// TODO: use "panel.use_binary_prefix" config value
return RawJs::make(<<<'JS'
{
scales: {
x: {
display: false,
},
y: {
min: 0,
grid: {
display: false,
},
ticks: {
display: true,
},
display: false, //debug
},
y: {
ticks: {
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);
}
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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,8 @@ class ActivityProcessingController extends Controller
{
public function __invoke(ActivityEventRequest $request): void
{
$tz = Carbon::now()->getTimezone();
/** @var \App\Models\Node $node */
$node = $request->attributes->get('node');
@ -47,8 +49,11 @@ class ActivityProcessingController extends Controller
$log = [
'ip' => empty($datum['ip']) ? '127.0.0.1' : $datum['ip'],
'event' => $datum['event'],
'properties' => $datum['metadata'] ?? [],
'timestamp' => $when,
'properties' => json_encode($datum['metadata'] ?? []),
// We have to change the time to the current timezone due to the way Laravel is handling
// 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'])) {

View File

@ -44,20 +44,6 @@ class OAuthController extends Controller
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();
// User is already logged in and wants to link a new OAuth Provider

View File

@ -5,9 +5,6 @@ namespace App\Http\Middleware;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Exceptions\Http\TwoFactorAuthRequiredException;
use App\Filament\Pages\Auth\EditProfile;
use App\Livewire\AlertBanner;
use App\Models\User;
class RequireTwoFactorAuthentication
{
@ -17,6 +14,11 @@ class RequireTwoFactorAuthentication
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
* proceed or not. This checks if the Panel is configured to require 2FA on an account in
@ -27,37 +29,31 @@ class RequireTwoFactorAuthentication
*/
public function handle(Request $request, \Closure $next): mixed
{
/** @var ?User $user */
$user = $request->user();
$uri = rtrim($request->getRequestUri(), '/') . '/';
$current = $request->route()->getName();
if (!$user || Str::startsWith($uri, ['/auth/', '/profile']) || Str::startsWith($current, ['auth.', 'account.', 'filament.app.auth.'])) {
if (!$user || Str::startsWith($uri, ['/auth/']) || Str::startsWith($current, ['auth.', 'account.'])) {
return $next($request);
}
/** @var \App\Models\User $user */
$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 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);
} 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.
} elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) {
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/')) {
throw new TwoFactorAuthRequiredException();
}
// ... 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'));
return redirect()->to($this->redirectRoute);
}
}

View File

@ -18,7 +18,26 @@ class StoreNodeRequest extends ApplicationApiRequest
*/
public function rules(?array $rules = null): array
{
return collect($rules ?? Node::getRules())->mapWithKeys(function ($value, $key) {
return collect($rules ?? Node::getRules())->only([
'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];
})->toArray();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ namespace App\Listeners\Auth;
use App\Facades\Activity;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use App\Events\Auth\DirectLogin;
class AuthenticationListener
{
@ -12,10 +12,9 @@ class AuthenticationListener
* Handles an authentication event by logging the user and information about
* the request.
*/
public function handle(Failed|Login $event): void
public function handle(Failed|DirectLogin $event): void
{
$activity = Activity::withRequestMetadata();
if ($event->user) {
$activity = $activity->subject($event->user);
}

View File

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

View File

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

View File

@ -117,10 +117,15 @@ class Allocation extends Model
protected function address(): Attribute
{
return Attribute::make(
get: fn () => (is_ipv6($this->alias) ? "[$this->alias]" : $this->alias) . ":$this->port",
get: fn () => "$this->alias:$this->port",
);
}
public function toString(): string
{
return $this->address;
}
/**
* Gets information for the server associated with this allocation.
*/

View File

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

View File

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

View File

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

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