Merge branch 'main' into issue/68

# Conflicts:
#	app/Filament/Resources/NodeResource/Pages/CreateNode.php
#	app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php
#	app/Filament/Resources/NodeResource/RelationManagers/NodesRelationManager.php
#	app/Filament/Resources/ServerResource/Pages/CreateServer.php
#	app/Filament/Resources/ServerResource/Pages/EditServer.php
#	app/Filament/Resources/ServerResource/Pages/ListServers.php
#	app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php
#	app/Filament/Resources/UserResource/RelationManagers/ServersRelationManager.php
#	app/Transformers/Api/Client/ServerTransformer.php
#	composer.lock
#	config/panel.php
This commit is contained in:
Lance Pioch 2024-10-18 21:18:10 -04:00
commit 5353d38302
152 changed files with 3101 additions and 1295 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git
node_modules
vendor
database/database.sqlite
storage/debugbar/*.json
storage/logs/*.log
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/testing
storage/framework/views/*.php

View File

@ -1,33 +1,7 @@
APP_ENV=production
APP_DEBUG=false
APP_KEY=
APP_TIMEZONE=UTC
APP_URL=http://panel.test
APP_LOCALE=en
APP_INSTALLED=false
LOG_CHANNEL=daily
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
CACHE_STORE=file
QUEUE_CONNECTION=database
SESSION_DRIVER=file
MAIL_MAILER=log
MAIL_HOST=smtp.example.com
MAIL_PORT=25
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="Pelican Admin"
# Set this to your domain to prevent it defaulting to 'localhost', causing mail servers such as Gmail to reject your mail
# MAIL_EHLO_DOMAIN=panel.example.com
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
APP_TIMEZONE=UTC
APP_LOCALE=en

View File

@ -1,81 +1,64 @@
#!/bin/ash -e
cd /app
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php8/ \
&& chmod 777 /var/log/panel/logs/ \
&& ln -s /app/storage/logs/ /var/log/panel/
#mkdir -p /var/log/supervisord/ /var/log/php8/ \
## check for .env file and generate app keys if missing
if [ -f /app/var/.env ]; then
if [ -f /pelican-data/.env ]; then
echo "external vars exist."
rm -rf /app/.env
ln -s /app/var/.env /app/
rm -rf /var/www/html/.env
else
echo "external vars don't exist."
rm -rf /app/.env
touch /app/var/.env
rm -rf /var/www/html/.env
touch /pelican-data/.env
## manually generate a key because key generate --force fails
if [ -z $APP_KEY ]; then
echo -e "Generating key."
APP_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
echo -e "Generated app key: $APP_KEY"
echo -e "APP_KEY=$APP_KEY" > /app/var/.env
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
else
echo -e "APP_KEY exists in environment, using that."
echo -e "APP_KEY=$APP_KEY" > /app/var/.env
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
fi
ln -s /app/var/.env /app/
## enable installer
echo -e "APP_INSTALLED=false" >> /pelican-data/.env
fi
echo "Checking if https is required."
if [ -f /etc/nginx/http.d/panel.conf ]; then
echo "Using nginx config already in place."
if [ $LE_EMAIL ]; then
echo "Checking for cert update"
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
else
echo "No letsencrypt email is set"
fi
mkdir /pelican-data/database
ln -s /pelican-data/.env /var/www/html/
ln -s /pelican-data/database/database.sqlite /var/www/html/database/
if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
echo "Generating APP_KEY..."
php artisan key:generate --force
else
echo "Checking if letsencrypt email is set."
if [ -z $LE_EMAIL ]; then
echo "No letsencrypt email is set using http config."
cp .github/docker/default.conf /etc/nginx/http.d/panel.conf
else
echo "writing ssl config"
cp .github/docker/default_ssl.conf /etc/nginx/http.d/panel.conf
echo "updating ssl config for domain"
sed -i "s|<domain>|$(echo $APP_URL | sed 's~http[s]*://~~g')|g" /etc/nginx/http.d/panel.conf
echo "generating certs"
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
fi
echo "Removing the default nginx config"
rm -rf /etc/nginx/http.d/default.conf
echo "APP_KEY is already set."
fi
if [[ -z $DB_PORT ]]; then
echo -e "DB_PORT not specified, defaulting to 3306"
DB_PORT=3306
fi
## check for DB up before starting the panel
echo "Checking database status."
until nc -z -v -w30 $DB_HOST $DB_PORT
do
echo "Waiting for database connection..."
# wait for 1 seconds before check again
sleep 1
done
## make sure the db is set up
echo -e "Migrating and Seeding D.B"
php artisan migrate --seed --force
echo -e "Migrating Database"
php artisan migrate --force
echo -e "Optimizing Filament"
php artisan filament:optimize
## start cronjobs for the queue
echo -e "Starting cron jobs."
crond -L /var/log/crond -l 5
echo -e "Starting supervisord."
export SUPERVISORD_CADDY=false
## disable caddy if SKIP_CADDY is set
if [[ -z $SKIP_CADDY ]]; then
echo "Starting PHP-FPM and Caddy"
export SUPERVISORD_CADDY=true
else
echo "Starting PHP-FPM only"
fi
chown -R www-data:www-data . /pelican-data/.env /pelican-data/database
echo "Starting Supervisord"
exec "$@"

View File

@ -25,15 +25,15 @@ autostart=true
autorestart=true
[program:queue-worker]
command=/usr/local/bin/php /app/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3
user=nginx
command=/usr/local/bin/php /var/www/html/artisan queue:work --tries=3
user=www-data
autostart=true
autorestart=true
[program:nginx]
command=/usr/sbin/nginx -g 'daemon off;'
autostart=true
autorestart=true
[program:caddy]
command=caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
autostart=%(ENV_SUPERVISORD_CADDY)s
autorestart=%(ENV_SUPERVISORD_CADDY)s
priority=10
stdout_events_enabled=true
stderr_events_enabled=true
stderr_events_enabled=true

82
.github/workflows/docker-publish.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Docker
on:
push:
branches:
- main
release:
types:
- published
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Always run against a tag, even if the commit into the tag has [docker skip] within the commit message.
if: "!contains(github.ref, 'main') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps:
- name: Code checkout
uses: actions/checkout@v4
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/pelican-dev/panel
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' && github.event.release.prerelease == false }}
type=ref,event=tag
type=ref,event=branch
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get Build Information
id: build_info
run: |
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build and Push (tag)
uses: docker/build-push-action@v5
if: "github.event_name == 'release' && github.event.action == 'published'"
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.build_info.outputs.version_tag }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
- name: Build and Push (main)
uses: docker/build-push-action@v5
if: "github.event_name == 'push' && contains(github.ref, 'main')"
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=dev-${{ steps.build_info.outputs.short_sha }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

12
Caddyfile Normal file
View File

@ -0,0 +1,12 @@
{
admin off
email {$ADMIN_EMAIL}
}
{$APP_URL} {
root * /var/www/html/public
encode gzip
php_fastcgi 127.0.0.1:9000
file_server
}

View File

@ -1,41 +1,58 @@
# Stage 0:
# Build the assets that are needed for the frontend. This build stage is then discarded
# since we won't need NodeJS anymore in the future. This Docker image ships a final production
# level distribution
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine
WORKDIR /app
# Pelican Production Dockerfile
FROM node:20-alpine AS yarn
#FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
COPY . ./
RUN yarn install --frozen-lockfile \
RUN yarn config set network-timeout 300000 \
&& yarn install --frozen-lockfile \
&& yarn run build:production
# Stage 1:
# Build the actual container with all of the needed PHP dependencies that will run the application.
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
WORKDIR /app
COPY . ./
COPY --from=0 /app/public/assets ./public/assets
RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev icu-dev certbot certbot-nginx \
&& docker-php-ext-configure zip \
&& docker-php-ext-install bcmath gd intl pdo_mysql zip \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& cp .env.example .env \
&& mkdir -p bootstrap/cache/ storage/logs storage/framework/sessions storage/framework/views storage/framework/cache \
&& chmod 777 -R bootstrap storage \
&& composer install --no-dev --optimize-autoloader \
&& rm -rf .env bootstrap/cache/*.php \
&& mkdir -p /app/storage/logs/ \
&& chown -R nginx:nginx .
FROM php:8.3-fpm-alpine
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
RUN rm /usr/local/etc/php-fpm.conf \
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
&& echo "0 23 * * * certbot renew --nginx --quiet" >> /var/spool/cron/crontabs/root \
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
&& mkdir -p /var/run/php /var/run/nginx
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
COPY .github/docker/default.conf /etc/nginx/http.d/default.conf
COPY .github/docker/www.conf /usr/local/etc/php-fpm.conf
COPY .github/docker/supervisord.conf /etc/supervisord.conf
WORKDIR /var/www/html
# Install dependencies
RUN apk update && apk add --no-cache \
libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev icu-dev \
zip unzip curl \
caddy ca-certificates supervisor \
&& docker-php-ext-install bcmath gd intl zip opcache pcntl posix pdo_mysql
# Copy the Caddyfile to the container
COPY Caddyfile /etc/caddy/Caddyfile
# Copy the application code to the container
COPY . .
COPY --from=yarn /build/public/assets ./public/assets
RUN touch .env
RUN composer install --no-dev --optimize-autoloader
# Set file permissions
RUN chmod -R 755 storage bootstrap/cache
# Add scheduler to cron
RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data -
## supervisord config and log dir
RUN cp .github/docker/supervisord.conf /etc/supervisord.conf && \
mkdir /var/log/supervisord/
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/up || exit 1
EXPOSE 80 443
VOLUME /pelican-data
ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands\Egg;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use Exception;
use Illuminate\Console\Command;
class CheckEggUpdatesCommand extends Command
{
protected $signature = 'p:egg:check-updates';
public function handle(): void
{
/** @var EggExporterService $exporterService */
$exporterService = app(EggExporterService::class);
$eggs = Egg::all();
foreach ($eggs as $egg) {
try {
if (is_null($egg->update_url)) {
$this->comment("{$egg->name}: Skipping (no update url set)");
continue;
}
$currentJson = json_decode($exporterService->handle($egg->id));
unset($currentJson->exported_at);
$updatedJson = json_decode(file_get_contents($egg->update_url));
unset($updatedJson->exported_at);
if (md5(json_encode($currentJson)) === md5(json_encode($updatedJson))) {
$this->info("{$egg->name}: Up-to-date");
cache()->put("eggs.{$egg->uuid}.update", false, now()->addHour());
} else {
$this->warn("{$egg->name}: Found update");
cache()->put("eggs.{$egg->uuid}.update", true, now()->addHour());
}
} catch (Exception $exception) {
$this->error("{$egg->name}: Error ({$exception->getMessage()})");
}
}
}
}

View File

@ -2,20 +2,14 @@
namespace App\Console\Commands\Environment;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command
{
use EnvironmentWriterTrait;
protected $description = 'Configure basic environment settings for the Panel.';
protected $signature = 'p:environment:setup
{--url= : The URL that this Panel is running on.}';
protected array $variables = [];
protected $signature = 'p:environment:setup';
public function handle(): void
{
@ -30,21 +24,6 @@ class AppSettingsCommand extends Command
Artisan::call('key:generate');
}
$this->variables['APP_TIMEZONE'] = 'UTC';
$this->variables['APP_URL'] = $this->option('url') ?? $this->ask(
'Application URL',
config('app.url', 'https://example.com')
);
// Make sure session cookies are set as "secure" when using HTTPS
if (str_starts_with($this->variables['APP_URL'], 'https://')) {
$this->variables['SESSION_SECURE_COOKIE'] = 'true';
}
$this->comment('Writing variables to .env file');
$this->writeToEnvironment($this->variables);
$this->info("Setup complete. Vist {$this->variables['APP_URL']}/installer to complete the installation");
Artisan::call('filament:optimize');
}
}

View File

