Compare commits

..

47 Commits

Author SHA1 Message Date
github-actions[bot]
94233fcae1 ci(release): bump version 2025-01-05 22:50:32 +00:00
Charles
7cc4358a04 Fix 500 on duplicate ports (#861)
* Fix 500 on duplicate ports

This should also address N+1 issues from the last PR

* Combine into one method

* Pint

* Add missing type

* Add 0.0.0.0

* Add notifications to help the user

* Pint

* Too verbose

* Show notification here

* Simplify code

* Reset the ports if the ip changes

* Don’t limit these anymore

---------

Co-authored-by: Lance Pioch <git@lance.sh>
2025-01-04 22:30:37 -05:00
MartinOscar
168d37b996 Add missing externalId on Server creation (#859)
* Add missing externalId on server creation

* Pint

* Fix mobile layout

* fix layout

---------

Co-authored-by: notCharles <charles@pelican.dev>
2025-01-04 19:58:51 +01:00
MartinOscar
df615f6915 Remove validated override (#860) 2025-01-04 13:36:22 -05:00
Charles
17805f676e Add OAuth Settings to Settings (#839)
* Replace tabler icon package

* Use new filled icons

note: not everything has a filled icon

* Add OAuth Settings to Settings Page

* Fix authentik base url

* replace hard coded oauth
2025-01-04 12:35:07 -05:00
Lance Pioch
23d515c3e5 Convert to bytes beforehand (#857) 2025-01-04 12:34:26 -05:00
Charles
7a5dd87385 Change limits section on front end (#853)
* Edit Front end settings

* Use helpers

---------

Co-authored-by: Lance Pioch <git@lance.sh>
2025-01-04 11:48:26 -05:00
Charles
8f51502c6d Remove First/Last Name for Users (#855)
* Update Tests

* Update Translations

* Add Migration

* Remove First/Last Names
2025-01-03 17:13:44 -05:00
MartinOscar
9d48799c28 Remove required (#852) 2025-01-02 23:36:36 +01:00
Lance Pioch
133c1a511f Replace some guzzle exceptions and fix server creation failures (#848)
* Replace guzzle exceptions

* Pint fixes

* Fix test

* Remove unused imports

* Catch & Notify the user instead of 500

* Update app/Filament/Admin/Resources/ServerResource/Pages/CreateServer.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-01-01 15:20:16 -05:00
Lance Pioch
3a7ddfca5e Scope power buttons to current server (#849)
* Scope setServerState to current server

* Use match statement

* Reset this
2025-01-01 15:20:02 -05:00
MartinOscar
00ae3b8b61 Hide Startup + Show Activity on Server panel when in conflictState (#850)
* Hide startup if isInConflictState

* Show ActivityLog regardless of isInConflictState

* Update app/Filament/Server/Pages/Startup.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2024-12-31 14:19:18 +01:00
MartinOscar
b5733715a6 Remove useless rightJoin (#851) 2024-12-31 14:05:14 +01:00
Lance Pioch
9a859cdec3 Move role resource under the advanced settings (#847) 2024-12-29 18:15:25 -05:00
MartinOscar
1571e3cb24 Rework Schedules (#843) 2024-12-28 16:03:21 -05:00
Charles
a8680c7aed Mobile + Layout Changes (#836)
* Update Server Listing

* Update Edit/Create Server Pages

Re-arrange limits, CPU->Memory->Disk

* Remove auto focus

its cancer on mobile...

* Hide Title, Quick yaml fix

* Hide columns on mobile

* Hide backup locked on mobile

* Fix schedules for mobile

* Hide Notes on mobile

* Consolidate and clean these up

* Simplify

* Remove unused imports

* Replace tabler icon package

* Update app/Filament/Server/Resources/FileResource/Pages/EditFiles.php

Co-authored-by: Lance Pioch <git@lance.sh>

* Allow the unit to be changed

* Use existing method

* Update composer and pint

* Update resources/views/tables/columns/server-entry-column.blade.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

* Simplify html and add small margin

* Unused

* Add enum

---------

Co-authored-by: Lance Pioch <git@lance.sh>
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2024-12-28 16:02:24 -05:00
Scai
66a17879a0 fix: use options instead relationship (#845) 2024-12-27 16:14:05 -05:00
Scai
f684da997c Fix deleting node with database host
* fix: delete database host when node is deleted

* chore: revert change to file
2024-12-27 16:12:49 -05:00
Boy132
00644c2c60 Health page (#469)
* add spatie health

* change slug for health page

* add check for panel version

* only check for debug mode if env isn't local

* add check for node versions

* improve short summary

* fix outdated check

* run pint

* fix health checks during tests

* add count to ok message

* fix typo

* temp fix for phpstan job

* fix pint...

* improve "outdated" count

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>

* run pint

* skip node versions check if no nodes are created

* auto run health checks if they didn't run before

* small refactor

* update navigation

Co-authored-by: Charles <sir3lit@gmail.com>

* fix errors if tests didn't run yet

* fix disk usage check

* remove plugin and use own page

* use health status indicator from spatie

* fix after merge

* update icon

* update color classes

* fix after merge

* add back imports

oops...

* wrong import

oops²...

* update spatie/laravel-health to latest

* move Health page to correct namespace

* update NodeVersionsCheck

* use style instead of tailwind classes

workaround until we have vite

* cleanup custom checks

---------

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: Charles <sir3lit@gmail.com>
2024-12-24 19:09:16 +01:00
Boy132
02a0c5c3eb Fix wrong language formatting in charts (#832) 2024-12-17 13:08:12 +01:00
Boy132
993e2c4244 Add CreateUser page (#825) 2024-12-13 09:21:37 +01:00
pelican-vehikl
7a4c4ce02a Listen to more framework webhook events (#728)
* Add new framework events to listen to

* Add simple test for framework events

* Update app/Models/WebhookConfiguration.php

Co-authored-by: Lance Pioch <git@lance.sh>

* Update app/Models/WebhookConfiguration.php

Co-authored-by: Lance Pioch <git@lance.sh>

* Update app/Models/WebhookConfiguration.php

---------

Co-authored-by: Vehikl <go@vehikl.com>
Co-authored-by: Lance Pioch <git@lance.sh>
2024-12-13 01:03:35 -05:00
Boy132
914f3dcdbd Add own action class for "rotate database password" (#822) 2024-12-12 18:34:52 +01:00
Boy132
d43b99792f (Admin) UI Consistency (#824)
* update phpdocs

* replace deprecated $label and $pluralLabel

* update record title attributes and labels

* update create pages

* run pint
2024-12-12 18:26:37 +01:00
Boy132
771eece01e Properly handle 404 for editing files (#816) 2024-12-12 18:26:01 +01:00
Boy132
026494c353 Catch correct Exceptions when updating/ deleting subusers (#828) 2024-12-12 17:32:39 +01:00
Charles
663b097d22 Add Edit/Delete on Tasks (#826) 2024-12-12 10:31:33 -05:00
Boy132
d09227659e Add database notifications (#817)
* add database notifications to all panels

* add successful param to Installed event

* add listener for Installed event

* create event for subuser creation

* add listener for SubUserAdded event

* always send Installed event

* create event for subuser removal

* add listener for SubUserRemoved event

* add prefix to server name

* remove view action from SubUserRemoved notification
2024-12-12 14:38:45 +01:00
Boy132
eb819032bc Add own action classes for egg actions (+ add empty state) (#823)
* add own action classes for egg actions

* add empty state to ListEggs

* put Import before Create
2024-12-12 14:29:02 +01:00
Boy132
5af507b54b Add own column class for node health (#820) 2024-12-12 14:14:52 +01:00
Boy132
bbee45592f Move custom columns to new namespace (#821) 2024-12-12 14:14:37 +01:00
Boy132
640ff9f5b3 Remove unused DatabaseResource (#819) 2024-12-12 14:03:16 +01:00
Charles
d6f814b7a3 Move schedule buttons (#815)
* Move buttons around

* change to Save
2024-12-10 17:57:06 -05:00
Charles
8a122fa99c Add redirect after save (#813) 2024-12-10 17:43:23 -05:00
Boy132
3ffb54503f Custom error pages (#810)
* add custom error pages

* move icon in front of header text

* show exception message if user is root admin

* add missing page for very important error: 418

* Update resources/views/errors/layout.blade.php

* Update resources/views/errors/layout.blade.php

* add dark mode to error pages

---------

Co-authored-by: Lance Pioch <lancepioch@gmail.com>
2024-12-10 23:42:43 +01:00
Charles
53460b8d1b Update File Manager (#814)
* Make Everything Sortable

* Replace app calls
2024-12-10 17:40:11 -05:00
Boy132
0051d9fefc Allow admins to change server egg (#811)
* add service that handles egg changing

* add "change egg" action to EditServer page

* add toggle for keeping old variables or not
2024-12-10 23:38:40 +01:00
Lance Pioch
ef1ae72d06 Dynamic server status (#803)
* Better readability

* Force refresh the server instance

* Use kebab case for these

* Fix phpstan

* Retry a little longer

* Updates

* Add pint

* Don’t need this

* Pint fix
2024-12-10 17:36:14 -05:00
Boy132
3dfdc70790 Make use of Laravels AboutCommand (#809)
* add pelican info to laravel AboutCommand

* simplify p:info command
2024-12-10 23:07:59 +01:00
Boy132
8460c52534 Add Run now button for schedules & add status field (#806)
* add `Run Now` button to schedules

* add status to schedule view/ edit

* only show status on "view"
2024-12-09 23:31:03 +01:00
Lance Pioch
2bfc788e13 Allow searching for port when associating allocations (#801) 2024-12-08 16:24:00 -05:00
Lance Pioch
839ff96271 Fix power buttons (#799) 2024-12-08 16:19:15 -05:00
Lance Pioch
5d2b892eab Better IP addresses (#800)
* Unique ip addresses

* Only ipv4 addresses for now

* Switch to selects
2024-12-08 16:19:04 -05:00
MartinOscar
c953b97009 Force width (#798) 2024-12-08 20:27:16 +01:00
MartinOscar
9716b1e64d Only allow one * (#797) 2024-12-08 20:23:37 +01:00
Boy132
8358e410dc Move installer to correct namespace (#795) 2024-12-08 19:57:00 +01:00
Boy132
f6c586bf5b Add persistFiltersInSession to server list (#796) 2024-12-08 19:14:56 +01:00
180 changed files with 2925 additions and 1769 deletions

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Checks;
use App\Models\Node;
use App\Services\Helpers\SoftwareVersionService;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use Spatie\Health\Enums\Status;
class NodeVersionsCheck extends Check
{
public function __construct(private SoftwareVersionService $versionService) {}
public function run(): Result
{
$all = Node::query()->count();
if ($all === 0) {
$result = Result::make()->notificationMessage('No Nodes created')->shortSummary('No Nodes');
$result->status = Status::skipped();
return $result;
}
$latestVersion = $this->versionService->latestWingsVersion();
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
->count();
$result = Result::make()
->meta([
'all' => $all,
'outdated' => $outdated,
])
->shortSummary($outdated === 0 ? 'All up-to-date' : "{$outdated}/{$all} outdated");
return $outdated === 0
? $result->ok('All Nodes are up-to-date.')
: $result->failed(':outdated/:all Nodes are outdated.');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Checks;
use App\Services\Helpers\SoftwareVersionService;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
class PanelVersionCheck extends Check
{
public function __construct(private SoftwareVersionService $versionService) {}
public function run(): Result
{
$isLatest = $this->versionService->isLatestPanel();
$currentVersion = $this->versionService->currentPanelVersion();
$latestVersion = $this->versionService->latestPanelVersion();
$result = Result::make()
->meta([
'isLatest' => $isLatest,
'currentVersion' => $currentVersion,
'latestVersion' => $latestVersion,
])
->shortSummary($isLatest ? 'up-to-date' : 'outdated');
return $isLatest
? $result->ok('Panel is up-to-date.')
: $result->failed('Installed version is `:currentVersion` but latest is `:latestVersion`.');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Checks;
use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck as BaseCheck;
class UsedDiskSpaceCheck extends BaseCheck
{
protected function getDiskUsagePercentage(): int
{
$freeSpace = disk_free_space($this->filesystemName ?? '/');
$totalSpace = disk_total_space($this->filesystemName ?? '/');
return 100 - ($freeSpace * 100 / $totalSpace);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\Helpers\SoftwareVersionService;
class InfoCommand extends Command
{
@@ -11,98 +10,8 @@ class InfoCommand extends Command
protected $signature = 'p:info';
/**
* InfoCommand constructor.
*/
public function __construct(private SoftwareVersionService $versionService)
{
parent::__construct();
}
/**
* Handle execution of command.
*/
public function handle(): void
{
$this->output->title('Version Information');
$this->table([], [
['Panel Version', $this->versionService->currentPanelVersion()],
['Latest Version', $this->versionService->latestPanelVersion()],
['Up-to-Date', $this->versionService->isLatestPanel() ? 'Yes' : $this->formatText('No', 'bg=red')],
], 'compact');
$this->output->title('Application Configuration');
$this->table([], [
['Environment', config('app.env') === 'production' ? config('app.env') : $this->formatText(config('app.env'), 'bg=red')],
['Debug Mode', config('app.debug') ? $this->formatText('Yes', 'bg=red') : 'No'],
['Application Name', config('app.name')],
['Application URL', config('app.url')],
['Installation Directory', base_path()],
['Cache Driver', config('cache.default')],
['Queue Driver', config('queue.default') === 'sync' ? $this->formatText(config('queue.default'), 'bg=red') : config('queue.default')],
['Session Driver', config('session.driver')],
['Filesystem Driver', config('filesystems.default')],
], 'compact');
$this->output->title('Database Configuration');
$driver = config('database.default');
if ($driver === 'sqlite') {
$this->table([], [
['Driver', $driver],
['Database', config("database.connections.$driver.database")],
], 'compact');
} else {
$this->table([], [
['Driver', $driver],
['Host', config("database.connections.$driver.host")],
['Port', config("database.connections.$driver.port")],
['Database', config("database.connections.$driver.database")],
['Username', config("database.connections.$driver.username")],
], 'compact');
}
$this->output->title('Email Configuration');
$driver = config('mail.default');
if ($driver === 'smtp') {
$this->table([], [
['Driver', $driver],
['Host', config("mail.mailers.$driver.host")],
['Port', config("mail.mailers.$driver.port")],
['Username', config("mail.mailers.$driver.username")],
['Encryption', config("mail.mailers.$driver.encryption")],
['From Address', config('mail.from.address')],
['From Name', config('mail.from.name')],
], 'compact');
} else {
$this->table([], [
['Driver', $driver],
['From Address', config('mail.from.address')],
['From Name', config('mail.from.name')],
], 'compact');
}
$this->output->title('Backup Configuration');
$driver = config('backups.default');
if ($driver === 's3') {
$this->table([], [
['Driver', $driver],
['Region', config("backups.disks.$driver.region")],
['Bucket', config("backups.disks.$driver.bucket")],
['Endpoint', config("backups.disks.$driver.endpoint")],
['Use path style endpoint', config("backups.disks.$driver.use_path_style_endpoint") ? 'Yes' : 'No'],
], 'compact');
} else {
$this->table([], [
['Driver', $driver],
], 'compact');
}
}
/**
* Format output in a Name: Value manner.
*/
private function formatText(string $value, string $opts = ''): string
{
return sprintf('<%s>%s</>', $opts, $value);
$this->call('about');
}
}

View File

@@ -13,6 +13,8 @@ use App\Models\Webhook;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Database\Console\PruneCommand;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\Commands\ScheduleCheckHeartbeatCommand;
class Kernel extends ConsoleKernel
{
@@ -53,5 +55,8 @@ class Kernel extends ConsoleKernel
if (config('panel.webhook.prune_days')) {
$schedule->command(PruneCommand::class, ['--model' => [Webhook::class]])->daily();
}
$schedule->command(ScheduleCheckHeartbeatCommand::class)->everyMinute();
$schedule->command(RunHealthChecksCommand::class)->everyFiveMinutes();
}
}

View File

@@ -52,4 +52,45 @@ enum ContainerStatus: string
self::Offline => 'gray',
};
}
public function colorHex(): string
{
return match ($this) {
self::Created, self::Restarting => '#2563EB',
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
self::Running => '#22C55E',
self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
};
}
public function isStartingOrStopping(): bool
{
return in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting]);
}
public function isStartable(): bool
{
return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting]);
}
public function isRestartable(): bool
{
if ($this->isStartable()) {
return true;
}
return !in_array($this, [ContainerStatus::Offline]);
}
public function isStoppable(): bool
{
return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline]);
}
public function isKillable(): bool
{
// [ContainerStatus::Restarting, ContainerStatus::Removing, ContainerStatus::Dead, ContainerStatus::Created]
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited]);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ServerResourceType
{
case Unit;
case Percentage;
case Time;
}

View File

@@ -13,5 +13,5 @@ class Installed extends Event
/**
* Create a new event instance.
*/
public function __construct(public Server $server) {}
public function __construct(public Server $server, public bool $successful, public bool $initialInstall) {}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class SubUserAdded extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class SubUserRemoved extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server, public User $user) {}
}

View File

@@ -2,8 +2,8 @@
namespace App\Exceptions\Http\Connection;
use Exception;
use Illuminate\Http\Response;
use GuzzleHttp\Exception\GuzzleException;
use App\Exceptions\DisplayException;
use Illuminate\Support\Facades\Context;
@@ -22,7 +22,7 @@ class DaemonConnectionException extends DisplayException
/**
* Throw a displayable exception caused by a daemon connection error.
*/
public function __construct(GuzzleException $previous, bool $useStatusCode = true)
public function __construct(?Exception $previous, bool $useStatusCode = true)
{
/** @var \GuzzleHttp\Psr7\Response|null $response */
$response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null;

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Filament\Admin\Pages;
use Carbon\Carbon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\ResultStores\ResultStore;
class Health extends Page
{
protected static ?string $navigationIcon = 'tabler-heart';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.health';
// @phpstan-ignore-next-line
protected $listeners = [
'refresh-component' => '$refresh',
];
protected function getActions(): array
{
return [
Action::make('refresh')
->button()
->action('refresh'),
];
}
protected function getViewData(): array
{
// @phpstan-ignore-next-line
$checkResults = app(ResultStore::class)->latestResults();
if ($checkResults === null) {
Artisan::call(RunHealthChecksCommand::class);
$this->dispatch('refresh-component');
}
return [
'lastRanAt' => new Carbon($checkResults?->finishedAt),
'checkResults' => $checkResults,
];
}
public function refresh(): void
{
Artisan::call(RunHealthChecksCommand::class);
$this->dispatch('refresh-component');
Notification::make()
->title('Health check results refreshed')
->success()
->send();
}
public static function getNavigationBadge(): ?string
{
// @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
return null;
}
$results = json_decode($results->toJson(), true);
$failed = array_reduce($results['checkResults'], function ($numFailed, $result) {
return $numFailed + ($result['status'] === 'failed' ? 1 : 0);
}, 0);
return $failed === 0 ? null : (string) $failed;
}
public static function getNavigationBadgeColor(): string
{
return self::getNavigationBadge() > null ? 'danger' : '';
}
public static function getNavigationBadgeTooltip(): ?string
{
// @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
return null;
}
$results = json_decode($results->toJson(), true);
$failedNames = array_reduce($results['checkResults'], function ($carry, $result) {
if ($result['status'] === 'failed') {
$carry[] = $result['name'];
}
return $carry;
}, []);
return 'Failed: ' . implode(', ', $failedNames);
}
public static function getNavigationIcon(): string
{
// @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
return 'tabler-heart-question';
}
return $results->containsFailingCheck() ? 'tabler-heart-exclamation' : 'tabler-heart-check';
}
}

View File

@@ -26,9 +26,9 @@ use Filament\Notifications\Notification;
use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\HtmlString;
@@ -84,6 +84,10 @@ class Settings extends Page implements HasForms
->label('Backup')
->icon('tabler-box')
->schema($this->backupSettings()),
Tab::make('OAuth')
->label('OAuth')
->icon('tabler-brand-oauth')
->schema($this->oauthSettings()),
Tab::make('misc')
->label('Misc')
->icon('tabler-tool')
@@ -164,22 +168,23 @@ class Settings extends Page implements HasForms
->label('Set to Cloudflare IPs')
->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function (Client $client, Set $set) {
->action(function (Factory $client, Set $set) {
$ips = collect();
try {
$response = $client->request(
'GET',
'https://api.cloudflare.com/client/v4/ips',
config('panel.guzzle')
);
$response = $client
->timeout(3)
->connectTimeout(3)
->get('https://api.cloudflare.com/client/v4/ips');
if ($response->getStatusCode() === 200) {
$result = json_decode($response->getBody(), true)['result'];
$result = $response->json('result');
foreach (['ipv4_cidrs', 'ipv6_cidrs'] as $value) {
$ips->push(...data_get($result, $value));
}
$ips->unique();
}
} catch (GuzzleException $e) {
} catch (Exception) {
}
$set('TRUSTED_PROXIES', $ips->values()->all());
@@ -245,12 +250,12 @@ class Settings extends Page implements HasForms
->columnSpanFull()
->inline()
->options([
'log' => 'Print mails to Log',
'log' => '/storage/logs Directory',
'smtp' => 'SMTP Server',
'sendmail' => 'sendmail Binary',
'mailgun' => 'Mailgun',
'mandrill' => 'Mandrill',
'postmark' => 'Postmark',
'sendmail' => 'sendmail (PHP)',
])
->live()
->default(env('MAIL_MAILER', config('mail.default')))
@@ -411,6 +416,74 @@ class Settings extends Page implements HasForms
];
}
private function oauthSettings(): array
{
$oauthProviders = Config::get('auth.oauth');
$formFields = [];
foreach ($oauthProviders as $providerName => $providerConfig) {
$providerEnvPrefix = strtoupper($providerName);
$fields = [
Toggle::make("OAUTH_{$providerEnvPrefix}_ENABLED")
->onColor('success')
->offColor('danger')
->onIcon('tabler-check')
->offIcon('tabler-x')
->live()
->columnSpan(1)
->label('Enabled')
->default(env("OAUTH_{$providerEnvPrefix}_ENABLED", false)),
];
if (array_key_exists('client_id', $providerConfig['service'] ?? [])) {
$fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_CLIENT_ID")
->label('Client ID')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->hidden(fn (Get $get) => !$get("OAUTH_{$providerEnvPrefix}_ENABLED"))
->default(env("OAUTH_{$providerEnvPrefix}_CLIENT_ID", $providerConfig['service']['client_id'] ?? ''))
->placeholder('Client ID');
}
if (array_key_exists('client_secret', $providerConfig['service'] ?? [])) {
$fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_CLIENT_SECRET")
->label('Client Secret')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->hidden(fn (Get $get) => !$get("OAUTH_{$providerEnvPrefix}_ENABLED"))
->default(env("OAUTH_{$providerEnvPrefix}_CLIENT_SECRET", $providerConfig['service']['client_secret'] ?? ''))
->placeholder('Client Secret');
}
if (array_key_exists('base_url', $providerConfig['service'] ?? [])) {
$fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_BASE_URL")
->label('Base URL')
->columnSpanFull()
->autocomplete(false)
->hidden(fn (Get $get) => !$get("OAUTH_{$providerEnvPrefix}_ENABLED"))
->default(env("OAUTH_{$providerEnvPrefix}_BASE_URL", ''))
->placeholder('Base URL');
}
$formFields[] = Section::make(ucfirst($providerName))
->columns(5)
->icon($providerConfig['icon'] ?? 'tabler-brand-oauth')
->collapsed(fn () => !env("OAUTH_{$providerEnvPrefix}_ENABLED", false))
->collapsible()
->schema($fields);
}
return $formFields;
}
private function miscSettings(): array
{
return [

View File

@@ -5,13 +5,16 @@ namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Models\ApiKey;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class ApiKeyResource extends Resource
{
protected static ?string $model = ApiKey::class;
protected static ?string $label = 'API Key';
protected static ?string $modelLabel = 'Application API Key';
protected static ?string $pluralModelLabel = 'Application API Keys';
protected static ?string $navigationLabel = 'API Keys';
protected static ?string $navigationIcon = 'tabler-key';
@@ -22,11 +25,6 @@ class ApiKeyResource extends Resource
return static::getModel()::where('key_type', ApiKey::TYPE_APPLICATION)->count() ?: null;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [

View File

@@ -17,7 +17,19 @@ class CreateApiKey extends CreateRecord
{
protected static string $resource = ApiKeyResource::class;
protected ?string $heading = 'Create Application API Key';
protected static bool $canCreateAnother = false;
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
public function form(Form $form): Form
{

View File

@@ -3,8 +3,8 @@
namespace App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Filament\Admin\Resources\ApiKeyResource;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\ApiKey;
use App\Tables\Columns\DateTimeColumn;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction;

View File

@@ -10,12 +10,12 @@ class DatabaseHostResource extends Resource
{
protected static ?string $model = DatabaseHost::class;
protected static ?string $label = 'Database Host';
protected static ?string $navigationIcon = 'tabler-database';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;

View File

@@ -17,15 +17,11 @@ use PDOException;
class CreateDatabaseHost extends CreateRecord
{
private HostCreationService $service;
protected static string $resource = DatabaseHostResource::class;
protected ?string $heading = 'Database Hosts';
protected static bool $canCreateAnother = false;
protected ?string $subheading = '(database servers that can have individual databases)';
private HostCreationService $service;
public function boot(HostCreationService $service): void
{

View File

@@ -2,14 +2,11 @@
namespace App\Filament\Admin\Resources\DatabaseHostResource\RelationManagers;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\Database;
use App\Services\Databases\DatabasePasswordService;
use App\Tables\Columns\DateTimeColumn;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
@@ -30,25 +27,19 @@ class DatabasesRelationManager extends RelationManager
TextInput::make('password')
->password()
->revealable()
->hintAction(
Action::make('rotate')
->icon('tabler-refresh')
->requiresConfirmation()
->action(fn (DatabasePasswordService $service, Database $database, $set, $get) => $this->rotatePassword($service, $database, $set, $get))
->authorize(fn (Database $database) => auth()->user()->can('update database', $database))
)
->hintAction(RotateDatabasePasswordAction::make())
->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote')
->label('Connections From')
->formatStateUsing(fn ($record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
TextInput::make('max_connections')
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections),
TextInput::make('JDBC')
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections),
TextInput::make('jdbc')
->label('JDBC Connection String')
->columnSpanFull()
->password()
->revealable()
->formatStateUsing(fn (Get $get, Database $database) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($database->password) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database')),
->formatStateUsing(fn (Database $database) => $database->jdbc),
]);
}
@@ -62,7 +53,7 @@ class DatabasesRelationManager extends RelationManager
TextColumn::make('username')
->icon('tabler-user'),
TextColumn::make('remote')
->formatStateUsing(fn ($record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
TextColumn::make('server.name')
->icon('tabler-brand-docker')
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
@@ -78,13 +69,4 @@ class DatabasesRelationManager extends RelationManager
->hidden(fn () => !auth()->user()->can('viewList database')),
]);
}
protected function rotatePassword(DatabasePasswordService $service, Database $database, Set $set, Get $get): void
{
$newPassword = $service->handle($database);
$jdbcString = 'jdbc:mysql://' . $get('username') . ':' . urlencode($newPassword) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database');
$set('password', $newPassword);
$set('JDBC', $jdbcString);
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\DatabaseResource\Pages;
use App\Models\Database;
use Filament\Resources\Resource;
class DatabaseResource extends Resource
{
protected static ?string $model = Database::class;
protected static ?string $navigationIcon = 'tabler-database';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $navigationGroup = 'Advanced';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getPages(): array
{
return [
'index' => Pages\ListDatabases::route('/'),
'create' => Pages\CreateDatabase::route('/create'),
'edit' => Pages\EditDatabase::route('/{record}/edit'),
];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\DatabaseResource\Pages;
use App\Filament\Admin\Resources\DatabaseResource;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\CreateRecord;
class CreateDatabase extends CreateRecord
{
protected static string $resource = DatabaseResource::class;
public function form(Form $form): Form
{
return $form
->schema([
Select::make('server_id')
->relationship('server', 'name')
->searchable()
->preload()
->required(),
Select::make('database_host_id')
->relationship('host', 'name')
->searchable()
->selectablePlaceholder(false)
->preload()
->required(),
TextInput::make('database')
->required()
->maxLength(255),
TextInput::make('remote')
->required()
->maxLength(255)
->default('%'),
TextInput::make('username')
->required()
->maxLength(255),
TextInput::make('password')
->password()
->revealable()
->required(),
TextInput::make('max_connections')
->numeric()
->minValue(0)
->default(0),
]);
}
}

View File

@@ -1,55 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\DatabaseResource\Pages;
use App\Filament\Admin\Resources\DatabaseResource;
use Filament\Actions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
class EditDatabase extends EditRecord
{
protected static string $resource = DatabaseResource::class;
public function form(Form $form): Form
{
return $form
->schema([
Select::make('server_id')
->relationship('server', 'name')
->searchable()
->preload()
->required(),
TextInput::make('database_host_id')
->required()
->numeric(),
TextInput::make('database')
->required()
->maxLength(255),
TextInput::make('remote')
->required()
->maxLength(255)
->default('%'),
TextInput::make('username')
->required()
->maxLength(255),
TextInput::make('password')
->password()
->revealable()
->required(),
TextInput::make('max_connections')
->numeric()
->minValue(0)
->default(0),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\DatabaseResource\Pages;
use App\Filament\Admin\Resources\DatabaseResource;
use App\Tables\Columns\DateTimeColumn;
use Filament\Actions;
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;
class ListDatabases extends ListRecords
{
protected static string $resource = DatabaseResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('server.name')
->numeric()
->sortable(),
TextColumn::make('database_host_id')
->numeric()
->sortable(),
TextColumn::make('database')
->searchable(),
TextColumn::make('username')
->searchable(),
TextColumn::make('remote')
->searchable(),
TextColumn::make('max_connections')
->numeric()
->sortable(),
DateTimeColumn::make('created_at')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
DateTimeColumn::make('updated_at')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->actions([
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete database')),
]),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -14,8 +14,6 @@ class EggResource extends Resource
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $recordRouteKeyName = 'id';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;

View File

@@ -28,6 +28,18 @@ class CreateEgg extends CreateRecord
protected static bool $canCreateAnother = false;
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
public function form(Form $form): Form
{
return $form

View File

@@ -5,17 +5,14 @@ namespace App\Filament\Admin\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Admin\Resources\EggResource;
use App\Filament\Admin\Resources\EggResource\RelationManagers\ServersRelationManager;
use App\Filament\Components\Actions\ExportEggAction;
use App\Filament\Components\Actions\ImportEggAction;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs;
@@ -26,7 +23,6 @@ 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;
class EditEgg extends EditRecord
@@ -242,83 +238,11 @@ class EditEgg extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make('deleteEgg')
DeleteAction::make()
->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'))
->authorize(fn () => auth()->user()->can('export egg')),
Actions\Action::make('importEgg')
->label('Import')
->form([
Placeholder::make('warning')
->label('This will overwrite the current egg to the one you upload.'),
Tabs::make('Tabs')
->tabs([
Tab::make('From File')
->icon('tabler-file-upload')
->schema([
FileUpload::make('egg')
->label('Egg')
->hint('eg. minecraft.json')
->acceptedFileTypes(['application/json'])
->storeFiles(false),
]),
Tab::make('From URL')
->icon('tabler-world-upload')
->schema([
TextInput::make('url')
->label('URL')
->default(fn (Egg $egg): ?string => $egg->update_url)
->hint('Link to the egg file (eg. minecraft.json)')
->url(),
]),
])
->contained(false),
])
->action(function (array $data, Egg $egg, EggImporterService $eggImportService): void {
if (!empty($data['egg'])) {
try {
$eggImportService->fromFile($data['egg'], $egg);
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger() // Will Robinson
->send();
report($exception);
return;
}
} elseif (!empty($data['url'])) {
try {
$eggImportService->fromUrl($data['url'], $egg);
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
}
$this->refreshForm();
Notification::make()
->title('Import Success')
->success()
->send();
})
->authorize(fn () => auth()->user()->can('import egg')),
ExportEggAction::make(),
ImportEggAction::make(),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@@ -3,24 +3,19 @@
namespace App\Filament\Admin\Resources\EggResource\Pages;
use App\Filament\Admin\Resources\EggResource;
use App\Filament\Components\Actions\ImportEggAction as ImportEggHeaderAction;
use App\Filament\Components\Tables\Actions\ExportEggAction;
use App\Filament\Components\Tables\Actions\ImportEggAction;
use App\Filament\Components\Tables\Actions\UpdateEggAction;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Actions\CreateAction as CreateHeaderAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
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;
use Filament\Tables\Table;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ListEggs extends ListRecords
{
@@ -49,132 +44,31 @@ class ListEggs extends ListRecords
])
->actions([
EditAction::make(),
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'))
->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, EggImporterService $eggImporterService) {
try {
$eggImporterService->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)),
ExportEggAction::make(),
UpdateEggAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete egg')),
]),
])
->emptyStateIcon('tabler-eggs')
->emptyStateDescription('')
->emptyStateHeading('No Eggs')
->emptyStateActions([
CreateAction::make()
->label('Create Egg'),
ImportEggAction::make(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make('create')->label('Create Egg'),
Actions\Action::make('import')
->label('Import')
->form([
Tabs::make('Tabs')
->tabs([
Tab::make('From File')
->icon('tabler-file-upload')
->schema([
FileUpload::make('egg')
->label('Egg')
->hint('This should be the json file ( egg-minecraft.json )')
->acceptedFileTypes(['application/json'])
->storeFiles(false)
->multiple(),
]),
Tab::make('From URL')
->icon('tabler-world-upload')
->schema([
TextInput::make('url')
->label('URL')
->hint('This URL should point to a single json file')
->url(),
]),
])
->contained(false),
])
->action(function (array $data, EggImporterService $eggImportService): void {
if (!empty($data['egg'])) {
/** @var TemporaryUploadedFile[] $eggFile */
$eggFile = $data['egg'];
foreach ($eggFile as $file) {
try {
$eggImportService->fromFile($file);
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
}
}
if (!empty($data['url'])) {
try {
$eggImportService->fromUrl($data['url']);
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
}
Notification::make()
->title('Import Success')
->success()
->send();
})
->authorize(fn () => auth()->user()->can('import egg')),
ImportEggHeaderAction::make(),
CreateHeaderAction::make()
->label('Create Egg'),
];
}
}

View File

@@ -14,6 +14,8 @@ class MountResource extends Resource
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;

View File

@@ -21,6 +21,18 @@ class CreateMount extends CreateRecord
protected static bool $canCreateAnother = false;
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
public function form(Form $form): Form
{
return $form

View File

@@ -23,8 +23,6 @@ class CreateNode extends CreateRecord
protected static bool $canCreateAnother = false;
protected ?string $subheading = 'which is a machine that runs your Servers';
public function form(Forms\Form $form): Forms\Form
{
return $form

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource;
use App\Filament\Components\Tables\Columns\NodeHealthColumn;
use App\Models\Node;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@@ -27,10 +28,7 @@ class ListNodes extends ListRecords
->label('UUID')
->searchable()
->hidden(),
IconColumn::make('health')
->alignCenter()
->state(fn (Node $node) => $node)
->view('livewire.columns.version-column'),
NodeHealthColumn::make('health'),
TextColumn::make('name')
->icon('tabler-server-2')
->sortable()

View File

@@ -2,12 +2,15 @@
namespace App\Filament\Admin\Resources\NodeResource\RelationManagers;
use App\Filament\Admin\Resources\ServerResource\Pages\CreateServer;
use App\Models\Allocation;
use App\Models\Node;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
@@ -73,12 +76,14 @@ class AllocationsRelationManager extends RelationManager
->headerActions([
Tables\Actions\Action::make('create new allocation')->label('Create Allocations')
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->ipAddresses())
Select::make('allocation_ip')
->options(collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->live()
->required(),
TextInput::make('allocation_alias')
->label('Alias')
@@ -96,54 +101,10 @@ class AllocationsRelationManager extends RelationManager
->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());
}
})
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts($this->getOwnerRecord(), $state, $get('allocation_ip')))
)
->splitKeys(['Tab', ' ', ','])
->required(),
])

View File

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

View File

@@ -24,8 +24,8 @@ class NodeMemoryChart extends ChartWidget
$memUsed = collect(cache()->get("nodes.$node->id.memory_used"))->slice(-10)
->map(fn ($value, $key) => [
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();
@@ -41,6 +41,7 @@ class NodeMemoryChart extends ChartWidget
],
],
'labels' => array_column($memUsed, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
];
}

View File

@@ -24,6 +24,8 @@ class RoleResource extends Resource
protected static ?string $navigationIcon = 'tabler-users-group';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string

View File

@@ -14,11 +14,23 @@ use Spatie\Permission\Models\Permission;
*/
class CreateRole extends CreateRecord
{
public Collection $permissions;
protected static string $resource = RoleResource::class;
protected static bool $canCreateAnother = false;
public Collection $permissions;
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
protected function mutateFormDataBeforeCreate(array $data): array
{

View File

@@ -34,7 +34,9 @@ use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Blade;
@@ -68,9 +70,8 @@ class CreateServer extends CreateRecord
->completedIcon('tabler-check')
->columns([
'default' => 1,
'sm' => 1,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->schema([
TextInput::make('name')
@@ -87,24 +88,51 @@ class CreateServer extends CreateRecord
$set('name', $prefix . $word);
}))
->columnSpan([
'default' => 2,
'sm' => 3,
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 3,
])
->required()
->maxLength(255),
TextInput::make('external_id')
->label('External ID')
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->unique()
->maxLength(255),
Select::make('node_id')
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->live()
->relationship('node', 'name')
->searchable()
->preload()
->afterStateUpdated(function (Set $set, $state) {
$set('allocation_id', null);
$this->node = Node::find($state);
})
->required(),
Select::make('owner_id')
->preload()
->prefixIcon('tabler-user')
->default(auth()->user()->id)
->label('Owner')
->columnSpan([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 3,
'default' => 1,
'sm' => 2,
'md' => 2,
])
->relationship('user', 'username')
->searchable(['username', 'email'])
@@ -134,36 +162,15 @@ class CreateServer extends CreateRecord
})
->required(),
Select::make('node_id')
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->columnSpan([
'default' => 2,
'sm' => 3,
'md' => 6,
'lg' => 6,
])
->live()
->relationship('node', 'name')
->searchable()
->preload()
->afterStateUpdated(function (Set $set, $state) {
$set('allocation_id', null);
$this->node = Node::find($state);
})
->required(),
Select::make('allocation_id')
->preload()
->live()
->prefixIcon('tabler-network')
->label('Primary Allocation')
->columnSpan([
'default' => 2,
'sm' => 3,
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 3,
])
->disabled(fn (Get $get) => $get('node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
@@ -191,87 +198,47 @@ class CreateServer extends CreateRecord
->where('node_id', $get('node_id'))
->whereNull('server_id'),
)
->createOptionForm(fn (Get $get) => [
TextInput::make('allocation_ip')
->datalist(Node::find($get('node_id'))?->ipAddresses() ?? [])
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
// ->selectablePlaceholder(false)
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->datalist([
$get('name'),
Egg::find($get('egg_id'))?->name,
])
->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);
->createOptionForm(function (Get $get) {
$getPage = $get;
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);
$range = $start <= $end ? range($start, $end) : range($end, $start);
foreach ($range as $i) {
if ($i > 1024 && $i <= 65535) {
$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;
}
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
return [
Select::make('allocation_ip')
->options(collect(Node::find($get('node_id'))?->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label('IP Address')
->helperText("Usually your machine's public IP unless you are port forwarding.")
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->inlineLabel()
->ipv4()
->live()
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->datalist([
$get('name'),
Egg::find($get('egg_id'))?->name,
])
->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()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts(Node::find($getPage('node_id')), $state, $get('allocation_ip')))
)
->splitKeys(['Tab', ' ', ','])
->required(),
];
})
->createOptionUsing(function (array $data, Get $get, AssignmentService $assignmentService): int {
return collect(
$assignmentService->handle(Node::find($get('node_id')), $data)
@@ -282,10 +249,9 @@ class CreateServer extends CreateRecord
Repeater::make('allocation_additional')
->label('Additional Allocations')
->columnSpan([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 3,
'default' => 1,
'sm' => 2,
'md' => 2,
])
->addActionLabel('Add Allocation')
->disabled(fn (Get $get) => $get('allocation_id') === null)
@@ -321,10 +287,9 @@ class CreateServer extends CreateRecord
->placeholder('Description')
->rows(3)
->columnSpan([
'default' => 2,
'sm' => 6,
'md' => 6,
'lg' => 6,
'default' => 1,
'sm' => 4,
'md' => 4,
])
->label('Description'),
]),
@@ -535,6 +500,37 @@ class CreateServer extends CreateRecord
'lg' => 3,
])
->schema([
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->live()
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->suffix('%')
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0)
->helperText('100% equals one CPU core.'),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -565,7 +561,6 @@ class CreateServer extends CreateRecord
->numeric()
->minValue(0),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -597,37 +592,6 @@ class CreateServer extends CreateRecord
->minValue(0),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->live()
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->suffix('%')
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0)
->helperText('100% equals one CPU core.'),
]),
]),
Fieldset::make('Advanced Limits')
@@ -639,6 +603,40 @@ class CreateServer extends CreateRecord
'lg' => 3,
])
->schema([
Hidden::make('io')
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion')
->default(500),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -687,41 +685,6 @@ class CreateServer extends CreateRecord
->integer(),
]),
Hidden::make('io')
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion')
->default(500),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -876,7 +839,18 @@ class CreateServer extends CreateRecord
{
$data['allocation_additional'] = collect($data['allocation_additional'])->filter()->all();
return $this->serverCreationService->handle($data);
try {
return $this->serverCreationService->handle($data);
} catch (Exception $exception) {
Notification::make()
->title('Could not create server')
->body($exception->getMessage())
->color('danger')
->danger()
->send();
throw new Halt();
}
}
private function shouldHideComponent(Get $get, Component $component): bool
@@ -909,4 +883,88 @@ class CreateServer extends CreateRecord
->mapWithKeys(fn ($value) => [$value => $value])
->all();
}
public static function retrieveValidPorts(Node $node, array $portEntries, string $ip): array
{
$portRangeLimit = AssignmentService::PORT_RANGE_LIMIT;
$portFloor = AssignmentService::PORT_FLOOR;
$portCeil = AssignmentService::PORT_CEIL;
$ports = collect();
$existingPorts = $node
->allocations()
->where('ip', $ip)
->pluck('port')
->all();
foreach ($portEntries as $portEntry) {
$start = $end = $portEntry;
if (str_contains($portEntry, '-')) {
[$start, $end] = explode('-', $portEntry);
}
if (!is_numeric($start) || !is_numeric($end)) {
Notification::make()
->title('Invalid Port Range')
->danger()
->body("Your port range are not valid integers: $portEntry")
->send();
continue;
}
$start = (int) $start;
$end = (int) $end;
$range = $start <= $end ? range($start, $end) : range($end, $start);
if (count($range) > $portRangeLimit) {
Notification::make()
->title('Too many ports at one time!')
->danger()
->body("The current limit is $portRangeLimit number of ports at one time.")
->send();
continue;
}
foreach ($range as $i) {
// Invalid port number
if ($i <= $portFloor || $i > $portCeil) {
Notification::make()
->title('Port not in valid range')
->danger()
->body("$i is not in the valid port range between $portFloor-$portCeil")
->send();
continue;
}
// Already exists
if (in_array($i, $existingPorts)) {
Notification::make()
->title('Port already in use')
->danger()
->body("$i is already with an allocation")
->send();
continue;
}
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$ports = $sortedPorts;
}
return $ports->all();
}
}

View File

@@ -6,6 +6,7 @@ use App\Enums\ContainerStatus;
use App\Enums\ServerState;
use App\Filament\Admin\Resources\ServerResource;
use App\Filament\Admin\Resources\ServerResource\RelationManagers\AllocationsRelationManager;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Server\Pages\Console;
use App\Models\Database;
use App\Models\DatabaseHost;
@@ -14,7 +15,7 @@ use App\Models\Mount;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Services\Databases\DatabaseManagementService;
use App\Services\Databases\DatabasePasswordService;
use App\Services\Eggs\EggChangerService;
use App\Services\Servers\RandomWordService;
use App\Services\Servers\ReinstallServerService;
use App\Services\Servers\ServerDeletionService;
@@ -38,6 +39,7 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Forms\Get;
@@ -159,6 +161,7 @@ class EditServer extends EditRecord
'md' => 2,
'lg' => 3,
])
->unique()
->maxLength(255),
Select::make('node_id')
->label('Node')
@@ -182,6 +185,35 @@ class EditServer extends EditRecord
'lg' => 3,
])
->schema([
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->suffix('%')
->required()
->columnSpan(2)
->numeric()
->minValue(0),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -241,36 +273,6 @@ class EditServer extends EditRecord
->numeric()
->minValue(0),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->suffix('%')
->required()
->columnSpan(2)
->numeric()
->minValue(0),
]),
]),
Fieldset::make('Advanced Limits')
@@ -285,6 +287,36 @@ class EditServer extends EditRecord
->columns(4)
->columnSpanFull()
->schema([
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->formatStateUsing(fn (Get $get) => !empty($get('threads')))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
]),
ToggleButtons::make('swap_support')
->live()
->label('Swap Memory')->inlineLabel()->inline()
@@ -336,37 +368,6 @@ class EditServer extends EditRecord
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion'),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->formatStateUsing(fn (Get $get) => !empty($get('threads')))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -398,16 +399,19 @@ class EditServer extends EditRecord
])
->schema([
TextInput::make('allocation_limit')
->label('Allocations')
->suffixIcon('tabler-network')
->required()
->minValue(0)
->numeric(),
TextInput::make('database_limit')
->label('Databases')
->suffixIcon('tabler-database')
->required()
->minValue(0)
->numeric(),
TextInput::make('backup_limit')
->label('Backups')
->suffixIcon('tabler-copy-check')
->required()
->minValue(0)
@@ -418,7 +422,7 @@ class EditServer extends EditRecord
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
'lg' => 4,
])
->schema([
Select::make('select_image')
@@ -439,7 +443,12 @@ class EditServer extends EditRecord
return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image'];
})
->selectablePlaceholder(false)
->columnSpan(1),
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 2,
]),
TextInput::make('image')
->label('Image')
@@ -455,7 +464,12 @@ class EditServer extends EditRecord
}
})
->placeholder('Enter a custom Image')
->columnSpan(2),
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 2,
]),
KeyValue::make('docker_labels')
->label('Container Labels')
@@ -474,7 +488,7 @@ class EditServer extends EditRecord
])
->schema([
Select::make('egg_id')
->disabledOn('edit')
->disabled()
->prefixIcon('tabler-egg')
->columnSpan([
'default' => 6,
@@ -485,7 +499,28 @@ class EditServer extends EditRecord
->relationship('egg', 'name')
->searchable()
->preload()
->required(),
->required()
->hintAction(
Action::make('change_egg')
->action(function (array $data, Server $server, EggChangerService $service) {
$service->handle($server, $data['egg_id'], $data['keepOldVariables']);
// Use redirect instead of fillForm to prevent server variables from duplicating
$this->redirect($this->getUrl(['record' => $server, 'tab' => '-egg-tab']), true);
})
->form(fn (Server $server) => [
Select::make('egg_id')
->label('New Egg')
->prefixIcon('tabler-egg')
->options(fn () => Egg::all()->filter(fn (Egg $egg) => $egg->id !== $server->egg->id)->mapWithKeys(fn (Egg $egg) => [$egg->id => $egg->name]))
->searchable()
->preload()
->required(),
Toggle::make('keepOldVariables')
->label('Keep old variables if possible?')
->default(true),
])
),
ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')->inline()
@@ -644,31 +679,24 @@ class EditServer extends EditRecord
->password()
->revealable()
->columnSpan(1)
->hintAction(
Action::make('rotate')
->authorize(fn (Database $database) => auth()->user()->can('update database', $database))
->icon('tabler-refresh')
->modalHeading('Change Database Password?')
->action(fn (DatabasePasswordService $service, $record, $set, $get) => $this->rotatePassword($service, $record, $set, $get))
->requiresConfirmation()
)
->hintAction(RotateDatabasePasswordAction::make())
->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote')
->disabled()
->formatStateUsing(fn ($record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote)
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote)
->columnSpan(1)
->label('Connections From'),
TextInput::make('max_connections')
->disabled()
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections)
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections)
->columnSpan(1),
TextInput::make('JDBC')
TextInput::make('jdbc')
->disabled()
->password()
->revealable()
->label('JDBC Connection String')
->columnSpan(2)
->formatStateUsing(fn (Get $get, $record) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($record->password) . '@' . $record->host->host . ':' . $record->host->port . '/' . $get('database')),
->formatStateUsing(fn (Database $record) => $record->jdbc),
])
->relationship('databases')
->deletable(false)
@@ -707,8 +735,10 @@ class EditServer extends EditRecord
->label('Database Host')
->required()
->placeholder('Select Database Host')
->relationship('node.databaseHosts', 'name',
fn (Builder $query, Server $server) => $query->whereRelation('nodes', 'nodes.id', $server->node_id))
->options(fn (Server $server) => DatabaseHost::query()
->whereHas('nodes', fn ($query) => $query->where('nodes.id', $server->node_id))
->pluck('name', 'id')
)
->default(fn () => (DatabaseHost::query()->first())?->id)
->selectablePlaceholder(false),
TextInput::make('database')
@@ -927,13 +957,4 @@ class EditServer extends EditRecord
->mapWithKeys(fn ($value) => [$value => $value])
->all();
}
protected function rotatePassword(DatabasePasswordService $service, Database $record, Set $set, Get $get): void
{
$newPassword = $service->handle($record);
$jdbcString = 'jdbc:mysql://' . $get('username') . ':' . urlencode($newPassword) . '@' . $record->host->host . ':' . $record->host->port . '/' . $get('database');
$set('password', $newPassword);
$set('JDBC', $jdbcString);
}
}

View File

@@ -2,12 +2,15 @@
namespace App\Filament\Admin\Resources\ServerResource\RelationManagers;
use App\Filament\Admin\Resources\ServerResource\Pages\CreateServer;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
@@ -71,12 +74,13 @@ class AllocationsRelationManager extends RelationManager
CreateAction::make()->label('Create Allocation')
->createAnother(false)
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->node->ipAddresses())
Select::make('allocation_ip')
->options(collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')
->label('Alias')
@@ -94,54 +98,9 @@ class AllocationsRelationManager extends RelationManager
->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());
}
})
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip')))
)
->splitKeys(['Tab', ' ', ','])
->required(),
])
@@ -151,6 +110,7 @@ class AllocationsRelationManager extends RelationManager
->associateAnother(false)
->preloadRecordSelect()
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)->whereNull('server_id'))
->recordSelectSearchColumns(['ip', 'port'])
->label('Add Allocation'),
])
->bulkActions([

View File

@@ -31,6 +31,7 @@ class UserResource extends Resource
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Filament\Admin\Resources\UserResource\Pages;
use App\Filament\Admin\Resources\UserResource;
use App\Models\Role;
use App\Services\Users\UserCreationService;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected static bool $canCreateAnother = false;
private UserCreationService $service;
public function boot(UserCreationService $service): void
{
$this->service = $service;
}
public function form(Form $form): Form
{
return $form
->columns(['default' => 1, 'lg' => 3])
->schema([
TextInput::make('username')
->alphaNum()
->required()
->unique()
->minLength(3)
->maxLength(255),
TextInput::make('email')
->email()
->required()
->unique()
->maxLength(255),
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(),
CheckboxList::make('roles')
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->dehydrated()
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
]);
}
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
protected function handleRecordCreation(array $data): Model
{
$data['root_admin'] = false;
$roles = $data['roles'];
$roles = collect($roles)->map(fn ($role) => Role::findById($role));
unset($data['roles']);
$user = $this->service->handle($data);
$user->syncRoles($roles);
return $user;
}
}

View File

@@ -24,19 +24,25 @@ class EditUser extends EditRecord
return $form
->schema([
Section::make()->schema([
TextInput::make('username')->required()->minLength(3)->maxLength(255),
TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('username')
->required()
->minLength(3)
->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(),
Select::make('language')
->required()
->hidden()
->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()),
Hidden::make('skipValidation')->default(true),
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)
@@ -44,7 +50,8 @@ class EditUser extends EditRecord
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
])->columns(),
])
->columns(['default' => 1, 'lg' => 3]),
]);
}

View File

@@ -3,14 +3,8 @@
namespace App\Filament\Admin\Resources\UserResource\Pages;
use App\Filament\Admin\Resources\UserResource;
use App\Models\Role;
use App\Models\User;
use App\Services\Users\UserCreationService;
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;
@@ -82,53 +76,8 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make('create')
->label('Create User')
->createAnother(false)
->form([
Grid::make()
->schema([
TextInput::make('username')
->alphaNum()
->required()
->unique()
->minLength(3)
->maxLength(255),
TextInput::make('email')
->email()
->required()
->unique()
->maxLength(255),
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(),
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, UserCreationService $creationService) {
$roles = $data['roles'];
$roles = collect($roles)->map(fn ($role) => Role::findById($role));
unset($data['roles']);
$user = $creationService->handle($data);
$user->syncRoles($roles);
Notification::make()
->title('User Created!')
->success()
->send();
return redirect()->route('filament.admin.resources.users.index');
}),
CreateAction::make()
->label('Create User'),
];
}
}

View File

@@ -10,11 +10,15 @@ class WebhookResource extends Resource
{
protected static ?string $model = WebhookConfiguration::class;
protected static ?string $modelLabel = 'Webhook';
protected static ?string $pluralModelLabel = 'Webhooks';
protected static ?string $navigationIcon = 'tabler-webhook';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $label = 'Webhooks';
protected static ?string $recordTitleAttribute = 'description';
public static function getNavigationBadge(): ?string
{

View File

@@ -13,6 +13,20 @@ class CreateWebhookConfiguration extends CreateRecord
{
protected static string $resource = WebhookResource::class;
protected static bool $canCreateAnother = false;
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
public function form(Form $form): Form
{
return $form

View File

@@ -3,18 +3,15 @@
namespace App\Filament\App\Resources\ServerResource\Pages;
use App\Filament\App\Resources\ServerResource;
use App\Filament\Components\Tables\Columns\ServerEntryColumn;
use App\Filament\Server\Pages\Console;
use App\Models\Server;
use App\Tables\Columns\ServerEntryColumn;
use Carbon\CarbonInterface;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Number;
class ListServers extends ListRecords
{
@@ -36,12 +33,13 @@ class ListServers extends ListRecords
])
->contentGrid([
'default' => 1,
'xl' => 2,
'md' => 2,
])
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->emptyStateIcon('tabler-brand-docker')
->emptyStateDescription('')
->emptyStateHeading('You don\'t have access to any servers!')
->persistFiltersInSession()
->filters([
TernaryFilter::make('only_my_servers')
->label('Owned by')
@@ -60,64 +58,4 @@ class ListServers extends ListRecords
->preload(),
]);
}
// @phpstan-ignore-next-line
private function uptime(Server $server): string
{
$uptime = Arr::get($server->resources(), 'uptime', 0);
if ($uptime === 0) {
return 'Offline';
}
return now()->subMillis($uptime)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 2);
}
// @phpstan-ignore-next-line
private function cpu(Server $server): string
{
$cpu = Number::format(Arr::get($server->resources(), 'cpu_absolute', 0), maxPrecision: 2, locale: auth()->user()->language) . '%';
$max = Number::format($server->cpu, locale: auth()->user()->language) . '%';
return $cpu . ($server->cpu > 0 ? ' Of ' . $max : '');
}
// @phpstan-ignore-next-line
private function memory(Server $server): string
{
$latestMemoryUsed = Arr::get($server->resources(), 'memory_bytes', 0);
$totalMemory = Arr::get($server->resources(), 'memory_limit_bytes', 0);
$used = config('panel.use_binary_prefix')
? 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';
if ($totalMemory === 0) {
$total = config('panel.use_binary_prefix')
? Number::format($server->memory / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($server->memory / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
} else {
$total = config('panel.use_binary_prefix')
? 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 $used . ($server->memory > 0 ? ' Of ' . $total : '');
}
// @phpstan-ignore-next-line
private function disk(Server $server): string
{
$usedDisk = Arr::get($server->resources(), 'disk_bytes', 0);
$used = config('panel.use_binary_prefix')
? Number::format($usedDisk / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($usedDisk / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
$total = config('panel.use_binary_prefix')
? Number::format($server->disk / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($server->disk / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
return $used . ($server->disk > 0 ? ' Of ' . $total : '');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Components\Actions;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Actions\Action;
class ExportEggAction extends Action
{
public static function getDefaultName(): ?string
{
return 'export';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Export');
$this->authorize(fn () => auth()->user()->can('export egg'));
$this->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json'));
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Components\Actions;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportEggAction extends Action
{
public static function getDefaultName(): ?string
{
return 'import';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Import');
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->form([
Tabs::make('Tabs')
->contained(false)
->tabs([
Tab::make('From File')
->icon('tabler-file-upload')
->schema([
FileUpload::make('egg')
->label('Egg')
->hint('This should be the json file ( egg-minecraft.json )')
->acceptedFileTypes(['application/json'])
->storeFiles(false)
->multiple(),
]),
Tab::make('From URL')
->icon('tabler-world-upload')
->schema([
TextInput::make('url')
->label('URL')
->hint('This URL should point to a single json file')
->url(),
]),
]),
]);
$this->action(function (array $data, EggImporterService $eggImportService): void {
try {
if (!empty($data['egg'])) {
/** @var TemporaryUploadedFile[] $eggFile */
$eggFile = $data['egg'];
foreach ($eggFile as $file) {
$eggImportService->fromFile($file);
}
}
if (!empty($data['url'])) {
$eggImportService->fromUrl($data['url']);
}
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
Notification::make()
->title('Import Success')
->success()
->send();
});
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Components\Forms\Actions;
use App\Models\Database;
use App\Services\Databases\DatabasePasswordService;
use Exception;
use Filament\Actions\StaticAction;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
class RotateDatabasePasswordAction extends Action
{
public static function getDefaultName(): ?string
{
return 'rotate';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Rotate');
$this->icon('tabler-refresh');
$this->authorize(fn (Database $database) => auth()->user()->can('update database', $database));
$this->modalHeading('Rotate Password');
$this->modalIconColor('warning');
$this->modalSubmitAction(fn (StaticAction $action) => $action->color('warning'));
$this->requiresConfirmation();
$this->action(function (DatabasePasswordService $service, Database $database, Set $set) {
try {
$service->handle($database);
$database->refresh();
$set('password', $database->password);
$set('jdbc', $database->jdbc);
Notification::make()
->title('Password rotated')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Password rotation failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
}
});
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filament\Components\Tables\Actions;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Tables\Actions\Action;
class ExportEggAction extends Action
{
public static function getDefaultName(): ?string
{
return 'export';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Export');
$this->icon('tabler-download');
$this->authorize(fn () => auth()->user()->can('export egg'));
$this->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json'));
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Components\Tables\Actions;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportEggAction extends Action
{
public static function getDefaultName(): ?string
{
return 'import';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Import');
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->form([
Tabs::make('Tabs')
->contained(false)
->tabs([
Tab::make('From File')
->icon('tabler-file-upload')
->schema([
FileUpload::make('egg')
->label('Egg')
->hint('This should be the json file ( egg-minecraft.json )')
->acceptedFileTypes(['application/json'])
->storeFiles(false)
->multiple(),
]),
Tab::make('From URL')
->icon('tabler-world-upload')
->schema([
TextInput::make('url')
->label('URL')
->hint('This URL should point to a single json file')
->url(),
]),
]),
]);
$this->action(function (array $data, EggImporterService $eggImportService): void {
try {
if (!empty($data['egg'])) {
/** @var TemporaryUploadedFile[] $eggFile */
$eggFile = $data['egg'];
foreach ($eggFile as $file) {
$eggImportService->fromFile($file);
}
}
if (!empty($data['url'])) {
$eggImportService->fromUrl($data['url']);
}
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
Notification::make()
->title('Import Success')
->success()
->send();
});
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Components\Tables\Actions;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions\StaticAction;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
class UpdateEggAction extends Action
{
public static function getDefaultName(): ?string
{
return 'update';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Update');
$this->icon('tabler-cloud-download');
$this->color('success');
$this->requiresConfirmation();
$this->modalHeading('Are you sure you want to update this egg?');
$this->modalDescription('If you made any changes to the egg they will be overwritten!');
$this->modalIconColor('danger');
$this->modalSubmitAction(fn (StaticAction $action) => $action->color('danger'));
$this->action(function (Egg $egg, EggImporterService $eggImporterService) {
try {
$eggImporterService->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')
->body($egg->name)
->success()
->send();
});
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->visible(fn (Egg $egg) => cache()->get("eggs.$egg->uuid.update", false));
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Tables\Columns;
namespace App\Filament\Components\Tables\Columns;
use Filament\Tables\Columns\TextColumn;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Tables\Columns;
namespace App\Filament\Components\Tables\Columns;
use Filament\Tables\Columns\TextColumn;

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Components\Tables\Columns;
use Filament\Tables\Columns\IconColumn;
class NodeHealthColumn extends IconColumn
{
protected string $view = 'livewire.columns.version-column';
protected function setUp(): void
{
parent::setUp();
$this->alignCenter();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Tables\Columns;
namespace App\Filament\Components\Tables\Columns;
use Filament\Tables\Columns\Column;

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Pages;
use App\Enums\ContainerStatus;
use App\Filament\Server\Widgets\ServerConsole;
use App\Filament\Server\Widgets\ServerCpuChart;
use App\Filament\Server\Widgets\ServerMemoryChart;
@@ -12,6 +13,7 @@ use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Support\Enums\ActionSize;
use Livewire\Attributes\On;
class Console extends Page
{
@@ -21,6 +23,8 @@ class Console extends Page
protected static string $view = 'filament.server.pages.console';
public ContainerStatus $status = ContainerStatus::Offline;
public function getWidgetData(): array
{
return [
@@ -50,6 +54,18 @@ class Console extends Page
return 3;
}
#[On('console-status')]
public function receivedConsoleUpdate(?string $state = null): void
{
if ($state) {
$this->status = ContainerStatus::from($state);
}
$this->cachedHeaderActions = [];
$this->cacheHeaderActions();
}
protected function getHeaderActions(): array
{
/** @var Server $server */
@@ -59,18 +75,19 @@ class Console extends Page
Action::make('start')
->color('primary')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'start'))
->disabled(fn () => $server->isInConflictState()),
->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid))
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()),
Action::make('restart')
->color('gray')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'restart'))
->disabled(fn () => $server->isInConflictState() || $server->retrieveStatus() == 'offline'),
->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid))
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()),
Action::make('stop')
->color('danger')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'stop'))
->disabled(fn () => $server->isInConflictState() || $server->retrieveStatus() == 'offline'),
->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid))
->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable())
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()),
Action::make('kill')
->color('danger')
->requiresConfirmation()
@@ -78,8 +95,8 @@ class Console extends Page
->modalDescription('This can result in data corruption and/or data loss!')
->modalSubmitActionLabel('Kill Server')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'kill'))
->disabled(fn () => $server->isInConflictState() || $server->retrieveStatus() == 'offline'),
->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid))
->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable()),
];
}
}

View File

@@ -20,6 +20,7 @@ use Filament\Notifications\Notification;
use Filament\Support\Enums\Alignment;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Number;
class Settings extends ServerFormPage
{
@@ -99,21 +100,48 @@ class Settings extends ServerFormPage
'lg' => 3,
])
->schema([
TextInput::make('cpu')
->label('')
->prefix('CPU')
->prefixIcon('tabler-cpu')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'Unlimited' : Number::format($server->cpu, locale: auth()->user()->language) . '%'),
TextInput::make('memory')
->label('')
->prefix('Memory')
->prefixIcon('tabler-device-desktop-analytics')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'Unlimited' : convert_bytes_to_readable($server->memory * 2 ** 20)),
TextInput::make('disk')
->label('')
->prefix('Disk Space')
->prefixIcon('tabler-device-sd-card')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'Unlimited' : convert_bytes_to_readable($server->disk * 2 ** 20)),
TextInput::make('backup_limit')
->label('Backup Limit')
->label('')
->prefix('Backups')
->prefixIcon('tabler-file-zip')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Backups can be created' : $server->backups->count() . ' of ' . $state),
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Backups' : $server->backups->count() . ' of ' . $state),
TextInput::make('database_limit')
->label('Database Limit')
->label('')
->prefix('Databases')
->prefixIcon('tabler-database')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Databases can be created' : $server->databases->count() . ' of ' . $state),
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Databases' : $server->databases->count() . ' of ' . $state),
TextInput::make('allocation_limit')
->label('Allocation Limit')
->label('')
->prefix('Allocations')
->prefixIcon('tabler-network')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No additional Allocations can be created' : $server->allocations->count() . ' of ' . $state),
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Additional Allocations' : $server->allocations->count() . ' of ' . $state),
]),
]),
Section::make('Node Information')

View File

@@ -153,7 +153,7 @@ class Startup extends ServerFormPage
public static function canAccess(): bool
{
return auth()->user()->can(Permission::ACTION_STARTUP_READ, Filament::getTenant());
return parent::canAccess() && auth()->user()->can(Permission::ACTION_STARTUP_READ, Filament::getTenant());
}
private function shouldHideComponent(ServerVariable $serverVariable, Component $component): bool

View File

@@ -17,9 +17,9 @@ class ActivityResource extends Resource
{
protected static ?string $model = ActivityLog::class;
protected static ?string $label = 'Activity';
protected static ?string $modelLabel = 'Activity';
protected static ?string $pluralLabel = 'Activity';
protected static ?string $pluralModelLabel = 'Activity';
protected static ?int $navigationSort = 8;
@@ -52,19 +52,6 @@ class ActivityResource extends Resource
});
}
// TODO: find better way handle server conflict state
public static function canAccess(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
if ($server->isInConflictState()) {
return false;
}
return parent::canAccess();
}
public static function canViewAny(): bool
{
return auth()->user()->can(Permission::ACTION_ACTIVITY_READ, Filament::getTenant());

View File

@@ -5,7 +5,7 @@ namespace App\Filament\Server\Resources\ActivityResource\Pages;
use App\Filament\Server\Resources\ActivityResource;
use App\Models\ActivityLog;
use App\Models\User;
use App\Tables\Columns\DateTimeColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

View File

@@ -14,9 +14,9 @@ class AllocationResource extends Resource
{
protected static ?string $model = Allocation::class;
protected static ?string $label = 'Network';
protected static ?string $modelLabel = 'Network';
protected static ?string $pluralLabel = 'Network';
protected static ?string $pluralModelLabel = 'Network';
protected static ?int $navigationSort = 7;

View File

@@ -35,6 +35,7 @@ class ListAllocations extends ListRecords
->hidden(),
TextColumn::make('port'),
TextInputColumn::make('notes')
->visibleFrom('sm')
->disabled(fn () => !auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->label('Notes')
->placeholder('No Notes'),

View File

@@ -12,8 +12,8 @@ use App\Models\Server;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DownloadLinkService;
use App\Services\Backups\InitiateBackupService;
use App\Tables\Columns\BytesColumn;
use App\Tables\Columns\DateTimeColumn;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
@@ -44,8 +44,7 @@ class ListBackups extends ListRecords
->schema([
TextInput::make('name')
->label('Name')
->columnSpanFull()
->required(),
->columnSpanFull(),
TextArea::make('ignored')
->columnSpanFull()
->label('Ignored Files & Directories'),
@@ -74,6 +73,7 @@ class ListBackups extends ListRecords
->label('Successful')
->boolean(),
IconColumn::make('is_locked')
->visibleFrom('md')
->label('Lock Status')
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock-open' : 'tabler-lock'),
])

View File

@@ -2,22 +2,20 @@
namespace App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\DatabaseResource;
use App\Models\Database;
use App\Models\DatabaseHost;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Databases\DatabaseManagementService;
use App\Services\Databases\DatabasePasswordService;
use App\Tables\Columns\DateTimeColumn;
use Filament\Actions\CreateAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
@@ -45,16 +43,8 @@ class ListDatabases extends ListRecords
->password()->revealable()
->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hintAction(
Action::make('rotate')
RotateDatabasePasswordAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_DATABASE_UPDATE, $server))
->icon('tabler-refresh')
->requiresConfirmation()
->action(function (DatabasePasswordService $service, Database $database, $set, $get) {
$newPassword = $service->handle($database);
$set('password', $newPassword);
$set('JDBC', 'jdbc:mysql://' . $get('username') . ':' . urlencode($newPassword) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database'));
})
)
->suffixAction(CopyAction::make())
->formatStateUsing(fn (Database $database) => $database->password),
@@ -62,13 +52,13 @@ class ListDatabases extends ListRecords
->label('Connections From'),
TextInput::make('max_connections')
->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'),
TextInput::make('JDBC')
TextInput::make('jdbc')
->label('JDBC Connection String')
->password()->revealable()
->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->suffixAction(CopyAction::make())
->columnSpanFull()
->formatStateUsing(fn (Get $get, Database $database) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($database->password) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database')),
->formatStateUsing(fn (Database $database) => $database->jdbc),
]);
}

View File

@@ -24,6 +24,7 @@ use Filament\Panel;
use Filament\Resources\Pages\Page;
use Filament\Resources\Pages\PageRegistration;
use Filament\Support\Enums\Alignment;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
@@ -41,6 +42,8 @@ class EditFiles extends Page
protected static string $view = 'filament.server.pages.edit-file';
protected static ?string $title = '';
#[Locked]
public string $path;
@@ -62,22 +65,25 @@ class EditFiles extends Page
->options(EditorLanguages::class)
->hidden() //TODO Fix Dis
->default(function () {
$split = explode('.', $this->path);
$ext = pathinfo($this->path, PATHINFO_EXTENSION);
return end($split);
if ($ext === 'yml') {
return 'yaml';
}
return $ext;
}),
Section::make('Editing: ' . $this->path)
->footerActions([
Action::make('save')
->label('Save Changes')
->label('Save')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+s')
->action(function () use ($server) {
->action(function (DaemonFileRepository $fileRepository) use ($server) {
$data = $this->form->getState();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->putContent($this->path, $data['editor'] ?? '');
@@ -91,6 +97,8 @@ class EditFiles extends Page
->title('Saved File')
->body(fn () => $this->path)
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
}),
Action::make('cancel')
->label('Cancel')
@@ -103,11 +111,14 @@ class EditFiles extends Page
MonacoEditor::make('editor')
->label('')
->placeholderText('')
->formatStateUsing(function () use ($server) {
// @phpstan-ignore-next-line
return app(DaemonFileRepository::class)
->setServer($server)
->getContent($this->path, config('panel.files.max_edit_size'));
->formatStateUsing(function (DaemonFileRepository $fileRepository) use ($server) {
try {
return $fileRepository
->setServer($server)
->getContent($this->path, config('panel.files.max_edit_size'));
} catch (FileNotFoundException) {
abort(404, $this->path . ' not found.');
}
})
->language(fn (Get $get) => $get('lang') ?? 'plaintext')
->view('filament.plugins.monaco-editor'),

View File

@@ -11,8 +11,8 @@ use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Services\Nodes\NodeJWTService;
use App\Tables\Columns\BytesColumn;
use App\Tables\Columns\DateTimeColumn;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Carbon\CarbonImmutable;
use Filament\Actions\Action as HeaderAction;
use Filament\Facades\Filament;
@@ -80,13 +80,18 @@ class ListFiles extends ListRecords
return $table
->paginated([15, 25, 50, 100])
->defaultPaginationPageOption(15)
->query(fn () => File::get($server, $this->path)->orderByDesc('is_directory')->orderBy('name'))
->query(fn () => File::get($server, $this->path)->orderByDesc('is_directory'))
->defaultSort('name')
->columns([
TextColumn::make('name')
->searchable()
->sortable()
->icon(fn (File $file) => $file->getIcon()),
BytesColumn::make('size'),
BytesColumn::make('size')
->visibleFrom('md')
->sortable(),
DateTimeColumn::make('modified_at')
->visibleFrom('md')
->since()
->sortable(),
])
@@ -126,9 +131,8 @@ class ListFiles extends ListRecords
->default(fn (File $file) => $file->name)
->required(),
])
->action(function ($data, File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function ($data, File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->renameFiles($this->path, [['to' => $data['name'], 'from' => $file->name]]);
@@ -148,9 +152,8 @@ class ListFiles extends ListRecords
->label('Copy')
->icon('tabler-copy')
->visible(fn (File $file) => $file->is_file)
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->copyFile(join_paths($this->path, $file->name));
@@ -170,9 +173,8 @@ class ListFiles extends ListRecords
->label('Download')
->icon('tabler-download')
->visible(fn (File $file) => $file->is_file)
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
$token = app(NodeJWTService::class)
->action(function (File $file, NodeJWTService $service) use ($server) {
$token = $service
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser(auth()->user())
->setClaims([
@@ -201,11 +203,10 @@ class ListFiles extends ListRecords
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location')))),
])
->action(function ($data, File $file) use ($server) {
->action(function ($data, File $file, DaemonFileRepository $fileRepository) use ($server) {
$location = resolve_path(join_paths($this->path, $data['location']));
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->renameFiles($this->path, [['to' => $location, 'from' => $file->name]]);
@@ -261,15 +262,14 @@ class ListFiles extends ListRecords
return $this->getPermissionsFromModeBit($mode);
}),
])
->action(function ($data, File $file) use ($server) {
->action(function ($data, File $file, DaemonFileRepository $fileRepository) use ($server) {
$owner = (in_array('read', $data['owner']) ? 4 : 0) | (in_array('write', $data['owner']) ? 2 : 0) | (in_array('execute', $data['owner']) ? 1 : 0);
$group = (in_array('read', $data['group']) ? 4 : 0) | (in_array('write', $data['group']) ? 2 : 0) | (in_array('execute', $data['group']) ? 1 : 0);
$public = (in_array('read', $data['public']) ? 4 : 0) | (in_array('write', $data['public']) ? 2 : 0) | (in_array('execute', $data['public']) ? 1 : 0);
$mode = $owner . $group . $public;
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->chmodFiles($this->path, [['file' => $file->name, 'mode' => $mode]]);
@@ -282,9 +282,8 @@ class ListFiles extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label('Archive')
->icon('tabler-archive')
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->compressFiles($this->path, [$file->name]);
@@ -305,9 +304,8 @@ class ListFiles extends ListRecords
->label('Unarchive')
->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive())
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->decompressFile($this->path, $file->name);
@@ -331,9 +329,8 @@ class ListFiles extends ListRecords
->requiresConfirmation()
->modalDescription(fn (File $file) => $file->name)
->modalHeading('Delete file?')
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->deleteFiles($this->path, [$file->name]);
@@ -357,14 +354,12 @@ class ListFiles extends ListRecords
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data) use ($server) {
->action(function (Collection $files, $data, DaemonFileRepository $fileRepository) use ($server) {
$location = resolve_path(join_paths($this->path, $data['location']));
// @phpstan-ignore-next-line
$files = $files->map(fn ($file) => ['to' => $location, 'from' => $file->name])->toArray();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->renameFiles($this->path, $files);
@@ -380,12 +375,11 @@ class ListFiles extends ListRecords
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->action(function (Collection $files) use ($server) {
->action(function (Collection $files, DaemonFileRepository $fileRepository) use ($server) {
// @phpstan-ignore-next-line
$files = $files->map(fn ($file) => $file->name)->toArray();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->compressFiles($this->path, $files);
@@ -403,12 +397,10 @@ class ListFiles extends ListRecords
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files) use ($server) {
->action(function (Collection $files, DaemonFileRepository $fileRepository) use ($server) {
// @phpstan-ignore-next-line
$files = $files->map(fn ($file) => $file->name)->toArray();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->deleteFiles($this->path, $files);
@@ -438,9 +430,8 @@ class ListFiles extends ListRecords
->color('gray')
->keyBindings('')
->modalSubmitActionLabel('Create')
->action(function ($data) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function ($data, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->putContent(join_paths($this->path, $data['name']), $data['editor'] ?? '');
@@ -467,9 +458,8 @@ class ListFiles extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('New Folder')
->color('gray')
->action(function ($data) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->action(function ($data, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->createDirectory($data['name'], $this->path);
@@ -485,12 +475,11 @@ class ListFiles extends ListRecords
HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('Upload')
->action(function ($data) use ($server) {
->action(function ($data, DaemonFileRepository $fileRepository) use ($server) {
if (count($data['files']) > 0 && !isset($data['url'])) {
/** @var UploadedFile $file */
foreach ($data['files'] as $file) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->putContent(join_paths($this->path, $file->getClientOriginalName()), $file->getContent());
@@ -500,8 +489,7 @@ class ListFiles extends ListRecords
->log();
}
} elseif ($data['url'] !== null) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
$fileRepository
->setServer($server)
->pull($data['url'], $this->path);
@@ -545,6 +533,7 @@ class ListFiles extends ListRecords
->form([
TextInput::make('searchTerm')
->placeholder('Enter a search term, e.g. *.txt')
->regex('/^[^*]*\*?[^*]*$/')
->minLength(3),
])
->action(fn ($data) => redirect(SearchFiles::getUrl([

View File

@@ -5,8 +5,8 @@ namespace App\Filament\Server\Resources\FileResource\Pages;
use App\Filament\Server\Resources\FileResource;
use App\Models\File;
use App\Models\Server;
use App\Tables\Columns\BytesColumn;
use App\Tables\Columns\DateTimeColumn;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;

View File

@@ -14,6 +14,7 @@ use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Resource;
@@ -63,55 +64,100 @@ class ScheduleResource extends Resource
public static function form(Form $form): Form
{
return $form
->columns(10)
->columns([
'default' => 4,
'lg' => 5,
])
->schema([
TextInput::make('name')
->columnSpan(10)
->columnSpan([
'default' => 4,
'md' => 3,
'lg' => 4,
])
->label('Schedule Name')
->placeholder('A human readable identifier for this schedule.')
->autocomplete(false)
->required(),
ToggleButtons::make('Status')
->formatStateUsing(fn (Schedule $schedule) => !$schedule->is_active ? 'inactive' : ($schedule->is_processing ? 'processing' : 'active'))
->options(fn (Schedule $schedule) => !$schedule->is_active ? ['inactive' => 'Inactive'] : ($schedule->is_processing ? ['processing' => 'Processing'] : ['active' => 'Active']))
->colors([
'inactive' => 'danger',
'processing' => 'warning',
'active' => 'success',
])
->visibleOn('view')
->columnSpan([
'default' => 4,
'md' => 1,
'lg' => 1,
]),
Toggle::make('only_when_online')
->label('Only when Server is Online?')
->hintIconTooltip('Only execute this schedule when the server is in a running state.')
->hintIcon('tabler-question-mark')
->columnSpan(5)
->inline(false)
->columnSpan([
'default' => 2,
'lg' => 3,
])
->required()
->default(1),
Toggle::make('is_active')
->label('Enable Schedule?')
->hintIconTooltip('This schedule will be executed automatically if enabled.')
->hintIcon('tabler-question-mark')
->columnSpan(5)
->inline(false)
->columnSpan([
'default' => 2,
'lg' => 2,
])
->required()
->default(1),
TextInput::make('cron_minute')
->columnSpan(2)
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Minute')
->default('*/5')
->required(),
TextInput::make('cron_hour')
->columnSpan(2)
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Hour')
->default('*')
->required(),
TextInput::make('cron_day_of_month')
->columnSpan(2)
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Day of Month')
->default('*')
->required(),
TextInput::make('cron_month')
->columnSpan(2)
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Month')
->default('*')
->required(),
TextInput::make('cron_day_of_week')
->columnSpan(2)
->columnSpan([
'default' => 2,
'lg' => 1,
])
->label('Day of Week')
->default('*')
->required(),
Section::make('Presets')
->hiddenOn('view')
->columns(1)
->schema([
Actions::make([
Action::make('hourly')

View File

@@ -13,8 +13,9 @@ class EditSchedule extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form')->label('Save'),
$this->getCancelFormAction()->formId('form'),
];
}
@@ -22,4 +23,9 @@ class EditSchedule extends EditRecord
{
return [];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource;
use App\Models\Schedule;
use App\Tables\Columns\DateTimeColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DeleteAction;

View File

@@ -2,8 +2,13 @@
namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Facades\Activity;
use App\Filament\Server\Resources\ScheduleResource;
use App\Models\Permission;
use App\Models\Schedule;
use App\Services\Schedules\ProcessScheduleService;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ViewRecord;
class ViewSchedule extends ViewRecord
@@ -13,6 +18,21 @@ class ViewSchedule extends ViewRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('runNow')
->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant()))
->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? 'No tasks' : ($schedule->is_processing ? 'Processing' : 'Run now'))
->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing ? 'warning' : 'primary')
->disabled(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->is_processing)
->action(function (ProcessScheduleService $service, Schedule $schedule) {
$service->handle($schedule, true);
Activity::event('server:schedule.execute')
->subject($schedule)
->property('name', $schedule->name)
->log();
$this->fillForm();
}),
Actions\EditAction::make(),
];
}

View File

@@ -5,11 +5,13 @@ namespace App\Filament\Server\Resources\ScheduleResource\RelationManagers;
use App\Facades\Activity;
use App\Models\Schedule;
use App\Models\Task;
use Filament\Tables\Actions\DeleteAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Get;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Table;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\CreateAction;
@@ -20,6 +22,50 @@ class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
private function getActionOptions(bool $full = true): array
{
return [
Task::ACTION_POWER => $full ? 'Send power action' : 'Power action',
Task::ACTION_COMMAND => $full ? 'Send command' : 'Command',
Task::ACTION_BACKUP => $full ? 'Create backup' : 'Files to ignore',
Task::ACTION_DELETE_FILES => $full ? 'Delete files' : 'Files to delete',
];
}
private function getTaskForm(Schedule $schedule): array
{
return [
Select::make('action')
->required()
->live()
->disableOptionWhen(fn (string $value): bool => $value === Task::ACTION_BACKUP && $schedule->server->backup_limit === 0)
->options($this->getActionOptions())
->selectablePlaceholder(false),
Textarea::make('payload')
->hidden(fn (Get $get) => $get('action') === Task::ACTION_POWER)
->label(fn (Get $get) => $this->getActionOptions(false)[$get('action')] ?? 'Payload'),
Select::make('payload')
->visible(fn (Get $get) => $get('action') === Task::ACTION_POWER)
->label('Power Action')
->required()
->options([
'start' => 'Start',
'restart' => 'Restart',
'stop' => 'Stop',
'kill' => 'Kill',
])
->selectablePlaceholder(false),
TextInput::make('time_offset')
->hidden(fn (Get $get) => config('queue.default') === 'sync' || $get('sequence_id') === 1)
->default(0)
->numeric()
->minValue(0)
->maxValue(900)
->suffix('Seconds'),
Toggle::make('continue_on_failure'),
];
}
public function table(Table $table): Table
{
/** @var Schedule $schedule */
@@ -29,63 +75,34 @@ class TasksRelationManager extends RelationManager
->reorderable('sequence_id', true)
->columns([
TextColumn::make('action')
->state(fn (Task $task) => match ($task->action) {
Task::ACTION_POWER => 'Send power action',
Task::ACTION_COMMAND => 'Send command',
Task::ACTION_BACKUP => 'Create backup',
Task::ACTION_DELETE_FILES => 'Delete files',
default => $task->action
}),
->state(fn (Task $task) => $this->getActionOptions()[$task->action] ?? $task->action),
TextColumn::make('payload')
->state(function (Task $task) {
$payload = match ($task->payload) {
'start', 'restart', 'stop', 'kill' => mb_ucfirst($task->payload),
default => $task->payload
};
return explode(PHP_EOL, $payload);
})
->badge(),
TextColumn::make('time_offset')
->hidden(fn () => config('queue.default') === 'sync')
->suffix(' Seconds'),
IconColumn::make('continue_on_failure')
->boolean(),
])
->actions([
EditAction::make()
->form($this->getTaskForm($schedule)),
DeleteAction::make(),
])
->headerActions([
CreateAction::make()
->createAnother(false)
->label(fn () => $schedule->tasks()->count() >= config('panel.client_features.schedules.per_schedule_task_limit', 10) ? 'Task Limit Reached' : 'Create Task')
->disabled(fn () => $schedule->tasks()->count() >= config('panel.client_features.schedules.per_schedule_task_limit', 10))
->form([
Select::make('action')
->required()
->live()
->disableOptionWhen(fn (string $value): bool => $value === Task::ACTION_BACKUP && $schedule->server->backup_limit === 0)
->options([
Task::ACTION_POWER => 'Send power action',
Task::ACTION_COMMAND => 'Send command',
Task::ACTION_BACKUP => 'Create backup',
Task::ACTION_DELETE_FILES => 'Delete files',
]),
Textarea::make('payload')
->hidden(fn (Get $get) => $get('action') === Task::ACTION_POWER)
->label(fn (Get $get) => match ($get('action')) {
Task::ACTION_POWER => 'Power action',
Task::ACTION_COMMAND => 'Command',
Task::ACTION_BACKUP => 'Files to ignore',
Task::ACTION_DELETE_FILES => 'Files to delete',
default => 'Payload'
}),
Select::make('payload')
->visible(fn (Get $get) => $get('action') === Task::ACTION_POWER)
->label('Power Action')
->required()
->options([
'start' => 'Start',
'restart' => 'Restart',
'stop' => 'Stop',
'Kill' => 'Kill',
]),
TextInput::make('time_offset')
->hidden(fn (Get $get) => config('queue.default') === 'sync' || $get('sequence_id') === 1)
->default(0)
->numeric()
->minValue(0)
->maxValue(900)
->suffix('Seconds'),
Toggle::make('continue_on_failure'),
])
->form($this->getTaskForm($schedule))
->action(function ($data) use ($schedule) {
$sequenceId = ($schedule->tasks()->orderByDesc('sequence_id')->first()->sequence_id ?? 0) + 1;

View File

@@ -88,7 +88,7 @@ class ServerConsole extends Widget
}
}
#[On('storeStats')]
#[On('store-stats')]
public function storeStats(string $data): void
{
$data = json_decode($data);

View File

@@ -21,8 +21,8 @@ class ServerCpuChart extends ChartWidget
$cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))
->slice(-10)
->map(fn ($value, $key) => [
'cpu' => Number::format($value, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
'cpu' => Number::format($value, maxPrecision: 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();
@@ -38,6 +38,7 @@ class ServerCpuChart extends ChartWidget
],
],
'labels' => array_column($cpu, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
];
}

View File

@@ -20,8 +20,8 @@ class ServerMemoryChart extends ChartWidget
{
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))->slice(-10)
->map(fn ($value, $key) => [
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();
@@ -37,6 +37,7 @@ class ServerMemoryChart extends ChartWidget
],
],
'labels' => array_column($memUsed, 'timestamp'),
'locale' => auth()->user()->language ?? 'en',
];
}

View File

@@ -35,13 +35,11 @@ class ServerInstallController extends Controller
{
$status = null;
// Make sure the type of failure is accurate
if (!$request->boolean('successful')) {
$status = ServerState::InstallFailed;
$successful = $request->boolean('successful');
if ($request->boolean('reinstall')) {
$status = ServerState::ReinstallFailed;
}
// Make sure the type of failure is accurate
if (!$successful) {
$status = $request->boolean('reinstall') ? ServerState::ReinstallFailed : ServerState::InstallFailed;
}
// Keep the server suspended if it's already suspended
@@ -55,16 +53,8 @@ class ServerInstallController extends Controller
$server->installed_at = now();
$server->save();
// If the server successfully installed, fire installed event.
// This logic allows individually disabling install and reinstall notifications separately.
$isInitialInstall = is_null($previouslyInstalledAt);
if ($isInitialInstall && config()->get('panel.email.send_install_notification', true)) {
event(new ServerInstalled($server));
}
if (!$isInitialInstall && config()->get('panel.email.send_reinstall_notification', true)) {
event(new ServerInstalled($server));
}
event(new ServerInstalled($server, $successful, $isInitialInstall));
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Auth;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Livewire\Installer\PanelInstaller;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;

View File

@@ -19,7 +19,7 @@ class StoreUserRequest extends ApplicationApiRequest
{
$rules = $rules ?? User::getRules();
$response = collect($rules)->only([
return collect($rules)->only([
'external_id',
'email',
'username',
@@ -27,23 +27,6 @@ class StoreUserRequest extends ApplicationApiRequest
'language',
'timezone',
])->toArray();
$response['first_name'] = $rules['name_first'];
$response['last_name'] = $rules['name_last'];
return $response;
}
public function validated($key = null, $default = null): array
{
$data = parent::validated();
$data['name_first'] = $data['first_name'];
$data['name_last'] = $data['last_name'];
unset($data['first_name'], $data['last_name']);
return $data;
}
/**
@@ -53,8 +36,6 @@ class StoreUserRequest extends ApplicationApiRequest
{
return [
'external_id' => 'Third Party Identifier',
'name_first' => 'First Name',
'name_last' => 'Last Name',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Listeners\Server;
use App\Events\Server\Installed;
use App\Filament\Server\Pages\Console;
use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
class ServerInstalledListener
{
public function handle(Installed $event): void
{
$event->server->loadMissing('user');
Notification::make()
->status($event->successful ? 'success' : 'danger')
->title('Server ' . ($event->initialInstall ? 'Installation' : 'Reinstallation') . ' ' . ($event->successful ? 'completed' : 'failed'))
->body('Server Name: ' . $event->server->name)
->actions([
Action::make('view')
->button()
->label('Open Server')
->markAsRead()
->url(fn () => Console::getUrl(panel: 'server', tenant: $event->server)),
])
->sendToDatabase($event->server->user);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Listeners\Server;
use App\Events\Server\SubUserAdded;
use App\Filament\Server\Pages\Console;
use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
class SubUserAddedListener
{
public function handle(SubUserAdded $event): void
{
$event->subuser->loadMissing('server');
$event->subuser->loadMissing('user');
Notification::make()
->title('Added to Server')
->body('You have been added as a subuser to ' . $event->subuser->server->name . '.')
->actions([
Action::make('view')
->button()
->label('Open Server')
->markAsRead()
->url(fn () => Console::getUrl(panel: 'server', tenant: $event->subuser->server)),
])
->sendToDatabase($event->subuser->user);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Listeners\Server;
use App\Events\Server\SubUserRemoved;
use Filament\Notifications\Notification;
class SubUserRemovedListener
{
public function handle(SubUserRemoved $event): void
{
Notification::make()
->title('Removed from Server')
->body('You have been removed as a subuser from ' . $event->server->name . '.')
->sendToDatabase($event->user);
}
}

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Filament\Pages\Installer;
namespace App\Livewire\Installer;
use App\Filament\Admin\Pages\Dashboard;
use App\Filament\Pages\Installer\Steps\CacheStep;
use App\Filament\Pages\Installer\Steps\DatabaseStep;
use App\Filament\Pages\Installer\Steps\EnvironmentStep;
use App\Filament\Pages\Installer\Steps\QueueStep;
use App\Filament\Pages\Installer\Steps\RequirementsStep;
use App\Filament\Pages\Installer\Steps\SessionStep;
use App\Livewire\Installer\Steps\CacheStep;
use App\Livewire\Installer\Steps\DatabaseStep;
use App\Livewire\Installer\Steps\EnvironmentStep;
use App\Livewire\Installer\Steps\QueueStep;
use App\Livewire\Installer\Steps\RequirementsStep;
use App\Livewire\Installer\Steps\SessionStep;
use App\Models\User;
use App\Services\Users\UserCreationService;
use App\Traits\CheckMigrationsTrait;
@@ -42,7 +42,7 @@ class PanelInstaller extends SimplePage implements HasForms
public function getMaxWidth(): MaxWidth|string
{
return config('panel.filament.display-width', 'screen-2xl');
return MaxWidth::SevenExtraLarge;
}
public static function isInstalled(): bool

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
namespace App\Livewire\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Livewire\Installer\PanelInstaller;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
namespace App\Livewire\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Livewire\Installer\PanelInstaller;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
namespace App\Livewire\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Livewire\Installer\PanelInstaller;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
namespace App\Livewire\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Livewire\Installer\PanelInstaller;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
namespace App\Livewire\Installer\Steps;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
namespace App\Livewire\Installer\Steps;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Livewire;
use App\Models\Server;
use Filament\Facades\Filament;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
class ServerConflictBanner extends Component
{
public ?Server $server = null;
public function mount(): void
{
/** @var Server $server */
$server = Filament::getTenant();
$this->server = $server;
}
#[On('console-install-completed')]
#[On('console-install-started')]
#[On('console-status')]
public function refresh(?string $state = null): void
{
$this->server->fresh();
}
public function render(): View
{
return view('livewire.server-conflict-banner');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
* @property string $remote
* @property string $password
* @property int $max_connections
* @property string $jdbc
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property \App\Models\Server $server
@@ -87,6 +89,13 @@ class Database extends Model
return $this->belongsTo(Server::class);
}
protected function jdbc(): Attribute
{
return Attribute::make(
get: fn () => 'jdbc:mysql://' . $this->username . ':' . urlencode($this->password) . '@' . $this->host->host . ':' . $this->host->port . '/' . $this->database,
);
}
/**
* Run the provided statement against the database on a given connection.
*/

View File

@@ -16,6 +16,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $node_id
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Node[] $nodes
* @property int|null $nodes_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Database[] $databases
* @property int|null $databases_count
*/
class DatabaseHost extends Model
{

View File

@@ -43,7 +43,9 @@ use Illuminate\Support\Str;
* @property string $inherit_file_denylist
* @property array|null $inherit_features
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Server[] $servers
* @property int|null $servers_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\EggVariable[] $variables
* @property int|null $variables_count
* @property \App\Models\Egg|null $scriptFrom
* @property \App\Models\Egg|null $configFrom
*/

View File

@@ -41,8 +41,11 @@ use Symfony\Component\Yaml\Yaml;
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property \App\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts
* @property int|null $mounts_count
* @property \App\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property int|null $servers_count
* @property \App\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
* @property int|null $allocations_count
*/
class Node extends Model
{
@@ -384,7 +387,12 @@ class Node extends Model
// pass
}
return $ips->all();
$ips->push('0.0.0.0');
// Only IPV4
$ips = $ips->filter(fn (string $ip) => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false);
return $ips->unique()->all();
});
}
}

View File

@@ -3,16 +3,18 @@
namespace App\Models;
use App\Enums\ContainerStatus;
use App\Enums\ServerResourceType;
use App\Enums\ServerState;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Repositories\Daemon\DaemonServerRepository;
use GuzzleHttp\Exception\GuzzleException;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Number;
use Psr\Http\Message\ResponseInterface;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -303,10 +305,9 @@ class Server extends Model
public function viewableServerVariables(): HasMany
{
return $this->hasMany(ServerVariable::class)->rightJoin('egg_variables', function (JoinClause $join) {
$join->on('egg_variables.id', 'server_variables.variable_id')
->where('egg_variables.user_viewable', true);
});
return $this->serverVariables()
->join('egg_variables', 'egg_variables.id', '=', 'server_variables.variable_id')
->where('egg_variables.user_viewable', true);
}
/**
@@ -419,17 +420,13 @@ class Server extends Model
/**
* Sends a command or multiple commands to a running server instance.
*
* @throws DaemonConnectionException|GuzzleException
* @throws ConnectionException
*/
public function send(array|string $command): ResponseInterface
{
try {
return Http::daemon($this->node)->post("/api/servers/{$this->uuid}/commands", [
'commands' => is_array($command) ? $command : [$command],
])->toPsrResponse();
} catch (GuzzleException $exception) {
throw new DaemonConnectionException($exception);
}
return Http::daemon($this->node)->post("/api/servers/{$this->uuid}/commands", [
'commands' => is_array($command) ? $command : [$command],
])->toPsrResponse();
}
public function retrieveStatus(): string
@@ -453,6 +450,37 @@ class Server extends Model
});
}
public function formatResource(string $resourceKey, bool $limit = false, ServerResourceType $type = ServerResourceType::Unit, int $precision = 2): string
{
$resourceAmount = $this->{$resourceKey} ?? 0;
if (!$limit) {
$resourceAmount = $this->resources()[$resourceKey] ?? 0;
}
if ($type === ServerResourceType::Time) {
if ($resourceAmount === 0) {
return 'Offline';
}
return now()->subMillis($resourceAmount)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 4);
}
if ($resourceAmount === 0 & $limit) {
return 'Unlimited';
}
if ($type === ServerResourceType::Percentage) {
return Number::format($resourceAmount, precision: $precision, locale: auth()->user()->language ?? 'en') . '%';
}
// Our current limits are set in MB
if ($limit) {
$resourceAmount *= 2 ** 20;
}
return convert_bytes_to_readable($resourceAmount, decimals: $precision, base: 3);
}
public function condition(): Attribute
{
return Attribute::make(
@@ -481,4 +509,9 @@ class Server extends Model
return $this->status->color();
}
public function conditionColorHex(): string
{
return ContainerStatus::from($this->retrieveStatus())->colorHex();
}
}

View File

@@ -40,8 +40,6 @@ use Spatie\Permission\Traits\HasRoles;
* @property string $uuid
* @property string $username
* @property string $email
* @property string|null $name_first
* @property string|null $name_last
* @property string $password
* @property string|null $remember_token
* @property string $language
@@ -78,8 +76,6 @@ use Spatie\Permission\Traits\HasRoles;
* @method static Builder|User whereId($value)
* @method static Builder|User whereLanguage($value)
* @method static Builder|User whereTimezone($value)
* @method static Builder|User whereNameFirst($value)
* @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereTotpAuthenticatedAt($value)
@@ -126,8 +122,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'external_id',
'username',
'email',
'name_first',
'name_last',
'password',
'language',
'timezone',
@@ -152,8 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'timezone' => 'UTC',
'use_totp' => false,
'totp_secret' => null,
'name_first' => '',
'name_last' => '',
'oauth' => '[]',
];
@@ -165,8 +157,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'email' => 'required|email|between:1,255|unique:users,email',
'external_id' => 'sometimes|nullable|string|max:255|unique:users,external_id',
'username' => 'required|between:1,255|unique:users,username',
'name_first' => 'nullable|string|between:0,255',
'name_last' => 'nullable|string|between:0,255',
'password' => 'sometimes|nullable|string',
'language' => 'string',
'timezone' => 'string',
@@ -265,14 +255,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
$this->attributes['email'] = mb_strtolower($value);
}
/**
* Return a concatenated result for the accounts full name.
*/
public function getNameAttribute(): string
{
return trim($this->name_first . ' ' . $this->name_last);
}
/**
* Returns all servers that a user owns.
*/
@@ -394,7 +376,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function getFilamentName(): string
{
return $this->name_first ?: $this->username;
return $this->username;
}
public function getFilamentAvatarUrl(): ?string

View File

@@ -34,6 +34,24 @@ class WebhookConfiguration extends Model
'events',
];
public static function getEventClassesFromDirectory(string $directory, string $after): array
{
$events = [];
foreach (File::allFiles($directory) as $file) {
$namespace = str($file->getPath())
->after($after)
->replace(DIRECTORY_SEPARATOR, '\\')
->after('\\')
->replaceFirst('app', 'App')
->toString();
$events[] = $namespace.'\\'.str($file->getFilename())
->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']);
}
return $events;
}
protected function casts(): array
{
return [
@@ -75,6 +93,7 @@ class WebhookConfiguration extends Model
{
return collect(static::discoverCustomEvents())
->merge(static::allModelEvents())
->merge(static::discoverFrameworkEvents())
->unique()
->filter(fn ($event) => !in_array($event, static::$eventBlacklist))
->all();
@@ -97,6 +116,7 @@ class WebhookConfiguration extends Model
->after('eloquent.')
->replace('App\\Models\\', '')
->replace('App\\Events\\', 'event: ')
->replaceMatches('/Illuminate\\\\([A-z]+)\\\\Events\\\\/', fn (array $matches) => strtolower($matches[1]) . ': ')
->toString();
}
@@ -133,16 +153,23 @@ class WebhookConfiguration extends Model
{
$directory = app_path('Events');
$events = [];
foreach (File::allFiles($directory) as $file) {
$namespace = str($file->getPath())
->after(base_path())
->replace(DIRECTORY_SEPARATOR, '\\')
->replace('\\app\\', 'App\\')
->toString();
return self::getEventClassesFromDirectory($directory, base_path());
}
$events[] = $namespace . '\\' . str($file->getFilename())
->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']);
public static function discoverFrameworkEvents(): array
{
$frameworkDirectory = 'vendor/laravel/framework/src/';
$eventDirectories = [
'Illuminate/Auth/Events',
'Illuminate/Queue/Events',
];
$events = [];
foreach ($eventDirectories as $eventDirectory) {
$directory = base_path("$frameworkDirectory/$eventDirectory");
$events = array_merge($events, static::getEventClassesFromDirectory($directory, $frameworkDirectory));
}
return $events;

View File

@@ -2,7 +2,12 @@
namespace App\Notifications;
use App\Events\Server\SubUserAdded;
use App\Models\Server;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Container\Container;
use Illuminate\Contracts\Notifications\Dispatcher;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
@@ -11,14 +16,22 @@ class AddedToServer extends Notification implements ShouldQueue
{
use Queueable;
public object $server;
public Server $server;
public User $user;
/**
* Create a new notification instance.
* Handle a direct call to this notification from the subuser added event. This is configured
* in the event service provider.
*/
public function __construct(array $server)
public function handle(SubUserAdded $event): void
{
$this->server = (object) $server;
$this->server = $event->subuser->server;
$this->user = $event->subuser->user;
// Since we are calling this notification directly from an event listener we need to fire off the dispatcher
// to send the email now. Don't use send() or you'll end up firing off two different events.
Container::getInstance()->make(Dispatcher::class)->sendNow($this->user, $this);
}
/**
@@ -35,7 +48,7 @@ class AddedToServer extends Notification implements ShouldQueue
public function toMail(): MailMessage
{
return (new MailMessage())
->greeting('Hello ' . $this->server->user . '!')
->greeting('Hello ' . $this->user->username . '!')
->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Server', url('/server/' . $this->server->uuid_short));

View File

@@ -2,7 +2,12 @@
namespace App\Notifications;
use App\Events\Server\SubUserRemoved;
use App\Models\Server;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Container\Container;
use Illuminate\Contracts\Notifications\Dispatcher;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
@@ -11,14 +16,22 @@ class RemovedFromServer extends Notification implements ShouldQueue
{
use Queueable;
public object $server;
public Server $server;
public User $user;
/**
* Create a new notification instance.
* Handle a direct call to this notification from the subuser removed event. This is configured
* in the event service provider.
*/
public function __construct(array $server)
public function handle(SubUserRemoved $event): void
{
$this->server = (object) $server;
$this->server = $event->server;
$this->user = $event->user;
// Since we are calling this notification directly from an event listener we need to fire off the dispatcher
// to send the email now. Don't use send() or you'll end up firing off two different events.
Container::getInstance()->make(Dispatcher::class)->sendNow($this->user, $this);
}
/**
@@ -36,7 +49,7 @@ class RemovedFromServer extends Notification implements ShouldQueue
{
return (new MailMessage())
->error()
->greeting('Hello ' . $this->server->user . '.')
->greeting('Hello ' . $this->user->username . '.')
->line('You have been removed as a subuser for the following server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Panel', route('index'));

View File

@@ -4,7 +4,6 @@ namespace App\Notifications;
use App\Models\User;
use Illuminate\Bus\Queueable;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Container\Container;
use App\Events\Server\Installed;
@@ -27,14 +26,24 @@ class ServerInstalled extends Notification implements ShouldQueue
*/
public function handle(Installed $event): void
{
$event->server->loadMissing('user');
if ($event->initialInstall && !config()->get('panel.email.send_install_notification', true)) {
return;
}
$this->server = $event->server;
$this->user = $event->server->user;
if (!$event->initialInstall && !config()->get('panel.email.send_reinstall_notification', true)) {
return;
}
// Since we are calling this notification directly from an event listener we need to fire off the dispatcher
// to send the email now. Don't use send() or you'll end up firing off two different events.
Container::getInstance()->make(Dispatcher::class)->sendNow($this->user, $this);
if ($event->successful) {
$event->server->loadMissing('user');
$this->server = $event->server;
$this->user = $event->server->user;
// Since we are calling this notification directly from an event listener we need to fire off the dispatcher
// to send the email now. Don't use send() or you'll end up firing off two different events.
Container::getInstance()->make(Dispatcher::class)->sendNow($this->user, $this);
}
}
/**

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