@ -42,6 +42,13 @@ class DatabaseSettingsCommand extends Command
*/
public function handle(): int
{
$this->error('Changing the database driver will NOT move any database data!');
$this->error('Please make sure you made a database backup first!');
$this->error('After changing the driver you will have to manually move the old data to the new database.');
if (!$this->confirm('Do you want to continue?')) {
return 1;
}
$selected = config('database.default', 'sqlite');
$this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice(
'Database Driver',

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands\Environment;
use App\Traits\Commands\RequestRedisSettingsTrait;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
class RedisSetupCommand extends Command
{
use EnvironmentWriterTrait;
use RequestRedisSettingsTrait;
protected $description = 'Configure the Panel to use Redis as cache, queue and session driver.';
protected $signature = 'p:redis:setup
{--redis-host= : Redis host to use for connections.}
{--redis-user= : User used to connect to redis.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* RedisSetupCommand constructor.
*/
public function __construct(private Kernel $console)
{
parent::__construct();
}
/**
* Handle command execution.
*/
public function handle(): int
{
$this->variables['CACHE_STORE'] = 'redis';
$this->variables['QUEUE_CONNECTION'] = 'redis';
$this->variables['SESSION_DRIVERS'] = 'redis';
$this->requestRedisSettings();
$this->call('p:environment:queue-service', [
'--overwrite' => true,
]);
$this->writeToEnvironment($this->variables);
$this->info($this->console->output());
return 0;
}
}

View File

@ -52,7 +52,7 @@ class MakeUserCommand extends Command
['UUID', $user->uuid],
['Email', $user->email],
['Username', $user->username],
['Admin', $user->root_admin ? 'Yes' : 'No'],
['Admin', $user->isRootAdmin() ? 'Yes' : 'No'],
]);
return 0;

View File

@ -2,15 +2,16 @@
namespace App\Console;
use App\Console\Commands\Egg\CheckEggUpdatesCommand;
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 Illuminate\Console\Scheduling\Schedule;
use Illuminate\Database\Console\PruneCommand;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand;
class Kernel extends ConsoleKernel
{
@ -35,6 +36,7 @@ class Kernel extends ConsoleKernel
$schedule->command(CleanServiceBackupFilesCommand::class)->daily();
$schedule->command(PruneImagesCommand::class)->daily();
$schedule->command(CheckEggUpdatesCommand::class)->hourly();
$schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping();

View File

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
enum RolePermissionModels: string
{
case ApiKey = 'apiKey';
case DatabaseHost = 'databaseHost';
case Database = 'database';
case Egg = 'egg';
case Mount = 'mount';
case Node = 'node';
case Role = 'role';
case Server = 'server';
case User = 'user';
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum RolePermissionPrefixes: string
{
case ViewAny = 'viewList';
case View = 'view';
case Create = 'create';
case Update = 'update';
case Delete = 'delete';
}

View File

@ -3,24 +3,27 @@
namespace App\Filament\Pages\Installer;
use App\Filament\Pages\Installer\Steps\AdminUserStep;
use App\Filament\Pages\Installer\Steps\CompletedStep;
use App\Filament\Pages\Installer\Steps\DatabaseStep;
use App\Filament\Pages\Installer\Steps\EnvironmentStep;
use App\Filament\Pages\Installer\Steps\RedisStep;
use App\Filament\Pages\Installer\Steps\RequirementsStep;
use App\Models\User;
use App\Services\Users\UserCreationService;
use App\Traits\CheckMigrationsTrait;
use App\Traits\EnvironmentWriterTrait;
use Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Wizard;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Pages\Concerns\HasUnsavedDataChangesAlert;
use Filament\Pages\SimplePage;
use Filament\Support\Enums\MaxWidth;
use Filament\Support\Exceptions\Halt;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
@ -32,31 +35,30 @@ class PanelInstaller extends SimplePage implements HasForms
{
use CheckMigrationsTrait;
use EnvironmentWriterTrait;
use HasUnsavedDataChangesAlert;
use InteractsWithForms;
public $data = [];
protected static string $view = 'filament.pages.installer';
private User $user;
public function getMaxWidth(): MaxWidth|string
{
return MaxWidth::SevenExtraLarge;
}
public function mount()
public static function isInstalled(): bool
{
if (is_installed()) {
abort(404);
}
$this->form->fill();
// This defaults to true so existing panels count as "installed"
return env('APP_INSTALLED', true);
}
public function dehydrate(): void
public function mount()
{
Artisan::call('config:clear');
Artisan::call('cache:clear');
abort_if(self::isInstalled(), 404);
$this->form->fill();
}
protected function getFormSchema(): array
@ -64,13 +66,15 @@ class PanelInstaller extends SimplePage implements HasForms
return [
Wizard::make([
RequirementsStep::make(),
EnvironmentStep::make(),
DatabaseStep::make(),
RedisStep::make()
->hidden(fn (Get $get) => $get('env.SESSION_DRIVER') != 'redis' && $get('env.QUEUE_CONNECTION') != 'redis' && $get('env.CACHE_STORE') != 'redis'),
AdminUserStep::make(),
EnvironmentStep::make($this),
DatabaseStep::make($this),
RedisStep::make($this)
->hidden(fn (Get $get) => $get('env_general.SESSION_DRIVER') != 'redis' && $get('env_general.QUEUE_CONNECTION') != 'redis' && $get('env_general.CACHE_STORE') != 'redis'),
AdminUserStep::make($this),
CompletedStep::make(),
])
->persistStepInQueryString()
->nextAction(fn (Action $action) => $action->keyBindings('enter'))
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::button
type="submit"
@ -89,59 +93,89 @@ class PanelInstaller extends SimplePage implements HasForms
return 'data';
}
protected function hasUnsavedDataChangesAlert(): bool
{
return true;
}
public function submit()
{
// Disable installer
$this->writeToEnvironment(['APP_INSTALLED' => 'true']);
// Login user
$this->user ??= User::all()->filter(fn ($user) => $user->isRootAdmin())->first();
auth()->guard()->login($this->user, true);
// Redirect to admin panel
return redirect(Filament::getPanel('admin')->getUrl());
}
public function writeToEnv(string $key): void
{
try {
$inputs = $this->form->getState();
// Write variables to .env file
$variables = array_get($inputs, 'env');
$variables = array_get($this->data, $key);
$this->writeToEnvironment($variables);
// Clear config cache
Artisan::call('config:clear');
// Run migrations
Artisan::call('migrate', [
'--force' => true,
'--seed' => true,
'--database' => $variables['DB_CONNECTION'],
]);
if (!$this->hasCompletedMigrations()) {
throw new Exception('Migrations didn\'t run successfully. Double check your database configuration.');
}
// Create first admin user
$userData = array_get($inputs, 'user');
$userData['root_admin'] = true;
app(UserCreationService::class)->handle($userData);
// Install setup complete
$this->writeToEnvironment(['APP_INSTALLED' => 'true']);
$this->rememberData();
Notification::make()
->title('Successfully Installed')
->success()
->send();
redirect()->intended(Filament::getUrl());
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Installation Failed')
->title('Could not write to .env file')
->body($exception->getMessage())
->danger()
->persistent()
->send();
throw new Halt('Error while writing .env file');
}
Artisan::call('config:clear');
}
public function runMigrations(string $driver): void
{
try {
Artisan::call('migrate', [
'--force' => true,
'--seed' => true,
'--database' => $driver,
]);
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Migrations failed')
->body($exception->getMessage())
->danger()
->persistent()
->send();
throw new Halt('Error while running migrations');
}
if (!$this->hasCompletedMigrations()) {
Notification::make()
->title('Migrations failed')
->danger()
->persistent()
->send();
throw new Halt('Migrations failed');
}
}
public function createAdminUser(): void
{
try {
$userData = array_get($this->data, 'user');
$userData['root_admin'] = true;
$this->user = app(UserCreationService::class)->handle($userData);
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Could not create admin user')
->body($exception->getMessage())
->danger()
->persistent()
->send();
throw new Halt('Error while creating admin user');
}
}
}

View File

@ -2,12 +2,13 @@
namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
class AdminUserStep
{
public static function make(): Step
public static function make(PanelInstaller $installer): Step
{
return Step::make('user')
->label('Admin User')
@ -16,16 +17,17 @@ class AdminUserStep
->label('Admin E-Mail')
->required()
->email()
->default('admin@example.com'),
->placeholder('admin@example.com'),
TextInput::make('user.username')
->label('Admin Username')
->required()
->default('admin'),
->placeholder('admin'),
TextInput::make('user.password')
->label('Admin Password')
->required()
->password()
->revealable(),
]);
])
->afterValidation(fn () => $installer->createAdminUser());
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class CompletedStep
{
public static function make(): Step
{
return Step::make('complete')
->label('Setup complete')
->schema([
Placeholder::make('')
->content(new HtmlString('The setup is nearly complete!<br>As last step you need to create a new cronjob that runs every minute to process specific tasks, such as session cleanup and scheduled tasks, and also create a queue worker.')),
TextInput::make('crontab')
->label(new HtmlString('Run the following command to setup your crontab. Note that <code>www-data</code> is your webserver user. On some systems this username might be different!'))
->disabled()
->hintAction(CopyAction::make())
->default('(crontab -l -u www-data 2>/dev/null; echo "* * * * * php ' . base_path() . '/artisan schedule:run >> /dev/null 2>&1") | crontab -u www-data -'),
TextInput::make('queueService')
->label(new HtmlString('To setup the queue worker service you simply have to run the following command.'))
->disabled()
->hintAction(CopyAction::make())
->default('sudo php ' . base_path() . '/artisan p:environment:queue-service'),
Placeholder::make('')
->content('After you finished these two last tasks you can click on "Finish" and use your new panel! Have fun!'),
]);
}
}

View File

@ -2,91 +2,108 @@
namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Illuminate\Support\Facades\DB;
use PDOException;
class DatabaseStep
{
public static function make(): Step
public static function make(PanelInstaller $installer): Step
{
return Step::make('database')
->label('Database')
->columns()
->schema([
TextInput::make('env.DB_DATABASE')
->label(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite' ? 'Database Path' : 'Database Name')
TextInput::make('env_database.DB_DATABASE')
->label(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite' ? 'Database Path' : 'Database Name')
->columnSpanFull()
->hintIcon('tabler-question-mark')
->hintIconTooltip(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite' ? 'The path of your .sqlite file relative to the database folder.' : 'The name of the panel database.')
->hintIconTooltip(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite' ? 'The path of your .sqlite file relative to the database folder.' : 'The name of the panel database.')
->required()
->default(fn (Get $get) => env('DB_DATABASE', $get('env.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel')),
TextInput::make('env.DB_HOST')
->default(fn (Get $get) => env('DB_DATABASE', $get('env_general.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel')),
TextInput::make('env_database.DB_HOST')
->label('Database Host')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The host of your database. Make sure it is reachable.')
->required()
->default(env('DB_HOST', '127.0.0.1'))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_PORT')
->required(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite')
->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_HOST', '127.0.0.1') : null)
->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
TextInput::make('env_database.DB_PORT')
->label('Database Port')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The port of your database.')
->required()
->required(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite')
->numeric()
->minValue(1)
->maxValue(65535)
->default(env('DB_PORT', 3306))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_USERNAME')
->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_PORT', 3306) : null)
->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
TextInput::make('env_database.DB_USERNAME')
->label('Database Username')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The name of your database user.')
->required()
->default(env('DB_USERNAME', 'pelican'))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_PASSWORD')
->required(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite')
->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_USERNAME', 'pelican') : null)
->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
TextInput::make('env_database.DB_PASSWORD')
->label('Database Password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The password of your database user. Can be empty.')
->password()
->revealable()
->default(env('DB_PASSWORD'))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_PASSWORD') : null)
->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
])
->afterValidation(function (Get $get) {
$driver = $get('env.DB_CONNECTION');
if ($driver !== 'sqlite') {
try {
config()->set('database.connections._panel_install_test', [
'driver' => $driver,
'host' => $get('env.DB_HOST'),
'port' => $get('env.DB_PORT'),
'database' => $get('env.DB_DATABASE'),
'username' => $get('env.DB_USERNAME'),
'password' => $get('env.DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
->afterValidation(function (Get $get) use ($installer) {
$driver = $get('env_general.DB_CONNECTION');
DB::connection('_panel_install_test')->getPdo();
} catch (PDOException $exception) {
Notification::make()
->title('Database connection failed')
->body($exception->getMessage())
->danger()
->send();
DB::disconnect('_panel_install_test');
throw new Halt('Database connection failed');
}
if (!self::testConnection($driver, $get('env_database.DB_HOST'), $get('env_database.DB_PORT'), $get('env_database.DB_DATABASE'), $get('env_database.DB_USERNAME'), $get('env_database.DB_PASSWORD'))) {
throw new Halt('Database connection failed');
}
$installer->writeToEnv('env_database');
$installer->runMigrations($driver);
});
}
private static function testConnection(string $driver, $host, $port, $database, $username, $password): bool
{
if ($driver === 'sqlite') {
return true;
}
try {
config()->set('database.connections._panel_install_test', [
'driver' => $driver,
'host' => $host,
'port' => $port,
'database' => $database,
'username' => $username,
'password' => $password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
DB::connection('_panel_install_test')->getPdo();
} catch (Exception $exception) {
DB::disconnect('_panel_install_test');
Notification::make()
->title('Database connection failed')
->body($exception->getMessage())
->danger()
->send();
return false;
}
return true;
}
}

View File

@ -2,14 +2,17 @@
namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Traits\EnvironmentWriterTrait;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Set;
class EnvironmentStep
{
use EnvironmentWriterTrait;
public const CACHE_DRIVERS = [
'file' => 'Filesystem',
'redis' => 'Redis',
@ -17,15 +20,15 @@ class EnvironmentStep
public const SESSION_DRIVERS = [
'file' => 'Filesystem',
'redis' => 'Redis',
'database' => 'Database',
'cookie' => 'Cookie',
'redis' => 'Redis',
];
public const QUEUE_DRIVERS = [
'database' => 'Database',
'sync' => 'Sync',
'redis' => 'Redis',
'sync' => 'Synchronous',
];
public const DATABASE_DRIVERS = [
@ -34,30 +37,30 @@ class EnvironmentStep
'mysql' => 'MySQL',
];
public static function make(): Step
public static function make(PanelInstaller $installer): Step
{
return Step::make('environment')
->label('Environment')
->columns()
->schema([
TextInput::make('env.APP_NAME')
TextInput::make('env_general.APP_NAME')
->label('App Name')
->hintIcon('tabler-question-mark')
->hintIconTooltip('This will be the Name of your Panel.')
->required()
->default(config('app.name')),
TextInput::make('env.APP_URL')
TextInput::make('env_general.APP_URL')
->label('App URL')
->hintIcon('tabler-question-mark')
->hintIconTooltip('This will be the URL you access your Panel from.')
->required()
->default(config('app.url'))
->default(url(''))
->live()
->afterStateUpdated(fn ($state, Set $set) => $set('env.SESSION_SECURE_COOKIE', str_starts_with($state, 'https://'))),
Toggle::make('env.SESSION_SECURE_COOKIE')
->afterStateUpdated(fn ($state, Set $set) => $set('env_general.SESSION_SECURE_COOKIE', str_starts_with($state, 'https://') ? 'true' : 'false')),
TextInput::make('env_general.SESSION_SECURE_COOKIE')
->hidden()
->default(env('SESSION_SECURE_COOKIE')),
ToggleButtons::make('env.CACHE_STORE')
->default(str_starts_with(url(''), 'https://') ? 'true' : 'false'),
ToggleButtons::make('env_general.CACHE_STORE')
->label('Cache Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for caching. We recommend "Filesystem".')
@ -65,7 +68,7 @@ class EnvironmentStep
->inline()
->options(self::CACHE_DRIVERS)
->default(config('cache.default', 'file')),
ToggleButtons::make('env.SESSION_DRIVER')
ToggleButtons::make('env_general.SESSION_DRIVER')
->label('Session Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for storing sessions. We recommend "Filesystem" or "Database".')
@ -73,7 +76,7 @@ class EnvironmentStep
->inline()
->options(self::SESSION_DRIVERS)
->default(config('session.driver', 'file')),
ToggleButtons::make('env.QUEUE_CONNECTION')
ToggleButtons::make('env_general.QUEUE_CONNECTION')
->label('Queue Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for handling queues. We recommend "Database".')
@ -81,7 +84,7 @@ class EnvironmentStep
->inline()
->options(self::QUEUE_DRIVERS)
->default(config('queue.default', 'database')),
ToggleButtons::make('env.DB_CONNECTION')
ToggleButtons::make('env_general.DB_CONNECTION')
->label('Database Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for the panel database. We recommend "SQLite".')
@ -89,6 +92,7 @@ class EnvironmentStep
->inline()
->options(self::DATABASE_DRIVERS)
->default(config('database.default', 'sqlite')),
]);
])
->afterValidation(fn () => $installer->writeToEnv('env_general'));
}
}

View File

@ -2,6 +2,8 @@
namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Traits\EnvironmentWriterTrait;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
@ -12,30 +14,32 @@ use Illuminate\Support\Facades\Redis;
class RedisStep
{
public static function make(): Step
use EnvironmentWriterTrait;
public static function make(PanelInstaller $installer): Step
{
return Step::make('redis')
->label('Redis')
->columns()
->schema([
TextInput::make('env.REDIS_HOST')
TextInput::make('env_redis.REDIS_HOST')
->label('Redis Host')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The host of your redis server. Make sure it is reachable.')
->required()
->default(config('database.redis.default.host')),
TextInput::make('env.REDIS_PORT')
TextInput::make('env_redis.REDIS_PORT')
->label('Redis Port')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The port of your redis server.')
->required()
->default(config('database.redis.default.port')),
TextInput::make('env.REDIS_USERNAME')
TextInput::make('env_redis.REDIS_USERNAME')
->label('Redis Username')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The name of your redis user. Can be empty')
->default(config('database.redis.default.username')),
TextInput::make('env.REDIS_PASSWORD')
TextInput::make('env_redis.REDIS_PASSWORD')
->label('Redis Password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The password for your redis user. Can be empty.')
@ -43,25 +47,36 @@ class RedisStep
->revealable()
->default(config('database.redis.default.password')),
])
->afterValidation(function (Get $get) {
try {
config()->set('database.redis._panel_install_test', [
'host' => $get('env.REDIS_HOST'),
'username' => $get('env.REDIS_USERNAME'),
'password' => $get('env.REDIS_PASSWORD'),
'port' => $get('env.REDIS_PORT'),
]);
Redis::connection('_panel_install_test')->command('ping');
} catch (Exception $exception) {
Notification::make()
->title('Redis connection failed')
->body($exception->getMessage())
->danger()
->send();
->afterValidation(function (Get $get) use ($installer) {
if (!self::testConnection($get('env_redis.REDIS_HOST'), $get('env_redis.REDIS_PORT'), $get('env_redis.REDIS_USERNAME'), $get('env_redis.REDIS_PASSWORD'))) {
throw new Halt('Redis connection failed');
}
$installer->writeToEnv('env_redis');
});
}
private static function testConnection($host, $port, $username, $password): bool
{
try {
config()->set('database.redis._panel_install_test', [
'host' => $host,
'port' => $port,
'username' => $username,
'password' => $password,
]);
Redis::connection('_panel_install_test')->command('ping');
} catch (Exception $exception) {
Notification::make()
->title('Redis connection failed')
->body($exception->getMessage())
->danger()
->send();
return false;
}
return true;
}
}

View File

@ -10,18 +10,20 @@ use Filament\Support\Exceptions\Halt;
class RequirementsStep
{
public const MIN_PHP_VERSION = '8.2';
public static function make(): Step
{
$correctPhpVersion = version_compare(PHP_VERSION, '8.2.0') >= 0;
$correctPhpVersion = version_compare(PHP_VERSION, self::MIN_PHP_VERSION) >= 0;
$fields = [
Section::make('PHP Version')
->description('8.2 or newer')
->description(self::MIN_PHP_VERSION . ' or newer')
->icon($correctPhpVersion ? 'tabler-check' : 'tabler-x')
->iconColor($correctPhpVersion ? 'success' : 'danger')
->schema([
Placeholder::make('')
->content('Your PHP Version ' . ($correctPhpVersion ? 'is' : 'needs to be') .' 8.2 or newer.'),
->content('Your PHP Version is ' . PHP_VERSION . '.'),
]),
];
@ -80,7 +82,7 @@ class RequirementsStep
->danger()
->send();
throw new Halt();
throw new Halt('Some requirements are missing');
}
});
}

View File

@ -49,12 +49,18 @@ class Settings extends Page implements HasForms
$this->form->fill();
}
public static function canAccess(): bool
{
return auth()->user()->can('view settings');
}
protected function getFormSchema(): array
{
return [
Tabs::make('Tabs')
->columns()
->persistTabInQueryString()
->disabled(fn () => !auth()->user()->can('update settings'))
->tabs([
Tab::make('general')
->label('General')
@ -86,7 +92,6 @@ class Settings extends Page implements HasForms
TextInput::make('APP_NAME')
->label('App Name')
->required()
->alphaNum()
->default(env('APP_NAME', 'Pelican')),
TextInput::make('APP_FAVICON')
->label('App Favicon')
@ -147,10 +152,12 @@ class Settings extends Page implements HasForms
->color('danger')
->icon('tabler-trash')
->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
FormAction::make('cloudflare')
->label('Set to Cloudflare IPs')
->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [
'173.245.48.0/20',
'103.21.244.0/22',
@ -226,6 +233,7 @@ class Settings extends Page implements HasForms
->label('Send Test Mail')
->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function () {
try {
MailNotification::route('mail', auth()->user()->email)
@ -513,6 +521,25 @@ class Settings extends Page implements HasForms
->suffix('Requests Per Minute')
->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
]),
Section::make('Server')
->description('Settings for Servers.')
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_EDITABLE_SERVER_DESCRIPTIONS')
->label('Allow Users to edit Server Descriptions?')
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->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'))),
]),
];
}
@ -561,12 +588,9 @@ class Settings extends Page implements HasForms
return [
Action::make('save')
->action('save')
->authorize(fn () => auth()->user()->can('update settings'))
->keyBindings(['mod+s']),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@ -23,13 +23,6 @@ class ApiKeyResource extends Resource
return false;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [

View File

@ -6,6 +6,7 @@ use App\Filament\Resources\ApiKeyResource;
use App\Models\ApiKey;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@ -51,13 +52,23 @@ class ListApiKeys extends ListRecords
])
->actions([
DeleteAction::make(),
])
->emptyStateIcon('tabler-key')
->emptyStateDescription('')
->emptyStateHeading('No API Keys')
->emptyStateActions([
CreateAction::make('create')
->label('Create API Key')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->label('Create API Key')
->hidden(fn () => ApiKey::where('key_type', ApiKey::TYPE_APPLICATION)->count() <= 0),
];
}
}

View File

@ -10,7 +10,7 @@ class DatabaseHostResource extends Resource
{
protected static ?string $model = DatabaseHost::class;
protected static ?string $label = 'Databases';
protected static ?string $label = 'Database Host';
protected static ?string $navigationIcon = 'tabler-database';
protected static ?string $navigationGroup = 'Advanced';
@ -20,13 +20,6 @@ class DatabaseHostResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [

View File

@ -5,13 +5,13 @@ namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource;
use App\Models\Objects\Endpoint;
use App\Services\Databases\Hosts\HostCreationService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\CreateRecord;
use Filament\Forms;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use PDOException;

View File

@ -8,13 +8,13 @@ use App\Models\DatabaseHost;
use App\Models\Objects\Endpoint;
use App\Services\Databases\Hosts\HostUpdateService;
use Filament\Actions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\EditRecord;
use Filament\Forms;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use PDOException;

View File

@ -3,9 +3,11 @@
namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource;
use App\Models\DatabaseHost;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
@ -42,15 +44,26 @@ class ListDatabaseHosts extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete databasehost')),
]),
])
->emptyStateIcon('tabler-database')
->emptyStateDescription('')
->emptyStateHeading('No Database Hosts')
->emptyStateActions([
CreateAction::make('create')
->label('Create Database Host')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make('create')->label('New Database Host'),
Actions\CreateAction::make('create')
->label('Create Database Host')
->hidden(fn () => DatabaseHost::count() <= 0),
];
}
}

View File

@ -20,13 +20,6 @@ class DatabaseResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [

View File

@ -4,10 +4,10 @@ namespace App\Filament\Resources\DatabaseResource\Pages;
use App\Filament\Resources\DatabaseResource;
use Filament\Actions;
use Filament\Tables\Actions\EditAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@ -48,7 +48,8 @@ class ListDatabases extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete database')),
]),
]);
}

View File

@ -21,13 +21,6 @@ class EggResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getGloballySearchableAttributes(): array
{
return ['name', 'tags', 'uuid', 'id'];

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Resources\EggResource;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset;
@ -15,10 +16,9 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\CreateRecord;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
@ -155,7 +155,7 @@ class CreateEgg extends CreateRecord
->debounce(750)
->maxLength(255)
->columnSpanFull()
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
)
->required(),
Textarea::make('description')->columnSpanFull(),

View File

@ -2,9 +2,11 @@
namespace App\Filament\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Resources\EggResource;
use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions;
@ -22,12 +24,10 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Forms;
use Filament\Forms\Form;
class EditEgg extends EditRecord
{
@ -40,6 +40,7 @@ class EditEgg extends EditRecord
Tabs::make()->tabs([
Tab::make('Configuration')
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->icon('tabler-egg')
->schema([
TextInput::make('name')
->required()
@ -80,6 +81,7 @@ class EditEgg extends EditRecord
->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
Toggle::make('force_outgoing_ip')
->inline(false)
->hintIcon('tabler-question-mark')
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's endpoint.
Required for certain games to work properly when the Node has multiple public IP addresses.
@ -105,9 +107,9 @@ class EditEgg extends EditRecord
->valueLabel('Image URI')
->helperText('The docker images available to servers using this egg.'),
]),
Tab::make('Process Management')
->columns()
->icon('tabler-server-cog')
->schema([
Select::make('config_from')
->label('Copy Settings From')
@ -130,6 +132,7 @@ class EditEgg extends EditRecord
]),
Tab::make('Egg Variables')
->columnSpanFull()
->icon('tabler-variable')
->schema([
Repeater::make('variables')
->label('')
@ -165,7 +168,7 @@ class EditEgg extends EditRecord
->debounce(750)
->maxLength(255)
->columnSpanFull()
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
)
->required(),
Textarea::make('description')->columnSpanFull(),
@ -211,22 +214,19 @@ class EditEgg extends EditRecord
]),
Tab::make('Install Script')
->columns(3)
->icon('tabler-file-download')
->schema([
Select::make('copy_script_from')
->placeholder('None')
->relationship('scriptFrom', 'name', ignoreRecord: true),
TextInput::make('script_container')
->required()
->maxLength(255)
->default('alpine:3.4'),
TextInput::make('script_entry')
->required()
->maxLength(255)
->default('ash'),
MonacoEditor::make('script_install')
->label('Install Script')
->columnSpanFull()
@ -234,7 +234,6 @@ class EditEgg extends EditRecord
->language('shell')
->view('filament.plugins.monaco-editor'),
]),
])->columnSpanFull()->persistTabInQueryString(),
]);
}
@ -245,14 +244,13 @@ class EditEgg extends EditRecord
Actions\DeleteAction::make('deleteEgg')
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'),
Actions\Action::make('exportEgg')
->label('Export')
->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')),
}, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
Actions\Action::make('importEgg')
->label('Import')
->form([
@ -321,8 +319,8 @@ class EditEgg extends EditRecord
->title('Import Success')
->success()
->send();
}),
})
->authorize(fn () => auth()->user()->can('import egg')),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@ -14,13 +14,13 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Filament\Tables;
class ListEggs extends ListRecords
{
@ -49,17 +49,51 @@ class ListEggs extends ListRecords
])
->actions([
EditAction::make(),
Tables\Actions\Action::make('export')
Action::make('export')
->icon('tabler-download')
->label('Export')
->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')),
}, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
Action::make('update')
->icon('tabler-cloud-download')
->label('Update')
->color('success')
->requiresConfirmation()
->modalHeading('Are you sure you want to update this egg?')
->modalDescription('If you made any changes to the egg they will be overwritten!')
->modalIconColor('danger')
->modalSubmitAction(fn (Actions\StaticAction $action) => $action->color('danger'))
->action(function (Egg $egg) {
try {
app(EggImporterService::class)->fromUrl($egg->update_url, $egg);
cache()->forget("eggs.{$egg->uuid}.update");
} catch (Exception $exception) {
Notification::make()
->title('Egg Update failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
Notification::make()
->title('Egg updated')
->success()
->send();
})
->authorize(fn () => auth()->user()->can('import egg'))
->visible(fn (Egg $egg) => cache()->get("eggs.{$egg->uuid}.update", false)),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete egg')),
]),
]);
}
@ -138,7 +172,8 @@ class ListEggs extends ListRecords
->title('Import Success')
->success()
->send();
}),
})
->authorize(fn () => auth()->user()->can('import egg')),
];
}
}

View File

@ -15,7 +15,7 @@ class ServersRelationManager extends RelationManager
{
return $table
->recordTitleAttribute('servers')
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned this egg.')
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned to this Egg.')
->searchable(false)
->columns([
TextColumn::make('user.username')

View File

@ -18,13 +18,6 @@ class MountResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [

View File

@ -4,14 +4,14 @@ namespace App\Filament\Resources\MountResource\Pages;
use App\Filament\Resources\MountResource;
use Filament\Actions;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\EditRecord;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
class EditMount extends EditRecord
{

View File

@ -43,7 +43,8 @@ class ListMounts extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete mount')),
]),
])
->emptyStateIcon('tabler-layers-linked')

View File

@ -4,8 +4,8 @@ namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource;
use App\Models\Objects\Endpoint;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
@ -154,7 +154,6 @@ class CreateNode extends CreateRecord
'lg' => 2,
])
->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
@ -221,7 +220,7 @@ class CreateNode extends CreateRecord
ToggleButtons::make('public')
->default(true)
->columnSpan(1)
->label('Automatic Allocation')->inline()
->label('Use Node for deployment?')->inline()
->options([
true => 'Yes',
false => 'No',
@ -231,11 +230,7 @@ class CreateNode extends CreateRecord
false => 'danger',
]),
TagsInput::make('tags')
->label('Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented')
->placeholder('Add Tags')
->columnSpan(2),
TextInput::make('upload_size')
->label('Upload Limit')

View File

@ -84,7 +84,7 @@ class EditNode extends EditRecord
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses
You must use a domain name, because you cannot get SSL certificates for IP Addresses.
';
}
@ -99,7 +99,7 @@ class EditNode extends EditRecord
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL';
return 'You cannot connect to an IP Address over SSL!';
}
return '';
@ -131,11 +131,9 @@ class EditNode extends EditRecord
$set('dns', false);
})
->maxLength(255),
TextInput::make('ip')
->disabled()
->hidden(),
ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
@ -158,7 +156,6 @@ class EditNode extends EditRecord
'md' => 1,
'lg' => 1,
]),
TextInput::make('daemon_listen')
->columnSpan([
'default' => 1,
@ -173,7 +170,6 @@ class EditNode extends EditRecord
->default(8080)
->required()
->integer(),
TextInput::make('name')
->label('Display Name')
->columnSpan([
@ -183,10 +179,8 @@ class EditNode extends EditRecord
'lg' => 2,
])
->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
ToggleButtons::make('scheme')
->label('Communicate over SSL')
->columnSpan([
@ -236,11 +230,7 @@ class EditNode extends EditRecord
->disabled(),
TagsInput::make('tags')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->label('Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented'),
->placeholder('Add Tags'),
TextInput::make('upload_size')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
->label('Upload Limit')
@ -264,7 +254,7 @@ class EditNode extends EditRecord
->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'),
ToggleButtons::make('public')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
->label('Automatic Allocation')->inline()
->label('Use Node for deployment?')->inline()
->options([
true => 'Yes',
false => 'No',
@ -447,6 +437,16 @@ class EditNode extends EditRecord
$data['config'] = $node->getYamlConfiguration();
if (!is_ip($node->fqdn)) {
$validRecords = gethostbynamel($node->fqdn);
if ($validRecords) {
$data['dns'] = true;
$data['ip'] = collect($validRecords)->first();
} else {
$data['dns'] = false;
}
}
return $data;
}
@ -454,6 +454,7 @@ class EditNode extends EditRecord
{
return [];
}
protected function getHeaderActions(): array
{
return [
@ -473,6 +474,7 @@ class EditNode extends EditRecord
{
return null;
}
protected function getColumnStart()
{
return null;

View File

@ -13,6 +13,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Number;
class ListNodes extends ListRecords
{
@ -47,18 +48,18 @@ class ListNodes extends ListRecords
->icon('tabler-device-desktop-analytics')
->numeric()
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => number_format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), 2))
->formatStateUsing(fn ($state) => Number::format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), maxPrecision: 2, locale: auth()->user()->language))
->sortable(),
TextColumn::make('disk')
->visibleFrom('sm')
->icon('tabler-file')
->numeric()
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => number_format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), 2))
->formatStateUsing(fn ($state) => Number::format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), maxPrecision: 2, locale: auth()->user()->language))
->sortable(),
TextColumn::make('cpu')
->visibleFrom('sm')
->icon('tabler-file')
->icon('tabler-cpu')
->numeric()
->suffix(' %')
->sortable(),
@ -84,7 +85,8 @@ class ListNodes extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete node')),
]),
])
->emptyStateIcon('tabler-server-2')

View File

@ -0,0 +1,160 @@
<?php
namespace App\Filament\Resources\NodeResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Node;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Node getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
protected static ?string $icon = 'tabler-plug-connected';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->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)
->searchable()
->columns([
TextColumn::make('id'),
TextColumn::make('port')
->searchable()
->label('Port'),
TextColumn::make('server.name')
->label('Server')
->icon('tabler-brand-docker')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
TextInputColumn::make('ip_alias')
->searchable()
->label('Alias'),
TextInputColumn::make('ip')
->searchable()
->label('IP'),
])
->filters([
//
])
->actions([
//
])
->headerActions([
Tables\Actions\Action::make('create new allocation')->label('Create Allocations')
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data) => resolve(AssignmentService::class)->handle($this->getOwnerRecord(), $data)),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete allocation')),
]),
]);
}
}

View File

@ -3,9 +3,9 @@
namespace App\Filament\Resources\NodeResource\RelationManagers;
use App\Models\Server;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Resources\RelationManagers\RelationManager;
class NodesRelationManager extends RelationManager
{

View File

@ -7,6 +7,7 @@ use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeCpuChart extends ChartWidget
{
@ -24,7 +25,7 @@ class NodeCpuChart extends ChartWidget
$cpu = collect(cache()->get("nodes.$node->id.cpu_percent"))
->slice(-10)
->map(fn ($value, $key) => [
'cpu' => number_format($value * $threads, 2),
'cpu' => Number::format($value * $threads, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
@ -73,8 +74,8 @@ class NodeCpuChart extends ChartWidget
$node = $this->record;
$threads = $node->systemInformation()['cpu_count'] ?? 0;
$cpu = number_format(collect(cache()->get("nodes.$node->id.cpu_percent"))->last() * $threads, 2);
$max = number_format($threads * 100) . '%';
$cpu = Number::format(collect(cache()->get("nodes.$node->id.cpu_percent"))->last() * $threads, maxPrecision: 2, locale: auth()->user()->language);
$max = Number::format($threads * 100, locale: auth()->user()->language) . '%';
return 'CPU - ' . $cpu . '% Of ' . $max;
}

View File

@ -7,6 +7,7 @@ use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeMemoryChart extends ChartWidget
{
@ -22,7 +23,7 @@ class NodeMemoryChart extends ChartWidget
$memUsed = collect(cache()->get("nodes.$node->id.memory_used"))->slice(-10)
->map(fn ($value, $key) => [
'memory' => config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000,
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
@ -73,12 +74,12 @@ class NodeMemoryChart extends ChartWidget
$totalMemory = collect(cache()->get("nodes.$node->id.memory_total"))->last();
$used = config('panel.use_binary_prefix')
? number_format($latestMemoryUsed / 1024 / 1024 / 1024, 2) .' GiB'
: number_format($latestMemoryUsed / 1000 / 1000 / 1000, 2) . ' 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($totalMemory / 1024 / 1024 / 1024, 2) .' GiB'
: number_format($totalMemory / 1000 / 1000 / 1000, 2) . ' 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 'Memory - ' . $used . ' Of ' . $total;
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Filament\Resources;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Filament\Resources\RoleResource\Pages;
use App\Models\Role;
use Filament\Facades\Filament;
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\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Illuminate\Support\Str;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'tabler-users-group';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function form(Form $form): Form
{
$permissions = [];
foreach (RolePermissionModels::cases() as $model) {
$options = [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
$options[$prefix->value . ' ' . strtolower($model->value)] = Str::headline($prefix->value);
}
if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) {
foreach (Role::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) {
$options[$permission . ' ' . strtolower($model->value)] = Str::headline($permission);
}
}
$permissions[] = self::makeSection($model->value, $options);
}
foreach (Role::SPECIAL_PERMISSIONS as $model => $prefixes) {
$options = [];
foreach ($prefixes as $prefix) {
$options[$prefix . ' ' . strtolower($model)] = Str::headline($prefix);
}
$permissions[] = self::makeSection($model, $options);
}
return $form
->columns(1)
->schema([
TextInput::make('name')
->label('Role Name')
->required()
->disabled(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
TextInput::make('guard_name')
->label('Guard Name')
->default(Filament::getCurrentPanel()?->getAuthGuard() ?? '')
->nullable()
->hidden(),
Fieldset::make('Permissions')
->columns(3)
->schema($permissions)
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Placeholder::make('permissions')
->content('The Root Admin has all permissions.')
->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
]);
}
private static function makeSection(string $model, array $options): Section
{
$icon = null;
if (class_exists('\App\Filament\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Resources\\' . $model . 'Resource')::getNavigationIcon();
} elseif (class_exists('\App\Filament\Pages\\' . $model)) {
$icon = ('\App\Filament\Pages\\' . $model)::getNavigationIcon();
}
return Section::make(Str::headline(Str::plural($model)))
->columnSpan(1)
->collapsible()
->collapsed()
->icon($icon)
->headerActions([
Action::make('count')
->label(fn (Get $get) => count($get(strtolower($model) . '_list')))
->badge(),
])
->schema([
CheckboxList::make(strtolower($model) . '_list')
->label('')
->options($options)
->columns()
->gridDirection('row')
->bulkToggleable()
->live()
->afterStateHydrated(
function (Component $component, string $operation, ?Role $record) use ($options) {
if (in_array($operation, ['edit', 'view'])) {
if (blank($record)) {
return;
}
if ($component->isVisible()) {
$component->state(
collect($options)
->filter(fn ($value, $key) => $record->checkPermissionTo($key))
->keys()
->toArray()
);
}
}
}
)
->dehydrated(fn ($state) => !blank($state)),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
protected static bool $canCreateAnother = false;
public Collection $permissions;
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->permissions = collect($data)
->filter(function ($permission, $key) {
return !in_array($key, ['name', 'guard_name']);
})
->values()
->flatten()
->unique();
return Arr::only($data, ['name', 'guard_name']);
}
protected function afterCreate(): void
{
$permissionModels = collect();
$this->permissions->each(function ($permission) use ($permissionModels) {
$permissionModels->push(Permission::firstOrCreate([
'name' => $permission,
'guard_name' => $this->data['guard_name'],
]));
});
$this->record->syncPermissions($permissionModels);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
public Collection $permissions;
protected function mutateFormDataBeforeSave(array $data): array
{
$this->permissions = collect($data)
->filter(function ($permission, $key) {
return !in_array($key, ['name', 'guard_name']);
})
->values()
->flatten()
->unique();
return Arr::only($data, ['name', 'guard_name']);
}
protected function afterSave(): void
{
$permissionModels = collect();
$this->permissions->each(function ($permission) use ($permissionModels) {
$permissionModels->push(Permission::firstOrCreate([
'name' => $permission,
'guard_name' => $this->data['guard_name'],
]));
});
$this->record->syncPermissions($permissionModels);
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->disabled(fn (Role $role) => $role->isRootAdmin() || $role->users_count >= 1)
->label(fn (Role $role) => $role->isRootAdmin() ? 'Can\'t delete Root Admin' : ($role->users_count >= 1 ? 'In Use' : 'Delete')),
];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction as CreateActionTable;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->sortable()
->searchable(),
TextColumn::make('guard_name')
->hidden()
->sortable()
->searchable(),
TextColumn::make('permissions_count')
->label('Permissions')
->badge()
->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? 'All' : $state),
TextColumn::make('users_count')
->label('Users')
->counts('users')
->icon('tabler-users'),
])
->actions([
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0)
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete role')),
]),
])
->emptyStateIcon('tabler-users-group')
->emptyStateDescription('')
->emptyStateHeading('No Roles')
->emptyStateActions([
CreateActionTable::make('create')
->label('Create Role')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create Role'),
];
}
}

View File

@ -19,13 +19,6 @@ class ServerResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [

View File

@ -10,16 +10,34 @@ use App\Models\User;
use App\Services\Servers\RandomWordService;
use App\Services\Servers\ServerCreationService;
use App\Services\Users\UserCreationService;
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;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\Wizard;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Filament\Forms;
use Filament\Forms\Components\Wizard;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\HtmlString;
use Closure;
use LogicException;
class CreateServer extends CreateRecord
{
@ -36,7 +54,7 @@ class CreateServer extends CreateRecord
return $form
->schema([
Wizard::make([
Wizard\Step::make('Information')
Step::make('Information')
->label('Information')
->icon('tabler-info-circle')
->completedIcon('tabler-check')
@ -47,12 +65,12 @@ class CreateServer extends CreateRecord
'lg' => 6,
])
->schema([
Forms\Components\TextInput::make('name')
TextInput::make('name')
->prefixIcon('tabler-server')
->label('Name')
->suffixAction(Forms\Components\Actions\Action::make('random')
->icon('tabler-dice-' . random_int(1, 6))
->action(function (Forms\Set $set, Forms\Get $get) {
->action(function (Set $set, Get $get) {
$egg = Egg::find($get('egg_id'));
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
@ -69,7 +87,7 @@ class CreateServer extends CreateRecord
->required()
->maxLength(255),
Forms\Components\Select::make('owner_id')
Select::make('owner_id')
->preload()
->prefixIcon('tabler-user')
->default(auth()->user()->id)
@ -82,38 +100,23 @@ class CreateServer extends CreateRecord
])
->relationship('user', 'username')
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->root_admin ? '(admin)' : ''))
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->isRootAdmin() ? '(admin)' : ''))
->createOptionForm([
Forms\Components\TextInput::make('username')
TextInput::make('username')
->alphaNum()
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
TextInput::make('email')
->email()
->required()
->unique()
->maxLength(255),
Forms\Components\TextInput::make('password')
TextInput::make('password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false)
->hidden(),
])
->createOptionUsing(function ($data) {
resolve(UserCreationService::class)->handle($data);
@ -121,7 +124,7 @@ class CreateServer extends CreateRecord
})
->required(),
Forms\Components\Select::make('node_id')
Select::make('node_id')
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
@ -140,7 +143,7 @@ class CreateServer extends CreateRecord
})
->required(),
Forms\Components\Textarea::make('description')
Textarea::make('description')
->placeholder('Description')
->rows(3)
->columnSpan([
@ -149,11 +152,11 @@ class CreateServer extends CreateRecord
'md' => 6,
'lg' => 6,
])
->label('Notes'),
->label('Description'),
]),
Wizard\Step::make('Egg')
->label('Egg')
Step::make('Egg Configuration')
->label('Egg Configuration')
->icon('tabler-egg')
->completedIcon('tabler-check')
->columns([
@ -163,9 +166,7 @@ class CreateServer extends CreateRecord
'lg' => 6,
])
->schema([
Forms\Components\Select::make('egg_id')
->disabledOn('edit')
Select::make('egg_id')
->prefixIcon('tabler-egg')
->columnSpan([
'default' => 2,
@ -177,7 +178,7 @@ class CreateServer extends CreateRecord
->searchable()
->preload()
->live()
->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get, $old) {
->afterStateUpdated(function ($state, Set $set, Get $get, $old) {
$this->egg = Egg::query()->find($state);
$set('startup', $this->egg?->startup);
$set('image', '');
@ -191,7 +192,7 @@ class CreateServer extends CreateRecord
})
->required(),
Forms\Components\ToggleButtons::make('skip_scripts')
ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')
->default(false)
->columnSpan([
@ -215,7 +216,7 @@ class CreateServer extends CreateRecord
->inline()
->required(),
Forms\Components\ToggleButtons::make('start_on_completion')
ToggleButtons::make('start_on_completion')
->label('Start Server After Install?')
->default(true)
->required()
@ -239,10 +240,10 @@ class CreateServer extends CreateRecord
])
->inline(),
Forms\Components\Textarea::make('startup')
->hidden(fn () => !$this->egg)
Textarea::make('startup')
->hintIcon('tabler-code')
->label('Startup Command')
->hidden(fn () => !$this->egg)
->required()
->live()
->disabled(fn (Forms\Get $get) => $this->egg === null)
@ -266,24 +267,24 @@ class CreateServer extends CreateRecord
'lg' => 6,
]),
Forms\Components\Hidden::make('environment')->default([]),
Hidden::make('environment')->default([]),
Forms\Components\Section::make('Variables')
Section::make('Variables')
->icon('tabler-eggs')
->iconColor('primary')
->hidden(fn (Forms\Get $get) => $get('egg_id') === null)
->hidden(fn (Get $get) => $get('egg_id') === null)
->collapsible()
->columnSpanFull()
->schema([
Forms\Components\Placeholder::make('Select an egg first to show its variables!')
->hidden(fn (Forms\Get $get) => $get('egg_id')),
Placeholder::make('Select an egg first to show its variables!')
->hidden(fn (Get $get) => $get('egg_id')),
Forms\Components\Placeholder::make('The selected egg has no variables!')
->hidden(fn (Forms\Get $get) => !$get('egg_id') ||
Placeholder::make('The selected egg has no variables!')
->hidden(fn (Get $get) => !$get('egg_id') ||
Egg::query()->find($get('egg_id'))?->variables()?->count()
),
Forms\Components\Repeater::make('server_variables')
Repeater::make('server_variables')
->label('')
->relationship('serverVariables')
->saveRelationshipsBeforeChildrenUsing(null)
@ -296,11 +297,11 @@ class CreateServer extends CreateRecord
->hidden(fn ($state) => empty($state))
->schema(function () {
$text = Forms\Components\TextInput::make('variable_value')
$text = TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->required(fn (Forms\Get $get) => in_array('required', $get('rules')))
->required(fn (Get $get) => in_array('required', $get('rules')))
->rules(
fn (Forms\Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
fn (Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $get('rules'),
]);
@ -313,7 +314,7 @@ class CreateServer extends CreateRecord
},
);
$select = Forms\Components\Select::make('variable_value')
$select = Select::make('variable_value')
->hidden($this->shouldHideComponent(...))
->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false);
@ -324,11 +325,11 @@ class CreateServer extends CreateRecord
$component = $component
->live(onBlur: true)
->hintIcon('tabler-code')
->label(fn (Forms\Get $get) => $get('name'))
->hintIconTooltip(fn (Forms\Get $get) => implode('|', $get('rules')))
->prefix(fn (Forms\Get $get) => '{{' . $get('env_variable') . '}}')
->helperText(fn (Forms\Get $get) => empty($get('description')) ? '—' : $get('description'))
->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
->label(fn (Get $get) => $get('name'))
->hintIconTooltip(fn (Get $get) => implode('|', $get('rules')))
->prefix(fn (Get $get) => '{{' . $get('env_variable') . '}}')
->helperText(fn (Get $get) => empty($get('description')) ? '—' : $get('description'))
->afterStateUpdated(function (Set $set, Get $get, $state) {
$environment = $get($envPath = '../../environment');
$environment[$get('env_variable')] = $state;
$set($envPath, $environment);
@ -404,12 +405,12 @@ class CreateServer extends CreateRecord
]),
Wizard\Step::make('Environment')
->label('Environment')
Step::make('Environment Configuration')
->label('Environment Configuration')
->icon('tabler-brand-docker')
->completedIcon('tabler-check')
->schema([
Forms\Components\Fieldset::make('Resource Limits')
Fieldset::make('Resource Limits')
->columnSpan(6)
->columns([
'default' => 1,
@ -418,14 +419,14 @@ class CreateServer extends CreateRecord
'lg' => 3,
])
->schema([
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_mem')
ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->live()
->options([
true => 'Unlimited',
@ -437,9 +438,9 @@ class CreateServer extends CreateRecord
])
->columnSpan(2),
Forms\Components\TextInput::make('memory')
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0)
@ -449,15 +450,15 @@ class CreateServer extends CreateRecord
->minValue(0),
]),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_disk')
ToggleButtons::make('unlimited_disk')
->label('Disk Space')->inlineLabel()->inline()
->default(true)
->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->options([
true => 'Unlimited',
false => 'Limited',
@ -468,9 +469,9 @@ class CreateServer extends CreateRecord
])
->columnSpan(2),
Forms\Components\TextInput::make('disk')
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0)
@ -480,14 +481,14 @@ class CreateServer extends CreateRecord
->minValue(0),
]),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_cpu')
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0))
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->live()
->options([
true => 'Unlimited',
@ -499,9 +500,9 @@ class CreateServer extends CreateRecord
])
->columnSpan(2),
Forms\Components\TextInput::make('cpu')
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->suffix('%')
->default(0)
@ -512,23 +513,23 @@ class CreateServer extends CreateRecord
->helperText('100% equals one CPU core.'),
]),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('swap_support')
ToggleButtons::make('swap_support')
->live()
->label('Enable Swap Memory')
->inlineLabel()
->inline()
->columnSpan(2)
->default('disabled')
->afterStateUpdated(function ($state, Forms\Set $set) {
->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) {
'unlimited' => -1,
'disabled' => 0,
'limited' => 128,
default => throw new \LogicException('Invalid state'),
default => throw new LogicException('Invalid state'),
};
$set('swap', $value);
@ -544,9 +545,9 @@ class CreateServer extends CreateRecord
'disabled' => 'danger',
]),
Forms\Components\TextInput::make('swap')
TextInput::make('swap')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) {
->hidden(fn (Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited' => true,
default => false,
})
@ -560,16 +561,16 @@ class CreateServer extends CreateRecord
->integer(),
]),
Forms\Components\Hidden::make('io')
Hidden::make('io')
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion')
->default(config('panel.default_io_weight')),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('oom_killer')
ToggleButtons::make('oom_killer')
->label('OOM Killer')
->inlineLabel()->inline()
->default(false)
@ -583,12 +584,12 @@ class CreateServer extends CreateRecord
true => 'danger',
]),
Forms\Components\TextInput::make('oom_disabled_hidden')
TextInput::make('oom_disabled_hidden')
->hidden(),
]),
]),
Forms\Components\Fieldset::make('Feature Limits')
Fieldset::make('Feature Limits')
->inlineLabel()
->columnSpan(6)
->columns([
@ -598,21 +599,21 @@ class CreateServer extends CreateRecord
'lg' => 3,
])
->schema([
Forms\Components\TextInput::make('allocation_limit')
TextInput::make('allocation_limit')
->label('Allocations')
->suffixIcon('tabler-network')
->required()
->numeric()
->minValue(0)
->default(0),
Forms\Components\TextInput::make('database_limit')
TextInput::make('database_limit')
->label('Databases')
->suffixIcon('tabler-database')
->required()
->numeric()
->minValue(0)
->default(0),
Forms\Components\TextInput::make('backup_limit')
TextInput::make('backup_limit')
->label('Backups')
->suffixIcon('tabler-copy-check')
->required()
@ -620,7 +621,7 @@ class CreateServer extends CreateRecord
->minValue(0)
->default(0),
]),
Forms\Components\Fieldset::make('Docker Settings')
Fieldset::make('Docker Settings')
->columns([
'default' => 1,
'sm' => 2,
@ -629,10 +630,10 @@ class CreateServer extends CreateRecord
])
->columnSpan(6)
->schema([
Forms\Components\Select::make('select_image')
Select::make('select_image')
->label('Image Name')
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state))
->options(function ($state, Forms\Get $get, Forms\Set $set) {
->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
@ -653,10 +654,10 @@ class CreateServer extends CreateRecord
'lg' => 2,
]),
Forms\Components\TextInput::make('image')
TextInput::make('image')
->label('Image')
->debounce(500)
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) {
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
@ -674,13 +675,13 @@ class CreateServer extends CreateRecord
'lg' => 2,
]),
Forms\Components\KeyValue::make('docker_labels')
KeyValue::make('docker_labels')
->label('Container Labels')
->keyLabel('Title')
->valueLabel('Description')
->columnSpanFull(),
Forms\Components\CheckboxList::make('mounts')
CheckboxList::make('mounts')
->live()
->relationship('mounts')
->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? [])
@ -731,24 +732,24 @@ class CreateServer extends CreateRecord
return $service->handle($data, validateVariables: false);
}
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool
private function shouldHideComponent(Get $get, Component $component): bool
{
$containsRuleIn = collect($get('rules'))->reduce(
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
);
if ($component instanceof Forms\Components\Select) {
if ($component instanceof Select) {
return $containsRuleIn;
}
if ($component instanceof Forms\Components\TextInput) {
if ($component instanceof TextInput) {
return !$containsRuleIn;
}
throw new \Exception('Component type not supported: ' . $component::class);
throw new Exception('Component type not supported: ' . $component::class);
}
private function getSelectOptionsFromRules(Forms\Get $get): array
private function getSelectOptionsFromRules(Get $get): array
{
$inRule = collect($get('rules'))->reduce(
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''

View File

@ -28,12 +28,20 @@ use App\Models\Egg;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Services\Servers\ServerDeletionService;
use Closure;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Validator;
use Closure;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class EditServer extends EditRecord
@ -59,7 +67,7 @@ class EditServer extends EditRecord
])
->columnSpanFull()
->tabs([
Tabs\Tab::make('Information')
Tab::make('Information')
->icon('tabler-info-circle')
->schema([
Forms\Components\ToggleButtons::make('condition')
@ -81,12 +89,13 @@ class EditServer extends EditRecord
'md' => 1,
'lg' => 1,
]),
Forms\Components\TextInput::make('name')
TextInput::make('name')
->prefixIcon('tabler-server')
->label('Display Name')
->suffixAction(Forms\Components\Actions\Action::make('random')
->suffixAction(Action::make('random')
->icon('tabler-dice-' . random_int(1, 6))
->action(function (Forms\Set $set, Forms\Get $get) {
->action(function (Set $set, Get $get) {
$egg = Egg::find($get('egg_id'));
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
@ -103,7 +112,7 @@ class EditServer extends EditRecord
->required()
->maxLength(255),
Forms\Components\Select::make('owner_id')
Select::make('owner_id')
->prefixIcon('tabler-user')
->label('Owner')
->columnSpan([
@ -117,11 +126,31 @@ class EditServer extends EditRecord
->preload()
->required(),
Forms\Components\Textarea::make('description')
ToggleButtons::make('condition')
->label('Server Status')
->formatStateUsing(fn (Server $server) => $server->condition)
->options(fn ($state) => collect(array_merge(ContainerStatus::cases(), ServerState::cases()))
->filter(fn ($condition) => $condition->value === $state)
->mapWithKeys(fn ($state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()])
)
->colors(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->color()]
))
->icons(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->icon()]
))
->columnSpan([
'default' => 2,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Textarea::make('description')
->label('Notes')
->columnSpanFull(),
Forms\Components\TextInput::make('uuid')
TextInput::make('uuid')
->hintAction(CopyAction::make())
->columnSpan([
'default' => 2,
@ -131,7 +160,7 @@ class EditServer extends EditRecord
])
->readOnly()
->dehydrated(false),
Forms\Components\TextInput::make('uuid_short')
TextInput::make('uuid_short')
->label('Short UUID')
->hintAction(CopyAction::make())
->columnSpan([
@ -142,7 +171,7 @@ class EditServer extends EditRecord
])
->readOnly()
->dehydrated(false),
Forms\Components\TextInput::make('external_id')
TextInput::make('external_id')
->label('External ID')
->columnSpan([
'default' => 2,
@ -151,7 +180,7 @@ class EditServer extends EditRecord
'lg' => 3,
])
->maxLength(255),
Forms\Components\Select::make('node_id')
Select::make('node_id')
->label('Node')
->relationship('node', 'name')
->columnSpan([
@ -162,11 +191,11 @@ class EditServer extends EditRecord
])
->disabled(),
]),
Tabs\Tab::make('env-tab')
->label('Environment')
Tab::make('Environment')
->icon('tabler-brand-docker')
->schema([
Forms\Components\Fieldset::make('Resource Limits')
Fieldset::make('Resource Limits')
->columns([
'default' => 1,
'sm' => 2,
@ -174,14 +203,14 @@ class EditServer extends EditRecord
'lg' => 3,
])
->schema([
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_mem')
ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0)
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live()
->options([
true => 'Unlimited',
@ -193,9 +222,9 @@ class EditServer extends EditRecord
])
->columnSpan(2),
Forms\Components\TextInput::make('memory')
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
@ -204,15 +233,15 @@ class EditServer extends EditRecord
->minValue(0),
]),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_disk')
ToggleButtons::make('unlimited_disk')
->label('Disk Space')->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0)
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
@ -223,9 +252,9 @@ class EditServer extends EditRecord
])
->columnSpan(2),
Forms\Components\TextInput::make('disk')
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
@ -234,14 +263,14 @@ class EditServer extends EditRecord
->minValue(0),
]),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_cpu')
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0)
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->live()
->options([
true => 'Unlimited',
@ -253,9 +282,9 @@ class EditServer extends EditRecord
])
->columnSpan(2),
Forms\Components\TextInput::make('cpu')
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->suffix('%')
->required()
@ -264,15 +293,15 @@ class EditServer extends EditRecord
->minValue(0),
]),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('swap_support')
ToggleButtons::make('swap_support')
->live()
->label('Enable Swap Memory')->inlineLabel()->inline()
->columnSpan(2)
->afterStateUpdated(function ($state, Forms\Set $set) {
->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) {
'unlimited' => -1,
'disabled' => 0,
@ -282,7 +311,7 @@ class EditServer extends EditRecord
$set('swap', $value);
})
->formatStateUsing(function (Forms\Get $get) {
->formatStateUsing(function (Get $get) {
return match (true) {
$get('swap') > 0 => 'limited',
$get('swap') == 0 => 'disabled',
@ -301,9 +330,9 @@ class EditServer extends EditRecord
'disabled' => 'danger',
]),
Forms\Components\TextInput::make('swap')
TextInput::make('swap')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) {
->hidden(fn (Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited', true => true,
default => false,
})
@ -319,11 +348,11 @@ class EditServer extends EditRecord
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion'),
Forms\Components\Grid::make()
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('oom_killer')
ToggleButtons::make('oom_killer')
->label('OOM Killer')->inlineLabel()->inline()
->columnSpan(2)
->options([
@ -335,12 +364,12 @@ class EditServer extends EditRecord
true => 'danger',
]),
Forms\Components\TextInput::make('oom_disabled_hidden')
TextInput::make('oom_disabled_hidden')
->hidden(),
]),
]),
Forms\Components\Fieldset::make('Feature Limits')
Fieldset::make('Feature Limits')
->inlineLabel()
->columns([
'default' => 1,
@ -349,23 +378,23 @@ class EditServer extends EditRecord
'lg' => 3,
])
->schema([
Forms\Components\TextInput::make('allocation_limit')
TextInput::make('allocation_limit')
->suffixIcon('tabler-network')
->required()
->minValue(0)
->numeric(),
Forms\Components\TextInput::make('database_limit')
TextInput::make('database_limit')
->suffixIcon('tabler-database')
->required()
->minValue(0)
->numeric(),
Forms\Components\TextInput::make('backup_limit')
TextInput::make('backup_limit')
->suffixIcon('tabler-copy-check')
->required()
->minValue(0)
->numeric(),
]),
Forms\Components\Fieldset::make('Docker Settings')
Fieldset::make('Docker Settings')
->columns([
'default' => 1,
'sm' => 2,
@ -373,10 +402,10 @@ class EditServer extends EditRecord
'lg' => 3,
])
->schema([
Forms\Components\Select::make('select_image')
Select::make('select_image')
->label('Image Name')
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state))
->options(function ($state, Forms\Get $get, Forms\Set $set) {
->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
@ -392,10 +421,10 @@ class EditServer extends EditRecord
->selectablePlaceholder(false)
->columnSpan(1),
Forms\Components\TextInput::make('image')
TextInput::make('image')
->label('Image')
->debounce(500)
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) {
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
@ -415,7 +444,7 @@ class EditServer extends EditRecord
->columnSpanFull(),
]),
]),
Tabs\Tab::make('Egg')
Tab::make('Egg')
->icon('tabler-egg')
->columns([
'default' => 1,
@ -424,7 +453,7 @@ class EditServer extends EditRecord
'lg' => 5,
])
->schema([
Forms\Components\Select::make('egg_id')
Select::make('egg_id')
->disabledOn('edit')
->prefixIcon('tabler-egg')
->columnSpan([
@ -438,7 +467,7 @@ class EditServer extends EditRecord
->preload()
->required(),
Forms\Components\ToggleButtons::make('skip_scripts')
ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')->inline()
->columnSpan([
'default' => 6,
@ -520,7 +549,7 @@ class EditServer extends EditRecord
}
}),
Forms\Components\Textarea::make('startup')
Textarea::make('startup')
->label('Startup Command')
->required()
->columnSpan(6)
@ -531,18 +560,18 @@ class EditServer extends EditRecord
);
}),
Forms\Components\Textarea::make('defaultStartup')
Textarea::make('defaultStartup')
->hintAction(CopyAction::make())
->label('Default Startup Command')
->disabled()
->formatStateUsing(function ($state, Get $get, Set $set) {
->formatStateUsing(function ($state, Get $get) {
$egg = Egg::query()->find($get('egg_id'));
return $egg->startup;
})
->columnSpan(6),
Forms\Components\Repeater::make('server_variables')
Repeater::make('server_variables')
->relationship('serverVariables', function (Builder $query) {
/** @var Server $server */
$server = $this->getRecord();
@ -571,7 +600,7 @@ class EditServer extends EditRecord
->reorderable(false)->addable(false)->deletable(false)
->schema(function () {
$text = Forms\Components\TextInput::make('variable_value')
$text = TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->required(fn (ServerVariable $serverVariable) => $serverVariable->variable->getRequiredAttribute())
->rules([
@ -588,7 +617,7 @@ class EditServer extends EditRecord
},
]);
$select = Forms\Components\Select::make('variable_value')
$select = Select::make('variable_value')
->hidden($this->shouldHideComponent(...))
->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false);
@ -609,10 +638,10 @@ class EditServer extends EditRecord
})
->columnSpan(6),
]),
Tabs\Tab::make('Mounts')
Tab::make('Mounts')
->icon('tabler-layers-linked')
->schema([
Forms\Components\CheckboxList::make('mounts')
CheckboxList::make('mounts')
->relationship('mounts')
->options(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]))
->descriptions(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]))
@ -620,7 +649,7 @@ class EditServer extends EditRecord
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->columnSpanFull(),
]),
Tabs\Tab::make('Databases')
Tab::make('Databases')
->icon('tabler-database')
->schema([
Repeater::make('databases')
@ -628,7 +657,7 @@ class EditServer extends EditRecord
->helperText(fn (Server $server) => $server->databases->isNotEmpty() ? '' : 'No Databases exist for this Server')
->columns(2)
->schema([
Forms\Components\TextInput::make('database')
TextInput::make('database')
->columnSpan(2)
->label('Database Name')
->disabled()
@ -639,11 +668,11 @@ class EditServer extends EditRecord
->icon('tabler-trash')
->action(fn (DatabaseManagementService $databaseManagementService, $record) => $databaseManagementService->delete($record))
),
Forms\Components\TextInput::make('username')
TextInput::make('username')
->disabled()
->formatStateUsing(fn ($record) => $record->username)
->columnSpan(2),
Forms\Components\TextInput::make('password')
TextInput::make('password')
->disabled()
->hintAction(
Action::make('rotate')
@ -653,30 +682,30 @@ class EditServer extends EditRecord
)
->formatStateUsing(fn (Database $database) => $database->password)
->columnSpan(2),
Forms\Components\TextInput::make('remote')
TextInput::make('remote')
->disabled()
->formatStateUsing(fn ($record) => $record->remote)
->columnSpan(1)
->label('Connections From'),
Forms\Components\TextInput::make('max_connections')
TextInput::make('max_connections')
->disabled()
->formatStateUsing(fn ($record) => $record->max_connections)
->columnSpan(1),
Forms\Components\TextInput::make('JDBC')
TextInput::make('JDBC')
->disabled()
->label('JDBC Connection String')
->columnSpan(2)
->formatStateUsing(fn (Forms\Get $get, $record) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($record->password) . '@' . $record->host->host . ':' . $record->host->port . '/' . $get('database')),
->formatStateUsing(fn (Get $get, $record) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($record->password) . '@' . $record->host->host . ':' . $record->host->port . '/' . $get('database')),
])
->relationship('databases')
->deletable(false)
->addable(false)
->columnSpan(4),
])->columns(4),
Tabs\Tab::make('Actions')
Tab::make('Actions')
->icon('tabler-settings')
->schema([
Forms\Components\Fieldset::make('Server Actions')
Fieldset::make('Server Actions')
->columns([
'default' => 1,
'sm' => 2,
@ -684,11 +713,11 @@ class EditServer extends EditRecord
'lg' => 6,
])
->schema([
Forms\Components\Grid::make()
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('toggleInstall')
Action::make('toggleInstall')
->label('Toggle Install Status')
->disabled(fn (Server $server) => $server->isSuspended())
->action(function (ServersController $serversController, Server $server) {
@ -697,14 +726,14 @@ class EditServer extends EditRecord
$this->refreshFormData(['status', 'docker']);
}),
])->fullWidth(),
Forms\Components\ToggleButtons::make('')
ToggleButtons::make('')
->hint('If you need to change the install status from uninstalled to installed, or vice versa, you may do so with this button.'),
]),
Forms\Components\Grid::make()
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('toggleSuspend')
Action::make('toggleSuspend')
->label('Suspend')
->color('warning')
->hidden(fn (Server $server) => $server->isSuspended())
@ -714,7 +743,7 @@ class EditServer extends EditRecord
$this->refreshFormData(['status', 'docker']);
}),
Forms\Components\Actions\Action::make('toggleUnsuspend')
Action::make('toggleUnsuspend')
->label('Unsuspend')
->color('success')
->hidden(fn (Server $server) => !$server->isSuspended())
@ -725,37 +754,37 @@ class EditServer extends EditRecord
$this->refreshFormData(['status', 'docker']);
}),
])->fullWidth(),
Forms\Components\ToggleButtons::make('')
ToggleButtons::make('')
->hidden(fn (Server $server) => $server->isSuspended())
->hint('This will suspend the server, stop any running processes, and immediately block the user from being able to access their files or otherwise manage the server through the panel or API.'),
Forms\Components\ToggleButtons::make('')
ToggleButtons::make('')
->hidden(fn (Server $server) => !$server->isSuspended())
->hint('This will unsuspend the server and restore normal user access.'),
]),
Forms\Components\Grid::make()
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('transfer')
Action::make('transfer')
->label('Transfer Soon™')
->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, []))
->disabled() //TODO!
->form([ //TODO!
Forms\Components\Select::make('newNode')
Select::make('newNode')
->label('New Node')
->required()
->options([
true => 'on',
false => 'off',
]),
Forms\Components\Select::make('newMainAllocation')
Select::make('newMainAllocation')
->label('New Main Allocation')
->required()
->options([
true => 'on',
false => 'off',
]),
Forms\Components\Select::make('newAdditionalAllocation')
Select::make('newAdditionalAllocation')
->label('New Additional Allocations')
->options([
true => 'on',
@ -764,14 +793,14 @@ class EditServer extends EditRecord
])
->modalHeading('Transfer'),
])->fullWidth(),
Forms\Components\ToggleButtons::make('')
ToggleButtons::make('')
->hint('Transfer this server to another node connected to this panel. Warning! This feature has not been fully tested and may have bugs.'),
]),
Forms\Components\Grid::make()
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('reinstall')
Action::make('reinstall')
->label('Reinstall')
->color('danger')
->requiresConfirmation()
@ -780,7 +809,7 @@ class EditServer extends EditRecord
->disabled(fn (Server $server) => $server->isSuspended())
->action(fn (ServersController $serversController, Server $server) => $serversController->reinstallServer($server)),
])->fullWidth(),
Forms\Components\ToggleButtons::make('')
ToggleButtons::make('')
->hint('This will reinstall the server with the assigned egg install script.'),
]),
]),
@ -794,9 +823,9 @@ class EditServer extends EditRecord
return $form
->columns()
->schema([
Forms\Components\Select::make('toNode')
Select::make('toNode')
->label('New Node'),
Forms\Components\TextInput::make('newAllocation')
TextInput::make('newAllocation')
->label('Allocation'),
]);
@ -804,12 +833,17 @@ class EditServer extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make('Delete')
Actions\Action::make('Delete')
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->label('Delete')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server))
->requiresConfirmation(),
->requiresConfirmation()
->action(function (Server $server) {
resolve(ServerDeletionService::class)->handle($server);
return redirect(ListServers::getUrl());
})
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console')
->label('Console')
->icon('tabler-terminal')
@ -844,20 +878,19 @@ class EditServer extends EditRecord
return true;
}
if ($component instanceof Forms\Components\Select) {
if ($component instanceof Select) {
return !$containsRuleIn;
}
if ($component instanceof Forms\Components\TextInput) {
if ($component instanceof TextInput) {
return $containsRuleIn;
}
throw new \Exception('Component type not supported: ' . $component::class);
throw new Exception('Component type not supported: ' . $component::class);
}
private function getSelectOptionsFromRules(ServerVariable $serverVariable): array
{
$inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'));
return str($inRule)

View File

@ -6,10 +6,12 @@ use App\Filament\Resources\ServerResource;
use App\Models\Server;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Grouping\Group;
use Filament\Tables\Table;
use Filament\Tables;
class ListServers extends ListRecords
{
@ -26,46 +28,55 @@ class ListServers extends ListRecords
Group::make('egg.name')->getDescriptionFromRecordUsing(fn (Server $server): string => str($server->egg->description)->limit(150)),
])
->columns([
Tables\Columns\TextColumn::make('condition')
TextColumn::make('condition')
->default('unknown')
->badge()
->icon(fn (Server $server) => $server->conditionIcon())
->color(fn (Server $server) => $server->conditionColor()),
Tables\Columns\TextColumn::make('uuid')
TextColumn::make('uuid')
->hidden()
->label('UUID')
->searchable(),
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->icon('tabler-brand-docker')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('node.name')
TextColumn::make('node.name')
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'node.name')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('egg.name')
TextColumn::make('egg.name')
->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'egg.name')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('user.username')
TextColumn::make('user.username')
->icon('tabler-user')
->label('Owner')
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'user.username')
->sortable()
->searchable(),
TextColumn::make('image')->hidden(),
TextColumn::make('backups_count')
->counts('backups')
->label('Backups')
->icon('tabler-file-download')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('ports')
TextColumn::make('ports')
->badge()
->separator(),
])
->actions([
Tables\Actions\Action::make('View')
Action::make('View')
->icon('tabler-terminal')
->url(fn (Server $server) => "/server/$server->uuid_short"),
Tables\Actions\EditAction::make(),
->url(fn (Server $server) => "/server/$server->uuid_short")
->authorize(fn () => auth()->user()->can('view server')),
EditAction::make(),
])
->emptyStateIcon('tabler-brand-docker')
->searchable()

View File

@ -0,0 +1,161 @@
<?php
namespace App\Filament\Resources\ServerResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AssociateAction;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Server getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('ip')
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server')
->columns([
TextColumn::make('ip')->label('IP'),
TextColumn::make('port')->label('Port'),
TextInputColumn::make('ip_alias')->label('Alias'),
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(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]))
->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id)
->label('Primary'),
])
->actions([
Action::make('make-primary')
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]))
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
])
->headerActions([
CreateAction::make()->label('Create Allocation')
->createAnother(false)
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->node->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data) => resolve(AssignmentService::class)->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())),
AssociateAction::make()
->multiple()
->associateAnother(false)
->preloadRecordSelect()
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node))
->label('Add Allocation'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DissociateBulkAction::make(),
]),
]);
}
}

View File

@ -21,13 +21,14 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password;
@ -57,7 +58,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->maxLength(255)
->unique(ignoreRecord: true)
->autofocus(),
TextInput::make('email')
->prefixIcon('tabler-mail')
->label(trans('strings.email'))
@ -65,7 +65,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->required()
->maxLength(255)
->unique(ignoreRecord: true),
TextInput::make('password')
->label(trans('strings.password'))
->password()
@ -77,7 +76,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->dehydrateStateUsing(fn ($state): string => Hash::make($state))
->live(debounce: 500)
->same('passwordConfirmation'),
TextInput::make('passwordConfirmation')
->label(trans('strings.password_confirmation'))
->password()
@ -86,13 +84,11 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->required()
->visible(fn (Get $get): bool => filled($get('password')))
->dehydrated(false),
Select::make('timezone')
->required()
->prefixIcon('tabler-clock-pin')
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(),
Select::make('language')
->label(trans('strings.language'))
->required()
@ -111,7 +107,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
Tab::make('2FA')
->icon('tabler-shield-lock')
->schema(function () {
if ($this->getUser()->use_totp) {
return [
Placeholder::make('2fa-already-enabled')
@ -196,16 +191,13 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->helperText('Enter your current password to verify.'),
];
}),
Tab::make('API Keys')
->icon('tabler-key')
->schema([
Grid::make(5)->schema([
Section::make('Create API Key')->columnSpan(3)->schema([
TextInput::make('description')
->live(),
TagsInput::make('allowed_ips')
->live()
->splitKeys([',', ' ', 'Tab'])
@ -222,12 +214,10 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
$get('description'),
$get('allowed_ips'),
);
Activity::event('user:api-key.create')
->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier)
->log();
$action->success();
}),
]),
@ -256,13 +246,11 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
]),
]),
]),
Tab::make('SSH Keys')
->icon('tabler-lock-code')
->schema([
Placeholder::make('Coming soon!'),
]),
Tab::make('Activity')
->icon('tabler-history')
->schema([
@ -286,7 +274,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
];
}
protected function handleRecordUpdate($record, $data): \Illuminate\Database\Eloquent\Model
protected function handleRecordUpdate($record, $data): Model
{
if ($token = $data['2facode'] ?? null) {
/** @var ToggleTwoFactorService $service */

View File

@ -3,13 +3,16 @@
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Services\Exceptions\FilamentExceptionHandler;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use App\Models\Role;
use App\Models\User;
use Filament\Forms;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord
@ -20,54 +23,33 @@ class EditUser extends EditRecord
return $form
->schema([
Section::make()->schema([
Forms\Components\TextInput::make('username')->required()->maxLength(255),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(255),
Forms\Components\TextInput::make('password')
TextInput::make('username')->required()->maxLength(255),
TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('password')
->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->disableOptionWhen(function (string $operation, $value, User $user) {
if ($operation !== 'edit' || $value) {
return false;
}
return $user->isLastRootAdmin();
})
->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '')
->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '')
->hintColor('warning')
->inline()
->required()
->default(false),
Forms\Components\Hidden::make('skipValidation')->default(true),
Forms\Components\Select::make('language')
Select::make('language')
->required()
->hidden()
->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()),
Hidden::make('skipValidation')->default(true),
CheckboxList::make('roles')
->disabled(fn (User $user) => $user->id === auth()->user()->id)
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
])->columns(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete'))
->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
$this->getSaveFormAction()->formId('form'),
@ -78,9 +60,4 @@ class EditUser extends EditRecord
{
return [];
}
public function exception($exception, $stopPropagation): void
{
(new FilamentExceptionHandler())->handle($exception, $stopPropagation);
}
}

View File

@ -3,14 +3,22 @@
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Models\Role;
use App\Models\User;
use App\Services\Users\UserCreationService;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Tables;
use Filament\Forms;
class ListUsers extends ListRecords
{
@ -21,101 +29,102 @@ class ListUsers extends ListRecords
return $table
->searchable(false)
->columns([
Tables\Columns\ImageColumn::make('picture')
ImageColumn::make('picture')
->visibleFrom('lg')
->label('')
->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
Tables\Columns\TextColumn::make('external_id')
TextColumn::make('external_id')
->searchable()
->hidden(),
Tables\Columns\TextColumn::make('uuid')
TextColumn::make('uuid')
->label('UUID')
->hidden()
->searchable(),
Tables\Columns\TextColumn::make('username')
TextColumn::make('username')
->searchable(),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->searchable()
->icon('tabler-mail'),
Tables\Columns\IconColumn::make('root_admin')
->visibleFrom('md')
->label('Admin')
->boolean()
->trueIcon('tabler-star-filled')
->falseIcon('tabler-star-off')
->sortable(),
Tables\Columns\IconColumn::make('use_totp')->label('2FA')
IconColumn::make('use_totp')
->label('2FA')
->visibleFrom('lg')
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
->boolean()->sortable(),
Tables\Columns\TextColumn::make('servers_count')
TextColumn::make('roles_count')
->counts('roles')
->icon('tabler-users-group')
->label('Roles')
->formatStateUsing(fn (User $user, $state) => $state . ($user->isRootAdmin() ? ' (Root Admin)' : '')),
TextColumn::make('servers_count')
->counts('servers')
->icon('tabler-server')
->label('Servers'),
Tables\Columns\TextColumn::make('subusers_count')
TextColumn::make('subusers_count')
->visibleFrom('sm')
->label('Subusers')
->counts('subusers')
->icon('tabler-users'),
// ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count))
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count)
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete user')),
]),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make('create')
CreateAction::make('create')
->label('Create User')
->createAnother(false)
->form([
Forms\Components\Grid::make()
Grid::make()
->schema([
Forms\Components\TextInput::make('username')
TextInput::make('username')
->alphaNum()
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
TextInput::make('email')
->email()
->required()
->unique()
->maxLength(255),
Forms\Components\TextInput::make('password')
TextInput::make('password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false),
CheckboxList::make('roles')
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->dehydrated()
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
]),
])
->successRedirectUrl(route('filament.admin.resources.users.index'))
->action(function (array $data) {
resolve(UserCreationService::class)->handle($data);
Notification::make()->title('User Created!')->success()->send();
$roles = $data['roles'];
$roles = collect($roles)->map(fn ($role) => Role::findById($role));
unset($data['roles']);
/** @var UserCreationService $creationService */
$creationService = resolve(UserCreationService::class);
$user = $creationService->handle($data);
$user->syncRoles($roles);
Notification::make()
->title('User Created!')
->success()
->send();
return redirect()->route('filament.admin.resources.users.index');
}),

View File

@ -6,10 +6,10 @@ use App\Enums\ServerState;
use App\Models\Server;
use App\Models\User;
use App\Services\Servers\SuspensionService;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ServersRelationManager extends RelationManager
{
@ -36,7 +36,6 @@ class ServersRelationManager extends RelationManager
resolve(SuspensionService::class)->toggle($server);
}
}),
Actions\Action::make('toggleUnsuspend')
->hidden(fn () => $user->servers()->where('status', ServerState::Suspended)->count() === 0)
->label('Unsuspend All Servers')
@ -48,32 +47,32 @@ class ServersRelationManager extends RelationManager
}),
])
->columns([
Tables\Columns\TextColumn::make('uuid')
TextColumn::make('uuid')
->hidden()
->label('UUID')
->searchable(),
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->icon('tabler-brand-docker')
->label(trans('strings.name'))
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('node.name')
TextColumn::make('node.name')
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->sortable(),
Tables\Columns\TextColumn::make('egg.name')
TextColumn::make('egg.name')
->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->sortable(),
Tables\Columns\TextColumn::make('image')->hidden(),
Tables\Columns\TextColumn::make('databases_count')
TextColumn::make('image')->hidden(),
TextColumn::make('databases_count')
->counts('databases')
->label('Databases')
->icon('tabler-database')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('backups_count')
TextColumn::make('backups_count')
->counts('backups')
->label('Backups')
->icon('tabler-file-download')

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api\Application\Roles;
use App\Exceptions\PanelException;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\Role;
use Spatie\QueryBuilder\QueryBuilder;
use App\Transformers\Api\Application\RoleTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Roles\GetRoleRequest;
use App\Http\Requests\Api\Application\Roles\StoreRoleRequest;
use App\Http\Requests\Api\Application\Roles\DeleteRoleRequest;
use App\Http\Requests\Api\Application\Roles\UpdateRoleRequest;
class RoleController extends ApplicationApiController
{
/**
* Return all the roles currently registered on the Panel.
*/
public function index(GetRoleRequest $request): array
{
$roles = QueryBuilder::for(Role::query())
->allowedFilters(['id', 'name'])
->allowedSorts(['id', 'name'])
->paginate($request->query('per_page') ?? 10);
return $this->fractal->collection($roles)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Return a single role.
*/
public function view(GetRoleRequest $request, Role $role): array
{
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Store a new role on the Panel and return an HTTP/201 response code with the
* new role attached.
*
* @throws \Throwable
*/
public function store(StoreRoleRequest $request): JsonResponse
{
$role = Role::create($request->validated());
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->addMeta([
'resource' => route('api.application.roles.view', [
'role' => $role->id,
]),
])
->respond(201);
}
/**
* Update a role on the Panel and return the updated record to the user.
*
* @throws \Throwable
*/
public function update(UpdateRoleRequest $request, Role $role): array
{
if ($role->isRootAdmin()) {
throw new PanelException('Can\'t update root admin role!');
}
$role->update($request->validated());
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Delete a role from the Panel.
*
* @throws \Exception
*/
public function delete(DeleteRoleRequest $request, Role $role): Response
{
if ($role->isRootAdmin()) {
throw new PanelException('Can\'t delete root admin role!');
}
$role->delete();
return $this->returnNoContent();
}
}

View File

@ -13,6 +13,8 @@ use App\Http\Requests\Api\Application\Users\StoreUserRequest;
use App\Http\Requests\Api\Application\Users\DeleteUserRequest;
use App\Http\Requests\Api\Application\Users\UpdateUserRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Users\AssignUserRolesRequest;
use App\Models\Role;
class UserController extends ApplicationApiController
{
@ -75,6 +77,44 @@ class UserController extends ApplicationApiController
return $response->toArray();
}
/**
* Assign roles to a user.
*/
public function assignRoles(AssignUserRolesRequest $request, User $user): array
{
foreach ($request->input('roles') as $role) {
if ($role === Role::getRootAdmin()->id) {
continue;
}
$user->assignRole($role);
}
$response = $this->fractal->item($user)
->transformWith($this->getTransformer(UserTransformer::class));
return $response->toArray();
}
/**
* Removes roles from a user.
*/
public function removeRoles(AssignUserRolesRequest $request, User $user): array
{
foreach ($request->input('roles') as $role) {
if ($role === Role::getRootAdmin()->id) {
continue;
}
$user->removeRole($role);
}
$response = $this->fractal->item($user)
->transformWith($this->getTransformer(UserTransformer::class));
return $response->toArray();
}
/**
* Store a new user on the system. Returns the created user and an HTTP/201
* header on successful creation.

View File

@ -48,7 +48,7 @@ class ClientController extends ClientApiController
if (in_array($type, ['admin', 'admin-all'])) {
// If they aren't an admin but want all the admin servers don't fail the request, just
// make it a query that will never return any results back.
if (!$user->root_admin) {
if (!$user->isRootAdmin()) {
$builder->whereRaw('1 = 2');
} else {
$builder = $type === 'admin-all'

View File

@ -13,6 +13,7 @@ use Illuminate\Database\Query\JoinClause;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Transformers\Api\Client\ActivityLogTransformer;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Models\Role;
class ActivityLogController extends ClientApiController
{
@ -32,15 +33,16 @@ class ActivityLogController extends ClientApiController
// We could do this with a query and a lot of joins, but that gets pretty
// painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]);
$rootAdmins = Role::getRootAdmin()->users()->pluck('id');
$builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass());
})
->where(function (Builder $builder) use ($subusers) {
->where(function (Builder $builder) use ($subusers, $rootAdmins) {
$builder->whereNull('users.id')
->orWhere('users.root_admin', 0)
->orWhereNotIn('users.id', $rootAdmins)
->orWhereIn('users.id', $subusers);
});
})

View File

@ -2,16 +2,16 @@
namespace App\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Response;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use App\Facades\Activity;
use App\Services\Servers\ReinstallServerService;
use App\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
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;
use App\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
use App\Models\Server;
use App\Services\Servers\ReinstallServerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class SettingsController extends ClientApiController
{
@ -33,7 +33,11 @@ class SettingsController extends ClientApiController
$description = $request->has('description') ? (string) $request->input('description') : $server->description;
$server->name = $name;
$server->description = $description;
if (config('panel.editable_server_descriptions')) {
$server->description = $description;
}
$server->save();
if ($server->name !== $name) {

View File

@ -140,7 +140,7 @@ class SftpAuthenticationController extends Controller
*/
protected function validateSftpAccess(User $user, Server $server): void
{
if (!$user->root_admin && $server->owner_id !== $user->id) {
if (!$user->isRootAdmin() && $server->owner_id !== $user->id) {
$permissions = $this->permissions->handle($server, $user);
if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) {

View File

@ -2,13 +2,13 @@
namespace App\Http\Controllers\Auth;
use App\Filament\Pages\Installer\PanelInstaller;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use App\Facades\Activity;
use Illuminate\View\View;
class LoginController extends AbstractLoginController
{
@ -17,8 +17,12 @@ class LoginController extends AbstractLoginController
* base authentication view component. React will take over at this point and
* turn the login area into an SPA.
*/
public function index(): View
public function index()
{
if (!PanelInstaller::isInstalled()) {
return redirect('/installer');
}
return view('templates/auth.core');
}

View File

@ -14,7 +14,7 @@ class AdminAuthenticate
*/
public function handle(Request $request, \Closure $next): mixed
{
if (!$request->user() || !$request->user()->root_admin) {
if (!$request->user() || !$request->user()->isRootAdmin()) {
throw new AccessDeniedHttpException();
}

View File

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

View File

@ -39,7 +39,7 @@ class AuthenticateServerAccess
// At the very least, ensure that the user trying to make this request is the
// server owner, a subuser, or a root admin. We'll leave it up to the controllers
// to authenticate more detailed permissions if needed.
if ($user->id !== $server->owner_id && !$user->root_admin) {
if ($user->id !== $server->owner_id && !$user->isRootAdmin()) {
// Check for subuser status.
if (!$server->subusers->contains('user_id', $user->id)) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
@ -55,7 +55,7 @@ class AuthenticateServerAccess
if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) {
throw $exception;
}
if (!$user->root_admin || !$request->routeIs($this->except)) {
if (!$user->isRootAdmin() || !$request->routeIs($this->except)) {
throw $exception;
}
}

View File

@ -51,7 +51,7 @@ class RequireTwoFactorAuthentication
// 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) {
return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
} elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) {
return $next($request);
}

View File

@ -21,7 +21,7 @@ abstract class AdminFormRequest extends FormRequest
return false;
}
return (bool) $this->user()->root_admin;
return $this->user()->isRootAdmin();
}
/**

View File

@ -22,7 +22,6 @@ class NewUserFormRequest extends AdminFormRequest
'name_last',
'password',
'language',
'root_admin',
])->toArray();
}
}

View File

@ -22,7 +22,6 @@ class UserFormRequest extends AdminFormRequest
'name_last',
'password',
'language',
'root_admin',
])->toArray();
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::READ;
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
public function rules(array $rules = null): array
{
return [
'name' => 'required|string',
];
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
class UpdateRoleRequest extends StoreRoleRequest
{
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Api\Application\Users;
class AssignUserRolesRequest extends StoreUserRequest
{
/**
* Return the validation rules for this request.
*/
public function rules(array $rules = null): array
{
return [
'roles' => 'array',
'roles.*' => 'int',
];
}
}

View File

@ -26,7 +26,6 @@ class StoreUserRequest extends ApplicationApiRequest
'password',
'language',
'timezone',
'root_admin',
])->toArray();
$response['first_name'] = $rules['name_first'];
@ -56,7 +55,6 @@ class StoreUserRequest extends ApplicationApiRequest
'external_id' => 'Third Party Identifier',
'name_first' => 'First Name',
'name_last' => 'Last Name',
'root_admin' => 'Root Administrator Status',
];
}
}

View File

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

View File

@ -2,8 +2,8 @@
namespace App\Http\ViewComposers;
use Illuminate\View\View;
use App\Services\Helpers\AssetHashService;
use Illuminate\View\View;
class AssetComposer
{
@ -28,6 +28,7 @@ class AssetComposer
'siteKey' => config('recaptcha.website_key') ?? '',
],
'usesSyncDriver' => config('queue.default') === 'sync',
'serverDescriptionsEditable' => config('panel.editable_server_descriptions'),
]);
}
}

View File

@ -5,12 +5,12 @@ namespace App\Models;
use App\Exceptions\Service\HasActiveServersException;
use App\Repositories\Daemon\DaemonConfigurationRepository;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
/**
* @property int $id
@ -75,11 +75,11 @@ class Node extends Model
'disk_overallocate', 'cpu', 'cpu_overallocate',
'upload_size', 'daemon_base',
'daemon_sftp', 'daemon_sftp_alias', 'daemon_listen',
'description', 'maintenance_mode',
'description', 'maintenance_mode', 'tags',
];
public static array $validationRules = [
'name' => 'required|regex:/^([\w .-]{1,100})$/',
'name' => 'required|string|min:1|max:100',
'description' => 'string|nullable',
'public' => 'boolean',
'fqdn' => 'required|string',

48
app/Models/Role.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Spatie\Permission\Models\Role as BaseRole;
/**
* @property int $id
* @property string $name
* @property string $guard_name
* @property \Illuminate\Database\Eloquent\Collection|\Spatie\Permission\Models\Permission[] $permissions
* @property int|null $permissions_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\User[] $users
* @property int|null $users_count
*/
class Role extends BaseRole
{
public const RESOURCE_NAME = 'role';
public const ROOT_ADMIN = 'Root Admin';
public const MODEL_SPECIFIC_PERMISSIONS = [
'egg' => [
'import',
'export',
],
];
public const SPECIAL_PERMISSIONS = [
'settings' => [
'view',
'update',
],
];
public function isRootAdmin(): bool
{
return $this->name === self::ROOT_ADMIN;
}
public static function getRootAdmin(): self
{
/** @var self $role */
$role = self::findOrCreate(self::ROOT_ADMIN);
return $role;
}
}

View File

@ -25,6 +25,9 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model as IlluminateModel;
use Spatie\Permission\Traits\HasRoles;
/**
* App\Models\User.
@ -40,7 +43,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property string|null $remember_token
* @property string $language
* @property string $timezone
* @property bool $root_admin
* @property bool $use_totp
* @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at
@ -77,7 +79,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value)
@ -94,6 +95,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
use AvailableLanguages;
use CanResetPassword;
use HasAccessTokens;
use HasRoles;
use Notifiable;
public const USER_LEVEL_USER = 0;
@ -131,7 +133,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_secret',
'totp_authenticated_at',
'gravatar',
'root_admin',
'oauth',
];
@ -145,7 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
protected $attributes = [
'external_id' => null,
'root_admin' => false,
'language' => 'en',
'timezone' => 'UTC',
'use_totp' => false,
@ -166,7 +166,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'name_first' => 'nullable|string|between:0,255',
'name_last' => 'nullable|string|between:0,255',
'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string',
'timezone' => 'string',
'use_totp' => 'boolean',
@ -177,7 +176,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function casts(): array
{
return [
'root_admin' => 'boolean',
'use_totp' => 'boolean',
'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime',
@ -226,7 +224,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
public function toReactObject(): array
{
return collect($this->toArray())->except(['id', 'external_id'])->toArray();
return array_merge(collect($this->toArray())->except(['id', 'external_id'])->toArray(), [
'root_admin' => $this->isRootAdmin(),
'admin' => $this->canAccessPanel(Filament::getPanel('admin')),
]);
}
/**
@ -315,7 +316,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool
{
if ($this->root_admin || $server->owner_id === $this->id) {
if ($this->isRootAdmin() || $server->owner_id === $this->id) {
return true;
}
@ -351,14 +352,23 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function isLastRootAdmin(): bool
{
$rootAdmins = User::query()->where('root_admin', true)->limit(2)->get();
$rootAdmins = User::all()->filter(fn ($user) => $user->isRootAdmin());
return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this));
}
public function isRootAdmin(): bool
{
return $this->hasRole(Role::ROOT_ADMIN);
}
public function canAccessPanel(Panel $panel): bool
{
return $this->root_admin;
if ($this->isRootAdmin()) {
return true;
}
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1;
}
public function getFilamentName(): string
@ -370,4 +380,13 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email));
}
public function canTarget(IlluminateModel $user): bool
{
if ($this->isRootAdmin()) {
return true;
}
return $user instanceof User && !$user->isRootAdmin();
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class ApiKeyPolicy
{
use DefaultPolicies;
protected string $modelName = 'apikey';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabaseHostPolicy
{
use DefaultPolicies;
protected string $modelName = 'databasehost';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabasePolicy
{
use DefaultPolicies;
protected string $modelName = 'database';
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
trait DefaultPolicies
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->can('viewList ' . $this->modelName);
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Model $model): bool
{
return $user->can('view ' . $this->modelName, $model);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->can('create ' . $this->modelName);
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Model $model): bool
{
return $user->can('update ' . $this->modelName, $model);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Model $model): bool
{
return $user->can('delete ' . $this->modelName, $model);
}
}

View File

@ -2,12 +2,9 @@
namespace App\Policies;
use App\Models\User;
class EggPolicy
{
public function create(User $user): bool
{
return true;
}
use DefaultPolicies;
protected string $modelName = 'egg';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class MountPolicy
{
use DefaultPolicies;
protected string $modelName = 'mount';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class NodePolicy
{
use DefaultPolicies;
protected string $modelName = 'node';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class RolePolicy
{
use DefaultPolicies;
protected string $modelName = 'role';
}

View File

@ -2,34 +2,38 @@
namespace App\Policies;
use App\Models\User;
use App\Models\Server;
use App\Models\User;
class ServerPolicy
{
use DefaultPolicies;
protected string $modelName = 'server';
/**
* Checks if the user has the given permission on/for the server.
* Runs before any of the functions are called. Used to determine if the (sub-)user has permissions.
*/
protected function checkPermission(User $user, Server $server, string $permission): bool
public function before(User $user, string $ability, string|Server $server): ?bool
{
$subuser = $server->subusers->where('user_id', $user->id)->first();
if (!$subuser || empty($permission)) {
return false;
// For "viewAny" the $server param is the class name
if (is_string($server)) {
return null;
}
return in_array($permission, $subuser->permissions);
}
/**
* Runs before any of the functions are called. Used to determine if user is root admin, if so, ignore permissions.
*/
public function before(User $user, string $ability, Server $server): bool
{
if ($user->root_admin || $server->owner_id === $user->id) {
// Owner has full server permissions
if ($server->owner_id === $user->id) {
return true;
}
return $this->checkPermission($user, $server, $ability);
$subuser = $server->subusers->where('user_id', $user->id)->first();
// If the user is a subuser check their permissions
if ($subuser) {
return in_array($ability, $subuser->permissions);
}
// Return null to let default policies take over
return null;
}
/**

View File

@ -0,0 +1,26 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class UserPolicy
{
use DefaultPolicies {
update as defaultUpdate;
delete as defaultDelete;
}
protected string $modelName = 'user';
public function update(User $user, Model $model): bool
{
return $user->canTarget($model) && $this->defaultUpdate($user, $model);
}
public function delete(User $user, Model $model): bool
{
return $user->canTarget($model) && $this->defaultDelete($user, $model);
}
}

View File

@ -7,7 +7,7 @@ use App\Livewire\EndpointSynth;
use App\Models;
use App\Models\ApiKey;
use App\Models\Node;
use App\Services\Helpers\SoftwareVersionService;
use App\Models\User;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
@ -33,9 +33,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
$versionData = app(SoftwareVersionService::class)->versionData();
View::share('appVersion', $versionData['version'] ?? 'undefined');
View::share('appIsGit', $versionData['is_git'] ?? false);
// TODO: remove when old admin area gets yeeted
View::share('appVersion', config('app.version'));
View::share('appIsGit', false);
Paginator::useBootstrap();
@ -94,6 +94,10 @@ class AppServiceProvider extends ServiceProvider
'success' => Color::Green,
'warning' => Color::Amber,
]);
Gate::before(function (User $user, $ability) {
return $user->isRootAdmin() ? true : null;
});
}
/**

View File

@ -24,7 +24,7 @@ class AdminPanelProvider extends PanelProvider
public function boot()
{
FilamentAsset::registerCssVariables([
'sidebar-width' => '14rem !important',
'sidebar-width' => '16rem !important',
]);
}
@ -46,9 +46,7 @@ class AdminPanelProvider extends PanelProvider
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->pages([
// Pages\Dashboard::class,
])
->spa()
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,

View File

@ -32,6 +32,7 @@ class AdminAcl
public const RESOURCE_DATABASE_HOSTS = 'database_hosts';
public const RESOURCE_SERVER_DATABASES = 'server_databases';
public const RESOURCE_MOUNTS = 'mounts';
public const RESOURCE_ROLES = 'roles';
/**
* Determine if an API key has permission to perform a specific read/write operation.

View File

@ -14,10 +14,10 @@ class GetUserPermissionsService
*/
public function handle(Server $server, User $user): array
{
if ($user->root_admin || $user->id === $server->owner_id) {
if ($user->isRootAdmin() || $user->id === $server->owner_id) {
$permissions = ['*'];
if ($user->root_admin) {
if ($user->isRootAdmin()) {
$permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install';
$permissions[] = 'admin.websocket.transfer';

View File

@ -2,6 +2,7 @@
namespace App\Services\Users;
use App\Models\Role;
use Ramsey\Uuid\Uuid;
use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher;
@ -39,10 +40,17 @@ class UserCreationService
$data['password'] = $this->hasher->make(str_random(30));
}
$isRootAdmin = array_key_exists('root_admin', $data) && $data['root_admin'];
unset($data['root_admin']);
$user = User::query()->forceCreate(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(),
]));
if ($isRootAdmin) {
$user->syncRoles(Role::getRootAdmin());
}
if (isset($generateResetToken)) {
$token = $this->passwordBroker->createToken($user);
}

View File

@ -14,7 +14,7 @@ trait EnvironmentWriterTrait
public function escapeEnvironmentValue(string $value): string
{
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
return sprintf('"%s"', addslashes($value));
return sprintf('"%s"', addcslashes($value, '\\"'));
}
return $value;

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