feat: Client UI translate to Filament (from React) (#416)

* Add new panel

* Add some basic resource pages

* Wip

* Wip terminal

* Wip

* Add new panel

* Add some basic resource pages

* Wip

* [Sub-Users] Add Invite

TODO: The logic with permissions

* [Sub-Users] Fix Creation

* [Cron] Add basics

* Add basic auth and messages

* Add basic buttons

* WIP on issue/353

* WIP on issue/353

* Add Database page

* Update Database Page

* Start of Backup Page

* Composer Update

* Changes

* Send input

* Remove this includes

* Better offline handling

* Consolidate top nav config

* Update Backups Page

* Update Backups

* Change name

* Add Assign All, Layout Fixes.

* conflict

* update schedule pages

* fix phpstan

* update pint.json

* add cron presets to schedule

* fix tests

* fix task creation

* schedules: disable task creation if limit is reached & disable backup action if backup limit is 0

* update activity pages

* update resources

* Update Edit User

TODO: actually save permissions when they're changed.
TODO: Figure out why Control does not update it's state... but the rest do...

* .... Sure it works.

TODO: Update permissions when you save editing a sub user.

* user: update canAccessPanel & canAccessTenant

* add helper to convert bytes into readable format

* very basic file explorer

* files: fix some stuff & remove dummy data

* files: better error handling

* files: basic file editor

* files: add some actions

* File manager updates

* files: fix paths

* Revery Composer Upgrade, Fixes SQLite

* fix: Pint (#517)

feat: MenuItems to and from admin

* Update File Editing

Updated File Editing to its own page,
Added Permission checks for file manager.

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

* add enum for editor langs

* files: add upload & pull actions

* fix build

* files: handle images

* Update to Filament v3.2.98

* files: add remaining actions

* use `authorize` instead of `hidden`

* fix canAccessTenant

* update date columns

* files: testing & fixes

* Fix File Names

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

* Combine Pull/Upload

* Fix BulkDelete

* Uncontained tabs

* Hide Lang Selection, Move Actions

* Update Monaco, more custom

* Add livewire config

livewire limits uploads to 12MB... who knows why...
Fixed uploading a single files failing

* files: fix record url

* basic setup for settings & startup page

* make abstract class for simple app pages

* Basic Startup Page

* Update nav sort

* small cleanup

* startup: fix shouldHideComponent & getSelectOptionsFromRules

* startup: fix non editable fields & set default value

* startup: add todo for save button

* Save Variables after update & off click

Variables update when the user clicks off the input.

* Notifications are cool

* Add rule validation

* Sort variables by sortid

* pint

* Settings Page + Startup Changes

* settings: cleanup

* refactor: use server model for ServerFormPage (formerly known as SimplePage)

* Use Repeater for variables

* Add Network, Remove breadcrumbs

* Add paginated to file explorer

* Fix updating variables

* Add link to go to new client area

* fix after merge

* Add graphs to console page

Graphs still need to get the data from the web socket.

* fix pint & phpstan

* fix authorizeAccess for EditFiles and Startup page

* Fix rules on startup page

* Update console size

* Fix node name

* add "global search" to files list

requires https://github.com/pelican-dev/wings/pull/44

* remove debug dummy data

* update view action on ListServers

* enable SPA mode for app panel

* remove colors from app panel

they are defined globally in AppServiceProvider

* update global search ui a bit

(to be replaced with a custom page that is similar to the list files table)

* add own page for global search

untested - and route needs cleanup (if possible)

* fix File getRows

* remove "path" from SearchFiles (for now)

* fix caching for searched files

* add title and breadcrumbs to global search page

* make cpu & memory charts on console page working

* fix phpstan

* add missing import

* cleanup console views & widgets

* add overview stats to console

* don't be so lazy, console!

* make history working

* decode data to get array

* add missing On

* fix json_decode

* change polling to 1 sec

* hide "0" cpu/ memory

* add data to network chart

* Remove data labels

* fix data on network chart

* fix data on network chart (2nd try)

* WIP Network Stats

* Remove test

* Change MaxWidth

* run pint

* fix phpstan

* Fix storeStats cast

* make $data a string

this time for real

* update visible check for "admin" menu item

* remove account widget

* rebrand "Dashboard" to "Server List"

WIP - doesn't look good but is somewhat working

* fix canAccessPanel

* separate server list into own panel

* change path to avoid conflicts with old client area (and remove sidebar width)

* display correct icon and color on server list entries

* show total memory if server is offline

* replace custom server list page with ListRecords page

* fix tests

* fix namespace

* remove "open" button and make whole column clickable

* Update EditProfile

* run pint

* fix access to server list

* add new login page to panels

* fix next_run_at for new schedules

* use new DateTimeColumn

* add own column for file bytes

* return to server list when clicking title

* fix console loading

* handle server with "conflict state"

* add banner if server is in "conflict state"

* fix phpstan

* update docker image select

* fix permission checks on Settings & Startup pages

* fix query for activity log page

* fix activity log not being logged

* adjust ListActivities

* fix phpstan

* fix pint

* fix profile menu item link on server panel

* add ip tooltip to activity logs (and role permission)

* change backup icon

* update navigation sort

* general code cleanup

* more cleanup

* Disable Restart/Stop if server is offline

* Change rename notification

* Remove negation on abort_unless

* Add notification on save

* Single disabled closure & comment unused import

* Add required to Server Name & Nullable to description

* mutateFormDataBeforeSave doesn't work since we use forceFill

* Fix web socket connection not existing.

* Fix some subuser permissions

* add permission checks to resources

* do not allow self-deletion

* Update editing file permissions

* Fix of the previous fix

* add service for subuser updating

* Only allow save if they have file_update

* Remove unused import

* Update backup delete button

* Add Delete, remove bulks

* Update Database page

* Use Allocation Permissions

* add canAccess check to startup

* Add Permission checks to Settings page

* add service for subuser deletion

* Remove Kill permission

* Updates

* fix move files

* add redirects

* fix phpstan

* activity: remove properties from tans for now

* If alias, use that, else ip

---------

Co-authored-by: notCharles <charles@pelican.dev>
Co-authored-by: Boy132 <mail@boy132.de>
Co-authored-by: Senna <62171904+Poseidon281@users.noreply.github.com>
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
This commit is contained in:
Lance Pioch 2024-11-30 22:13:45 -05:00 committed by GitHub
parent e0c6137b92
commit fea1c51337
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 5333 additions and 160 deletions

View File

@ -0,0 +1,98 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasLabel;
enum EditorLanguages: string implements HasLabel
{
case plaintext = 'plaintext';
case abap = 'abap';
case apex = 'apex';
case azcali = 'azcali';
case bat = 'bat';
case bicep = 'bicep';
case cameligo = 'cameligo';
case coljure = 'coljure';
case coffeescript = 'coffeescript';
case c = 'c';
case cpp = 'cpp';
case csharp = 'csharp';
case csp = 'csp';
case css = 'css';
case cypher = 'cypher';
case dart = 'dart';
case dockerfile = 'dockerfile';
case ecl = 'ecl';
case elixir = 'elixir';
case flow9 = 'flow9';
case fsharp = 'fsharp';
case go = 'go';
case graphql = 'graphql';
case handlebars = 'handlebars';
case hcl = 'hcl';
case html = 'html';
case ini = 'ini';
case java = 'java';
case javascript = 'javascript';
case julia = 'julia';
case kotlin = 'kotlin';
case less = 'less';
case lexon = 'lexon';
case lua = 'lua';
case liquid = 'liquid';
case m3 = 'm3';
case markdown = 'markdown';
case mdx = 'mdx';
case mips = 'mips';
case msdax = 'msdax';
case mysql = 'mysql';
case objectivec = 'objective-c';
case pascal = 'pascal';
case pascaligo = 'pascaligo';
case perl = 'perl';
case pgsql = 'pgsql';
case php = 'php';
case pla = 'pla';
case postiats = 'postiats';
case powerquery = 'powerquery';
case powershell = 'powershell';
case proto = 'proto';
case pug = 'pug';
case python = 'python';
case qsharp = 'qsharp';
case r = 'r';
case razor = 'razor';
case redis = 'redis';
case redshift = 'redshift';
case restructuredtext = 'restructuredtext';
case ruby = 'ruby';
case rust = 'rust';
case sb = 'sb';
case scala = 'scala';
case scheme = 'scheme';
case scss = 'scss';
case shell = 'shell';
case sol = 'sol';
case aes = 'aes';
case sparql = 'sparql';
case sql = 'sql';
case st = 'st';
case swift = 'swift';
case systemverilog = 'systemverilog';
case verilog = 'verilog';
case tcl = 'tcl';
case twig = 'twig';
case typescript = 'typescript';
case typespec = 'typespec';
case vb = 'vb';
case wgsl = 'wgsl';
case xml = 'xml';
case yaml = 'yaml';
case json = 'json';
public function getLabel(): ?string
{
return $this->name;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Filament\App\Resources;
use App\Filament\App\Resources\ServerResource\Pages;
use App\Models\Server;
use Filament\Resources\Resource;
class ServerResource extends Resource
{
protected static ?string $model = Server::class;
protected static ?string $slug = '/';
protected static bool $shouldRegisterNavigation = false;
public static function canAccess(): bool
{
return true;
}
public static function getPages(): array
{
return [
'index' => Pages\ListServers::route('/'),
];
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Filament\App\Resources\ServerResource\Pages;
use App\Filament\App\Resources\ServerResource;
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\Table;
use Illuminate\Support\Number;
class ListServers extends ListRecords
{
protected static string $resource = ServerResource::class;
public function table(Table $table): Table
{
return $table
->paginated(false)
->query(fn () => auth()->user()->can('viewList server') ? Server::query() : auth()->user()->accessibleServers())
->columns([
Stack::make([
ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
])
->contentGrid([
'default' => 1,
'xl' => 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!');
}
// @phpstan-ignore-next-line
private function uptime(Server $server): string
{
$uptime = collect(cache()->get("servers.{$server->id}.uptime"))->last() ?? 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(collect(cache()->get("servers.{$server->id}.cpu_absolute"))->last() ?? 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 = collect(cache()->get("servers.{$server->id}.memory_bytes"))->last() ?? 0;
$totalMemory = collect(cache()->get("servers.{$server->id}.memory_limit_bytes"))->last() ?? 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 = collect(cache()->get("servers.{$server->id}.disk_bytes"))->last() ?? 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

@ -82,6 +82,7 @@ class CreateDatabaseHost extends CreateRecord
Select::make('node_id')
->searchable()
->preload()
->unique()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),

View File

@ -77,6 +77,7 @@ class EditDatabaseHost extends EditRecord
Select::make('node_id')
->searchable()
->preload()
->unique()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),

View File

@ -91,6 +91,8 @@ class RoleResource extends Resource
$icon = ('\App\Filament\Resources\\' . $model . 'Resource')::getNavigationIcon();
} elseif (class_exists('\App\Filament\Pages\\' . $model)) {
$icon = ('\App\Filament\Pages\\' . $model)::getNavigationIcon();
} elseif (class_exists('\App\Filament\Server\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Server\Resources\\' . $model . 'Resource')::getNavigationIcon();
}
return Section::make(Str::headline(Str::plural($model)))

View File

@ -512,23 +512,19 @@ class EditServer extends EditRecord
->label('Startup Command')
->required()
->columnSpan(6)
->rows(function ($state) {
return str($state)->explode("\n")->reduce(
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
0
);
}),
->autosize(),
Textarea::make('defaultStartup')
->hintAction(CopyAction::make())
->label('Default Startup Command')
->disabled()
->autosize()
->columnSpan(6)
->formatStateUsing(function ($state, Get $get) {
$egg = Egg::query()->find($get('egg_id'));
return $egg->startup;
})
->columnSpan(6),
}),
Repeater::make('server_variables')
->relationship('serverVariables', function (Builder $query) {
@ -868,7 +864,7 @@ class EditServer extends EditRecord
->action(function (Server $server, ServerDeletionService $service) {
$service->handle($server);
return redirect(ListServers::getUrl());
return redirect(ListServers::getUrl(panel: 'admin'));
})
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console')

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\ServerResource\Pages;
use App\Filament\Server\Pages\Console;
use App\Filament\Resources\ServerResource;
use App\Models\Server;
use Filament\Actions;
@ -82,8 +83,8 @@ class ListServers extends ListRecords
->actions([
Action::make('View')
->icon('tabler-terminal')
->url(fn (Server $server) => "/server/$server->uuid_short")
->authorize(fn () => auth()->user()->can('view server')),
->url(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->authorize(fn (Server $server) => auth()->user()->canAccessTenant($server)),
EditAction::make(),
])
->emptyStateIcon('tabler-brand-docker')

View File

@ -31,6 +31,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
@ -51,6 +52,11 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
$this->toggleTwoFactorService = $toggleTwoFactorService;
}
public function getMaxWidth(): MaxWidth|string
{
return MaxWidth::SevenExtraLarge;
}
protected function getForms(): array
{
return [
@ -236,7 +242,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->content(fn () => new HtmlString("
<div style='width: 300px; background-color: rgb(24, 24, 27);'>$image</div>
"))
->helperText('Setup Key: '. $secret),
->helperText('Setup Key: ' . $secret),
TextInput::make('2facode')
->label('Code')
->requiredWith('2fapassword')
@ -368,4 +374,17 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
$stopPropagation();
}
}
protected function getFormActions(): array
{
return [];
}
protected function getHeaderActions(): array
{
return [
$this->getSaveFormAction()->formId('form'),
];
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Server\Pages;
use App\Filament\Server\Widgets\ServerConsole;
use App\Filament\Server\Widgets\ServerCpuChart;
use App\Filament\Server\Widgets\ServerMemoryChart;
// use App\Filament\Server\Widgets\ServerNetworkChart;
use App\Filament\Server\Widgets\ServerOverview;
use App\Models\Server;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
class Console extends Page
{
protected static ?string $navigationIcon = 'tabler-brand-tabler';
protected static ?int $navigationSort = 1;
protected static string $view = 'filament.server.pages.console';
public function getWidgetData(): array
{
return [
'server' => Filament::getTenant(),
'user' => auth()->user(),
];
}
public function getWidgets(): array
{
return [
ServerOverview::class,
ServerConsole::class,
ServerCpuChart::class,
ServerMemoryChart::class,
//ServerNetworkChart::class, TODO: convert units.
];
}
public function getVisibleWidgets(): array
{
return $this->filterVisibleWidgets($this->getWidgets());
}
public function getColumns(): int|string|array
{
return 3;
}
protected function getHeaderActions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
return [
Action::make('start')
->color('primary')
->action(fn () => $this->dispatch('setServerState', state: 'start'))
->disabled(fn () => $server->isInConflictState()),
Action::make('restart')
->color('gray')
->action(fn () => $this->dispatch('setServerState', state: 'restart'))
->disabled(fn () => $server->isInConflictState() || $server->retrieveStatus() == 'offline'),
Action::make('stop')
->color('danger')
->action(fn () => $this->dispatch('setServerState', state: 'stop'))
->disabled(fn () => $server->isInConflictState() || $server->retrieveStatus() == 'offline'),
];
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Filament\Server\Pages;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Form;
use Filament\Pages\Concerns\InteractsWithFormActions;
use Filament\Pages\Page;
/**
* @property Form $form
*/
abstract class ServerFormPage extends Page
{
use InteractsWithFormActions;
use InteractsWithForms;
protected static string $view = 'filament.server.pages.server-form-page';
public ?array $data = [];
public function mount(): void
{
$this->authorizeAccess();
$this->fillForm();
}
protected function authorizeAccess(): void {}
protected function fillForm(): void
{
$data = $this->getRecord()->attributesToArray();
$this->form->fill($data);
}
/**
* @return array<int | string, string | Form>
*/
protected function getForms(): array
{
return [
'form' => $this->form($this->makeForm()
->model($this->getRecord())
->statePath($this->getFormStatePath())
->columns($this->hasInlineLabels() ? 1 : 2)
->inlineLabel($this->hasInlineLabels()),
),
];
}
public function getFormStatePath(): ?string
{
return 'data';
}
public function getRecord(): Server
{
/** @var Server $server */
$server = Filament::getTenant();
return $server;
}
// 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();
}
}

View File

@ -0,0 +1,271 @@
<?php
namespace App\Filament\Server\Pages;
use App\Enums\ServerState;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Support\Enums\Alignment;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Support\Facades\Http;
class Settings extends ServerFormPage
{
protected static ?string $navigationIcon = 'tabler-settings';
protected static ?int $navigationSort = 10;
public function form(Form $form): Form
{
/** @var Server $server */
$server = Filament::getTenant();
return $form
->columns([
'default' => 1,
'sm' => 2,
'md' => 4,
'lg' => 6,
])
->schema([
Section::make('Server Information')
->columns([
'default' => 1,
'sm' => 2,
'md' => 4,
'lg' => 6,
])
->schema([
Fieldset::make('Server')
->label('Information')
->schema([
TextInput::make('name')
->label('Server Name')
->disabled(!auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server))
->required()
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 4,
'lg' => 6,
])
->live(onBlur: true)
->afterStateUpdated(fn ($state, Server $server) => $this->updateName($state, $server)),
Textarea::make('description')
->label('Server Description')
->disabled(!auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 4,
'lg' => 6,
])
->autosize()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)),
TextInput::make('uuid')
->label('Server UUID')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 3,
'lg' => 5,
])
->disabled(),
TextInput::make('id')
->label('Server ID')
->disabled()
->columnSpan(1),
]),
Fieldset::make('Limits')
->label('Limits')
->columns([
'default' => 1,
'sm' => 1,
'md' => 3,
'lg' => 3,
])
->schema([
TextInput::make('backup_limit')
->label('Backup Limit')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Backups can be created' : $server->backups->count() . ' of ' . $state),
TextInput::make('database_limit')
->label('Database Limit')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No Databases can be created' : $server->databases->count() . ' of ' . $state),
TextInput::make('allocation_limit')
->label('Allocation Limit')
->columnSpan(1)
->disabled()
->formatStateUsing(fn ($state, Server $server) => !$state ? 'No additional Allocations can be created' : $server->allocations->count() . ' of ' . ($state + 1)),
]),
]),
Section::make('Node Information')
->schema([
TextInput::make('node.name')
->label('Node Name')
->formatStateUsing(fn (Server $server) => $server->node->name)
->disabled(),
Fieldset::make('SFTP Information')
->hidden(fn () => !auth()->user()->can(Permission::ACTION_FILE_SFTP, $server))
->label('SFTP Information')
->columns([
'default' => 1,
'sm' => 1,
'md' => 3,
'lg' => 3,
])
->schema([
TextInput::make('connection')
->label('Connection')
->columnSpan(1)
->disabled()
->hintActions([
Action::make('connect_sftp')
->label('Connect to SFTP')
->color('success')
->icon('tabler-plug')
->url(function (Server $server) {
$fqdn = $server->node->daemon_sftp_alias ?? $server->node->fqdn;
return 'sftp://' . auth()->user()->username . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
}),
])
->formatStateUsing(function (Server $server) {
$fqdn = $server->node->daemon_sftp_alias ?? $server->node->fqdn;
return 'sftp://' . auth()->user()->username . '.' . $server->uuid_short . '@' . $fqdn . ':' . $server->node->daemon_sftp;
}),
TextInput::make('username')
->label('Username')
->columnSpan(1)
->disabled()
->formatStateUsing(fn (Server $server) => auth()->user()->username . '.' . $server->uuid_short),
Placeholder::make('password')
->columnSpan(1)
->content('Your SFTP password is the same as the password you use to access this panel.'),
]),
]),
Section::make('Reinstall Server')
->hidden(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->collapsible()->collapsed()
->footerActions([
Action::make('reinstall')
->color('danger')
->disabled(!auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->label('Reinstall')
->requiresConfirmation()
->modalHeading('Are you sure you want to reinstall the server?')
->modalDescription('Some files may be deleted or modified during this process, please back up your data before continuing.')
->modalSubmitActionLabel('Yes, Reinstall')
->action(function (Server $server) {
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server), 403);
$server->fill(['status' => ServerState::Installing])->save();
try {
Http::daemon($server->node)->post(sprintf(
'/api/servers/%s/reinstall',
$server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
Activity::event('server:settings.reinstall')
->log();
Notification::make()
->success()
->title('Server Reinstall Started')
->send();
}),
])
->footerActionsAlignment(Alignment::Right)
->schema([
Placeholder::make('')
->label('Reinstalling your server will stop it, and then re-run the installation script that initially set it up.'),
Placeholder::make('')
->label('Some files may be deleted or modified during this process, please back up your data before continuing.'),
]),
]);
}
public function updateName(string $name, Server $server): void
{
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server), 403);
$original = $server->name;
try {
$server->forceFill([
'name' => $name,
])->saveOrFail();
if ($original !== $name) {
Activity::event('server:settings.rename')
->property(['old' => $original, 'new' => $name])
->log();
}
Notification::make()
->success()
->duration(5000) // 5 seconds
->title('Updated Server Name')
->body(fn () => $original . ' -> ' . $name)
->send();
} catch (Exception $exception) {
Notification::make()
->danger()
->title('Failed')
->body($exception->getMessage())
->send();
}
}
public function updateDescription(string $description, Server $server): void
{
abort_unless(auth()->user()->can(Permission::ACTION_SETTINGS_RENAME, $server), 403);
$original = $server->description;
try {
$server->forceFill([
'description' => $description,
])->saveOrFail();
if ($original !== $description) {
Activity::event('server:settings.description')
->property(['old' => $original, 'new' => $description])
->log();
}
Notification::make()
->success()
->duration(5000) // 5 seconds
->title('Updated Server Description')
->body(fn () => $original . ' -> ' . $description)
->send();
} catch (Exception $exception) {
Notification::make()
->danger()
->title('Failed')
->body($exception->getMessage())
->send();
}
}
}

View File

@ -0,0 +1,238 @@
<?php
namespace App\Filament\Server\Pages;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Models\ServerVariable;
use Closure;
use Filament\Facades\Filament;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Validator;
class Startup extends ServerFormPage
{
protected static ?string $navigationIcon = 'tabler-player-play';
protected static ?int $navigationSort = 9;
public function form(Form $form): Form
{
/** @var Server $server */
$server = Filament::getTenant();
return $form
->columns([
'default' => 1,
'sm' => 1,
'md' => 4,
'lg' => 6,
])
->schema([
Textarea::make('startup')
->label('Startup Command')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 4,
])
->autosize()
->readOnly(),
TextInput::make('custom_image')
->label('Docker Image')
->readOnly()
->visible(fn (Server $server) => !in_array($server->image, $server->egg->docker_images))
->formatStateUsing(fn (Server $server) => $server->image)
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
]),
Select::make('image')
->label('Docker Image')
->live()
->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images))
->disabled(!auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->afterStateUpdated(function ($state, Server $server) {
$original = $server->image;
$server->forceFill(['image' => $state])->saveOrFail();
if ($original !== $server->image) {
Activity::event('server:startup.image')
->property(['old' => $original, 'new' => $state])
->log();
}
Notification::make()
->title('Docker image updated')
->body('Restart the server to use the new image.')
->success()
->send();
})
->options(function (Server $server) {
$images = $server->egg->docker_images;
return array_flip($images);
})
->selectablePlaceholder(false)
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
]),
Section::make('Server Variables')
->schema([
Repeater::make('server_variables')
->label('')
->relationship('viewableServerVariables')
->grid()
->disabled(!auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->reorderable(false)->addable(false)->deletable(false)
->schema(function () {
$text = TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->disabled(fn (ServerVariable $serverVariable) => !$serverVariable->variable->user_editable)
->required(fn (ServerVariable $serverVariable) => $serverVariable->variable->getRequiredAttribute())
->rules([
fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $serverVariable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message);
}
},
]);
$select = Select::make('variable_value')
->hidden($this->shouldHideComponent(...))
->disabled(fn (ServerVariable $serverVariable) => !$serverVariable->variable->user_editable)
->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false);
$components = [$text, $select];
foreach ($components as &$component) {
$component = $component
->live(onBlur: true)
->afterStateUpdated(function ($state, ServerVariable $serverVariable) {
$this->update($state, $serverVariable);
})
->hintIcon('tabler-code')
->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name)
->hintIconTooltip(fn (ServerVariable $serverVariable) => implode('|', $serverVariable->variable->rules))
->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description);
}
return $components;
})
->columnSpan(6),
]),
]);
}
protected function authorizeAccess(): void
{
abort_unless(auth()->user()->can(Permission::ACTION_STARTUP_READ, Filament::getTenant()), 403);
}
public static function canAccess(): bool
{
return auth()->user()->can(Permission::ACTION_STARTUP_READ, Filament::getTenant());
}
private function shouldHideComponent(ServerVariable $serverVariable, Component $component): bool
{
$containsRuleIn = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'), false);
if ($component instanceof Select) {
return !$containsRuleIn;
}
if ($component instanceof TextInput) {
return $containsRuleIn;
}
throw new \Exception('Component type not supported: ' . $component::class);
}
private function getSelectOptionsFromRules(ServerVariable $serverVariable): array
{
$inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'));
return str($inRule)
->after('in:')
->explode(',')
->each(fn ($value) => str($value)->trim())
->mapWithKeys(fn ($value) => [$value => $value])
->all();
}
public function update(?string $state, ServerVariable $serverVariable): null
{
$original = $serverVariable->variable_value;
try {
$validator = Validator::make(
['variable_value' => $state],
['variable_value' => $serverVariable->variable->rules]
);
if ($validator->fails()) {
Notification::make()
->danger()
->title('Validation Failed: ' . $serverVariable->variable->name)
->body(implode(', ', $validator->errors()->all()))
->send();
return null;
}
ServerVariable::query()->updateOrCreate([
'server_id' => $this->getRecord()->id,
'variable_id' => $serverVariable->variable->id,
], [
'variable_value' => $state ?? '',
]);
if ($original !== $state) {
Activity::event('server:startup.edit')
->property([
'variable' => $serverVariable->variable->env_variable,
'old' => $original,
'new' => $state,
])
->log();
}
Notification::make()
->success()
->title('Updated: ' . $serverVariable->variable->name)
->body(fn () => $original . ' -> ' . $state)
->send();
} catch (\Exception $e) {
Notification::make()
->danger()
->title('Failed: ' . $serverVariable->variable->name)
->body($e->getMessage())
->send();
}
return null;
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\ActivityResource\Pages;
use App\Models\ActivityLog;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Server;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class ActivityResource extends Resource
{
protected static ?string $model = ActivityLog::class;
protected static ?string $label = 'Activity';
protected static ?string $pluralLabel = 'Activity';
protected static ?int $navigationSort = 8;
protected static ?string $navigationIcon = 'tabler-stack';
public static function getEloquentQuery(): Builder
{
/** @var Server $server */
$server = Filament::getTenant();
return $server->activity()
->getQuery()
->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS)
->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) {
// We could do this with a query and a lot of joins, but that gets pretty
// painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]);
$rootAdmins = Role::getRootAdmin()->users()->pluck('id');
$builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass());
})
->where(function (Builder $builder) use ($subusers, $rootAdmins) {
$builder->whereNull('users.id')
->orWhereNotIn('users.id', $rootAdmins)
->orWhereIn('users.id', $subusers);
});
});
}
// 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());
}
public static function getPages(): array
{
return [
'index' => Pages\ListActivities::route('/'),
];
}
}

View File

@ -0,0 +1,40 @@
<?php
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 Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListActivities extends ListRecords
{
protected static string $resource = ActivityResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('event')
->html()
->formatStateUsing(fn ($state, ActivityLog $activityLog) => __('activity.'.str($state)->replace(':', '.'))) // TODO: convert properties to a format that trans likes, see ActivityLogEntry.tsx - wrapProperties
->description(fn ($state) => $state),
TextColumn::make('user')
->state(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User ? $activityLog->actor->username : 'System')
->tooltip(fn (ActivityLog $activityLog) => auth()->user()->can('seeIps activityLog') ? $activityLog->ip : '')
->url(fn (ActivityLog $activityLog): string => $activityLog->actor instanceof User ? route('filament.admin.resources.users.edit', ['record' => $activityLog->actor]) : ''),
DateTimeColumn::make('timestamp')
->since()
->sortable(),
])
->defaultSort('timestamp', 'desc');
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\AllocationResource\Pages;
use App\Models\Allocation;
use App\Models\Permission;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class AllocationResource extends Resource
{
protected static ?string $model = Allocation::class;
protected static ?string $label = 'Network';
protected static ?string $pluralLabel = 'Network';
protected static ?int $navigationSort = 7;
protected static ?string $navigationIcon = 'tabler-network';
// 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_ALLOCATION_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_ALLOCATION_DELETE, Filament::getTenant());
}
public static function getPages(): array
{
return [
'index' => Pages\ListAllocations::route('/'),
];
}
}

View File

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

View File

@ -0,0 +1,57 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\BackupResource\Pages;
use App\Models\Backup;
use App\Models\Permission;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class BackupResource extends Resource
{
protected static ?string $model = Backup::class;
protected static ?int $navigationSort = 3;
protected static ?string $navigationIcon = 'tabler-file-zip';
protected static bool $canCreateAnother = false;
// 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_BACKUP_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_BACKUP_CREATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_BACKUP_DELETE, Filament::getTenant());
}
public static function getPages(): array
{
return [
'index' => Pages\ListBackups::route('/'),
];
}
}

View File

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

View File

@ -0,0 +1,65 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Models\Database;
use App\Models\Permission;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class DatabaseResource extends Resource
{
protected static ?string $model = Database::class;
protected static ?int $navigationSort = 6;
protected static ?string $navigationIcon = 'tabler-database';
// 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_DATABASE_READ, Filament::getTenant());
}
public static function canView(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_DATABASE_DELETE, Filament::getTenant());
}
public static function getPages(): array
{
return [
'index' => Pages\ListDatabases::route('/'),
];
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Filament\Server\Resources\DatabaseResource\Pages;
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\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class ListDatabases extends ListRecords
{
protected static string $resource = DatabaseResource::class;
public function form(Form $form): Form
{
/** @var Server $server */
$server = Filament::getTenant();
return $form
->schema([
TextInput::make('database')
->columnSpanFull()
->suffixAction(CopyAction::make()),
TextInput::make('username')
->suffixAction(CopyAction::make()),
TextInput::make('password')
->password()->revealable()
->hidden(fn () => !auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hintAction(
Action::make('rotate')
->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),
TextInput::make('remote')
->label('Connections From'),
TextInput::make('max_connections')
->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'),
TextInput::make('JDBC')
->label('JDBC Connection String')
->password()->revealable()
->hidden(!auth()->user()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->suffixAction(CopyAction::make())
->columnSpanFull()
->formatStateUsing(fn (Get $get, Database $database) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($database->password) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database')),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('database'),
TextColumn::make('username'),
TextColumn::make('remote'),
DateTimeColumn::make('created_at')
->sortable(),
])
->actions([
ViewAction::make()
->modalHeading(fn (Database $database) => 'Viewing ' . $database->database),
DeleteAction::make(),
]);
}
protected function getHeaderActions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
return [
CreateAction::make('new')
->label(fn () => $server->databases()->count() >= $server->database_limit ? 'Database limit reached' : 'Create Database')
->disabled(fn () => $server->databases()->count() >= $server->database_limit)
->color(fn () => $server->databases()->count() >= $server->database_limit ? 'danger' : 'primary')
->createAnother(false)
->form([
Grid::make()
->columns(3)
->schema([
TextInput::make('database')
->columnSpan(2)
->label('Database Name')
->prefix('s'. $server->id . '_')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Leaving this blank will auto generate a random name'),
TextInput::make('remote')
->columnSpan(1)
->label('Connections From')
->default('%'),
]),
])
->action(function ($data, DatabaseManagementService $service) use ($server) {
if (empty($data['database'])) {
$data['database'] = str_random(12);
}
$data['database_host_id'] = DatabaseHost::where('node_id', $server->node_id)->first()->id;
$data['database'] = 's'. $server->id . '_' . $data['database'];
$service->create($server, $data);
}),
];
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\FileResource\Pages;
use App\Models\File;
use App\Models\Permission;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class FileResource extends Resource
{
protected static ?string $model = File::class;
protected static ?int $navigationSort = 2;
protected static ?string $navigationIcon = 'tabler-files';
// 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_FILE_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_FILE_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_FILE_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_FILE_DELETE, Filament::getTenant());
}
public static function getPages(): array
{
return [
'edit' => Pages\EditFiles::route('/edit/{path}'),
'search' => Pages\SearchFiles::route('/search/{searchTerm}'), // TODO: find better way?
'index' => Pages\ListFiles::route('/{path?}'),
];
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace App\Filament\Server\Resources\FileResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Enums\EditorLanguages;
use App\Facades\Activity;
use App\Filament\Server\Resources\FileResource;
use App\Models\File;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Pages\Concerns\HasUnsavedDataChangesAlert;
use Filament\Pages\Concerns\InteractsWithFormActions;
use Filament\Panel;
use Filament\Resources\Pages\Page;
use Filament\Resources\Pages\PageRegistration;
use Filament\Support\Enums\Alignment;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
/**
* @property Form $form
*/
class EditFiles extends Page
{
use HasUnsavedDataChangesAlert;
use InteractsWithFormActions;
use InteractsWithForms;
protected static string $resource = FileResource::class;
protected static string $view = 'filament.server.pages.edit-file';
#[Locked]
public string $path;
public ?array $data = [];
public function form(Form $form): Form
{
/** @var Server $server */
$server = Filament::getTenant();
File::get($server, dirname($this->path))->orderByDesc('is_directory')->orderBy('name');
return $form
->schema([
Select::make('lang')
->live()
->label('')
->placeholder('File Language')
->options(EditorLanguages::class)
->hidden() //TODO Fix Dis
->default(function () {
$split = explode('.', $this->path);
return end($split);
}),
Section::make('Editing: ' . $this->path)
->footerActions([
Action::make('save')
->label('Save Changes')
->authorize(auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+s')
->action(function () use ($server) {
$data = $this->form->getState();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->putContent($this->path, $data['editor'] ?? '');
Activity::event('server:file.write')
->property('file', $this->path)
->log();
Notification::make()
->success()
->duration(5000) // 5 seconds
->title('Saved File')
->body(fn () => $this->path)
->send();
}),
Action::make('cancel')
->label('Cancel')
->color('danger')
->icon('tabler-x')
->url(fn () => ListFiles::getUrl(['path' => dirname($this->path)])),
])
->footerActionsAlignment(Alignment::End)
->schema([
MonacoEditor::make('editor')
->label('')
->formatStateUsing(function () use ($server) {
// @phpstan-ignore-next-line
return app(DaemonFileRepository::class)
->setServer($server)
->getContent($this->path, config('panel.files.max_edit_size'));
})
->language(fn (Get $get) => $get('lang') ?? 'plaintext')
->view('filament.plugins.monaco-editor'),
]),
]);
}
public function mount(string $path): void
{
$this->authorizeAccess();
$this->path = $path;
$this->form->fill();
}
protected function authorizeAccess(): void
{
abort_unless(auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
}
/**
* @return array<int | string, string | Form>
*/
protected function getForms(): array
{
return [
'form' => $this->form(static::getResource()::form(
$this->makeForm()
->statePath($this->getFormStatePath())
->columns($this->hasInlineLabels() ? 1 : 2)
->inlineLabel($this->hasInlineLabels()),
)),
];
}
public function getFormStatePath(): ?string
{
return 'data';
}
public function getBreadcrumbs(): array
{
$resource = static::getResource();
$breadcrumbs = [
$resource::getUrl() => $resource::getBreadcrumb(),
];
$previousParts = '';
foreach (explode('/', $this->path) as $part) {
$previousParts = $previousParts . '/' . $part;
$breadcrumbs[self::getUrl(['path' => ltrim($previousParts, '/')])] = $part;
}
return $breadcrumbs;
}
public static function route(string $path): PageRegistration
{
return new PageRegistration(
page: static::class,
route: fn (Panel $panel): Route => RouteFacade::get($path, static::class)
->middleware(static::getRouteMiddleware($panel))
->withoutMiddleware(static::getWithoutRouteMiddleware($panel))
->where('path', '.*'),
);
}
}

View File

@ -0,0 +1,588 @@
<?php
namespace App\Filament\Server\Resources\FileResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Enums\EditorLanguages;
use App\Facades\Activity;
use App\Filament\Server\Resources\FileResource;
use App\Models\File;
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 Carbon\CarbonImmutable;
use Filament\Actions\Action as HeaderAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Panel;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\PageRegistration;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
class ListFiles extends ListRecords
{
protected static string $resource = FileResource::class;
#[Locked]
public string $path;
public function mount(?string $path = null): void
{
parent::mount();
$this->path = $path ?? '/';
}
public function getBreadcrumbs(): array
{
$resource = static::getResource();
$breadcrumbs = [
$resource::getUrl() => $resource::getBreadcrumb(),
];
$previousParts = '';
foreach (explode('/', $this->path) as $part) {
$previousParts = $previousParts . '/' . $part;
$breadcrumbs[self::getUrl(['path' => ltrim($previousParts, '/')])] = $part;
}
return $breadcrumbs;
}
public function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->paginated([15, 25, 50, 100])
->defaultPaginationPageOption(15)
->query(fn () => File::get($server, $this->path)->orderByDesc('is_directory')->orderBy('name'))
->columns([
TextColumn::make('name')
->searchable()
->icon(fn (File $file) => $file->getIcon()),
BytesColumn::make('size'),
DateTimeColumn::make('modified_at')
->since()
->sortable(),
])
->recordUrl(function (File $file) use ($server) {
if ($file->is_directory) {
return self::getUrl(['path' => join_paths($this->path, $file->name)]);
}
if (!auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server)) {
return null;
}
return $file->canEdit() ? EditFiles::getUrl(['path' => join_paths($this->path, $file->name)]) : null;
})
->actions([
Action::make('view')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->label('Open')
->icon('tabler-eye')
->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])),
EditAction::make('edit')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->label('Edit')
->icon('tabler-edit')
->visible(fn (File $file) => $file->canEdit())
->url(fn (File $file) => EditFiles::getUrl(['path' => join_paths($this->path, $file->name)])),
ActionGroup::make([
Action::make('rename')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->label('Rename')
->icon('tabler-forms')
->form([
TextInput::make('name')
->label('File name')
->default(fn (File $file) => $file->name)
->required(),
])
->action(function ($data, File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->renameFiles($this->path, [['to' => $data['name'], 'from' => $file->name]]);
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', [['to' => $data['name'], 'from' => $file->name]])
->log();
Notification::make()
->title('File Renamed')
->body(fn () => $file->name . ' -> ' . $data['name'])
->success()
->send();
}),
Action::make('copy')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->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)
->setServer($server)
->copyFile(join_paths($this->path, $file->name));
Activity::event('server:file.copy')
->property('file', join_paths($this->path, $file->name))
->log();
Notification::make()
->title('File copied')
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
Action::make('download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->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)
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser(auth()->user())
->setClaims([
'file_path' => rawurldecode(join_paths($this->path, $file->name)),
'server_uuid' => $server->uuid,
])
->handle($server->node, auth()->user()->id . $server->uuid);
Activity::event('server:file.download')
->property('file', join_paths($this->path, $file->name))
->log();
return redirect()->away(sprintf('%s/download/file?token=%s', $server->node->getConnectionAddress(), $token->toString())); // TODO: download works, but breaks modals
}),
Action::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->label('Move')
->icon('tabler-replace')
->form([
TextInput::make('location')
->label('File name')
->hint('Enter the new name and directory of this file or folder, relative to the current directory.')
->default(fn (File $file) => $file->name)
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location')))),
])
->action(function ($data, File $file) use ($server) {
$location = resolve_path(join_paths($this->path, $data['location']));
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->renameFiles($this->path, [['to' => $location, 'from' => $file->name]]);
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', [['to' => $location, 'from' => $file->name]])
->log();
Notification::make()
->title(join_paths($this->path, $file->name) . ' was moved to ' . $location)
->success()
->send();
}),
Action::make('permissions')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->label('Permissions')
->icon('tabler-license')
->form([
CheckboxList::make('owner')
->bulkToggleable()
->options([
'read' => 'Read',
'write' => 'Write',
'execute' => 'Execute',
])
->formatStateUsing(function ($state, File $file) {
$mode = (int) substr((string) $file->mode_bits, 0, 1);
return $this->getPermissionsFromModeBit($mode);
}),
CheckboxList::make('group')
->bulkToggleable()
->options([
'read' => 'Read',
'write' => 'Write',
'execute' => 'Execute',
])
->formatStateUsing(function ($state, File $file) {
$mode = (int) substr((string) $file->mode_bits, 1, 1);
return $this->getPermissionsFromModeBit($mode);
}),
CheckboxList::make('public')
->bulkToggleable()
->options([
'read' => 'Read',
'write' => 'Write',
'execute' => 'Execute',
])
->formatStateUsing(function ($state, File $file) {
$mode = (int) substr((string) $file->mode_bits, 2, 1);
return $this->getPermissionsFromModeBit($mode);
}),
])
->action(function ($data, File $file) 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)
->setServer($server)
->chmodFiles($this->path, [['file' => $file->name, 'mode' => $mode]]);
Notification::make()
->title('Permissions changed to ' . $mode)
->success()
->send();
}),
Action::make('archive')
->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)
->setServer($server)
->compressFiles($this->path, [$file->name]);
Activity::event('server:file.compress')
->property('directory', $this->path)
->property('files', [$file->name])
->log();
Notification::make()
->title('Archive created')
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
Action::make('unarchive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label('Unarchive')
->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive())
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->decompressFile($this->path, $file->name);
Activity::event('server:file.decompress')
->property('directory', $this->path)
->property('files', $file->name)
->log();
Notification::make()
->title('Unarchive completed')
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
]),
DeleteAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->label('')
->icon('tabler-trash')
->requiresConfirmation()
->modalDescription(fn (File $file) => $file->name)
->modalHeading('Delete file?')
->action(function (File $file) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->deleteFiles($this->path, [$file->name]);
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $file->name)
->log();
}),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->form([
TextInput::make('location')
->label('File name')
->hint('Enter the new name and directory of this file or folder, relative to the current directory.')
->default(fn (File $file) => $file->name)
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data) 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)
->setServer($server)
->renameFiles($this->path, $files);
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files were moved from to ' . $location)
->success()
->send();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->action(function (Collection $files) use ($server) {
// @phpstan-ignore-next-line
$files = $files->map(fn ($file) => $file->name)->toArray();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->compressFiles($this->path, $files);
Activity::event('server:file.compress')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title('Archive created')
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files) use ($server) {
// @phpstan-ignore-next-line
$files = $files->map(fn ($file) => $file->name)->toArray();
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->deleteFiles($this->path, $files);
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
]),
]);
}
protected function getHeaderActions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
return [
HeaderAction::make('new_file')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('New File')
->color('gray')
->keyBindings('')
->modalSubmitActionLabel('Create')
->action(function ($data) use ($server) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->putContent(join_paths($this->path, $data['name']), $data['editor'] ?? '');
Activity::event('server:file.write')
->property('file', join_paths($this->path, $data['name']))
->log();
})
->form([
TextInput::make('name')
->label('File Name')
->required(),
Select::make('lang')
->live()
->hidden() //TODO: Make file language selection work
->label('Language')
->placeholder('File Language')
->options(EditorLanguages::class),
MonacoEditor::make('editor')
->label('')
->view('filament.plugins.monaco-editor')
->language(fn (Get $get) => $get('lang')),
]),
HeaderAction::make('new_folder')
->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)
->setServer($server)
->createDirectory($data['name'], $this->path);
Activity::event('server:file.write')
->property('file', join_paths($this->path, $data['name']))
->log();
})
->form([
TextInput::make('name')
->label('Folder Name')
->required(),
]),
HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('Upload')
->action(function ($data) 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)
->setServer($server)
->putContent(join_paths($this->path, $file->getClientOriginalName()), $file->getContent());
Activity::event('server:file.uploaded')
->property('directory', $this->path)
->property('file', $file->getFilename())
->log();
}
} elseif ($data['url'] !== null) {
// @phpstan-ignore-next-line
app(DaemonFileRepository::class)
->setServer($server)
->pull($data['url'], $this->path);
Activity::event('server:file.pull')
->property('url', $data['url'])
->property('directory', $this->path)
->log();
}
return redirect(ListFiles::getUrl(['path' => $this->path]));
})
->form([
Tabs::make()
->contained(false)
->schema([
Tabs\Tab::make('Upload Files')
->live()
->schema([
FileUpload::make('files')
->label('File(s)')
->storeFiles(false)
->previewable(false)
->preserveFilenames()
->multiple(),
]),
Tabs\Tab::make('Upload From URL')
->live()
->disabled(fn (Get $get) => count($get('files')) > 0)
->schema([
TextInput::make('url')
->label('URL')
->url(),
]),
]),
]),
HeaderAction::make('search')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->label('Global Search')
->modalSubmitActionLabel('Search')
->form([
TextInput::make('searchTerm')
->placeholder('Enter a search term, e.g. *.txt')
->minLength(3),
])
->action(fn ($data) => redirect(SearchFiles::getUrl([
'searchTerm' => $data['searchTerm'],
'path' => $this->path,
]))),
];
}
public static function route(string $path): PageRegistration
{
return new PageRegistration(
page: static::class,
route: fn (Panel $panel): Route => RouteFacade::get($path, static::class)
->middleware(static::getRouteMiddleware($panel))
->withoutMiddleware(static::getWithoutRouteMiddleware($panel))
->where('path', '.*'),
);
}
private function getPermissionsFromModeBit(int $mode): array
{
if ($mode === 1) {
return ['execute'];
} elseif ($mode === 2) {
return ['write'];
} elseif ($mode === 3) {
return ['write', 'execute'];
} elseif ($mode === 4) {
return ['read'];
} elseif ($mode === 5) {
return ['read', 'execute'];
} elseif ($mode === 6) {
return ['read', 'write'];
} elseif ($mode === 7) {
return ['read', 'write', 'execute'];
}
return [];
}
}

View File

@ -0,0 +1,70 @@
<?php
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 Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Livewire\Attributes\Locked;
class SearchFiles extends ListRecords
{
protected static string $resource = FileResource::class;
protected static ?string $title = 'Global Search';
#[Locked]
public string $searchTerm;
#[Locked]
public string $path;
public function mount(?string $searchTerm = null, ?string $path = null): void
{
parent::mount();
$this->searchTerm = $searchTerm;
$this->path = $path ?? '/';
}
public function getBreadcrumbs(): array
{
$resource = static::getResource();
return [
$resource::getUrl() => $resource::getBreadcrumb(),
self::getUrl(['searchTerm' => $this->searchTerm]) => 'Search "' . $this->searchTerm . '"',
];
}
public function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->paginated(false)
->query(fn () => File::get($server, $this->path, $this->searchTerm)->orderByDesc('is_directory')->orderBy('name'))
->columns([
TextColumn::make('name')
->searchable()
->icon(fn (File $file) => $file->getIcon()),
BytesColumn::make('size'),
DateTimeColumn::make('modified_at')
->since()
->sortable(),
])
->recordUrl(function (File $file) {
if ($file->is_directory) {
return ListFiles::getUrl(['path' => join_paths($this->path, $file->name)]);
}
return $file->canEdit() ? EditFiles::getUrl(['path' => join_paths($this->path, $file->name)]) : null;
});
}
}

View File

@ -0,0 +1,268 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager;
use App\Models\Permission;
use App\Models\Schedule;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class ScheduleResource extends Resource
{
protected static ?string $model = Schedule::class;
protected static ?int $navigationSort = 4;
protected static ?string $navigationIcon = 'tabler-clock';
// 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_SCHEDULE_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_SCHEDULE_DELETE, Filament::getTenant());
}
public static function form(Form $form): Form
{
return $form
->columns(10)
->schema([
TextInput::make('name')
->columnSpan(10)
->label('Schedule Name')
->placeholder('A human readable identifier for this schedule.')
->autocomplete(false)
->required(),
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)
->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)
->required()
->default(1),
TextInput::make('cron_minute')
->columnSpan(2)
->label('Minute')
->default('*/5')
->required(),
TextInput::make('cron_hour')
->columnSpan(2)
->label('Hour')
->default('*')
->required(),
TextInput::make('cron_day_of_month')
->columnSpan(2)
->label('Day of Month')
->default('*')
->required(),
TextInput::make('cron_month')
->columnSpan(2)
->label('Month')
->default('*')
->required(),
TextInput::make('cron_day_of_week')
->columnSpan(2)
->label('Day of Week')
->default('*')
->required(),
Section::make('Presets')
->schema([
Actions::make([
Action::make('hourly')
->disabled(fn (string $operation) => $operation === 'view')
->action(function (Set $set) {
$set('cron_minute', '0');
$set('cron_hour', '*');
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
Action::make('daily')
->disabled(fn (string $operation) => $operation === 'view')
->action(function (Set $set) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
Action::make('weekly')
->disabled(fn (string $operation) => $operation === 'view')
->action(function (Set $set) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', '0');
}),
Action::make('monthly')
->disabled(fn (string $operation) => $operation === 'view')
->action(function (Set $set) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '1');
$set('cron_month', '*');
$set('cron_day_of_week', '0');
}),
Action::make('every_x_minutes')
->disabled(fn (string $operation) => $operation === 'view')
->form([
TextInput::make('x')
->label('')
->numeric()
->minValue(1)
->maxValue(60)
->prefix('Every')
->suffix('Minutes'),
])
->action(function (Set $set, $data) {
$set('cron_minute', '*/' . $data['x']);
$set('cron_hour', '*');
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
Action::make('every_x_hours')
->disabled(fn (string $operation) => $operation === 'view')
->form([
TextInput::make('x')
->label('')
->numeric()
->minValue(1)
->maxValue(24)
->prefix('Every')
->suffix('Hours'),
])
->action(function (Set $set, $data) {
$set('cron_minute', '0');
$set('cron_hour', '*/' . $data['x']);
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
Action::make('every_x_days')
->disabled(fn (string $operation) => $operation === 'view')
->form([
TextInput::make('x')
->label('')
->numeric()
->minValue(1)
->maxValue(24)
->prefix('Every')
->suffix('Days'),
])
->action(function (Set $set, $data) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '*/' . $data['x']);
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
Action::make('every_x_months')
->disabled(fn (string $operation) => $operation === 'view')
->form([
TextInput::make('x')
->label('')
->numeric()
->minValue(1)
->maxValue(24)
->prefix('Every')
->suffix('Months'),
])
->action(function (Set $set, $data) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '0');
$set('cron_month', '*/' . $data['x']);
$set('cron_day_of_week', '*');
}),
Action::make('every_x_day_of_week')
->disabled(fn (string $operation) => $operation === 'view')
->form([
Select::make('x')
->label('')
->prefix('Every')
->options([
'0' => 'Sunday',
'1' => 'Monday',
'2' => 'Tuesday',
'3' => 'Wednesday',
'4' => 'Thursday',
'5' => 'Friday',
'6' => 'Saturday',
]),
])
->action(function (Set $set, $data) {
$set('cron_minute', '0');
$set('cron_hour', '0');
$set('cron_day_of_month', '*');
$set('cron_month', '*');
$set('cron_day_of_week', $data['x']);
}),
]),
]),
]);
}
public static function getRelations(): array
{
return [
TasksRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListSchedules::route('/'),
'create' => Pages\CreateSchedule::route('/create'),
'view' => Pages\ViewSchedule::route('/{record}'),
'edit' => Pages\EditSchedule::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Exceptions\DisplayException;
use App\Filament\Server\Resources\ScheduleResource;
use App\Helpers\Utilities;
use App\Models\Server;
use Carbon\Carbon;
use Exception;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
class CreateSchedule extends CreateRecord
{
protected static string $resource = ScheduleResource::class;
protected static bool $canCreateAnother = false;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (!isset($data['server_id'])) {
/** @var Server $server */
$server = Filament::getTenant();
$data['server_id'] = $server->id;
}
if (!isset($data['next_run_at'])) {
$data['next_run_at'] = $this->getNextRunAt($data['cron_minute'], $data['cron_hour'], $data['cron_day_of_month'], $data['cron_month'], $data['cron_day_of_week']);
}
return $data;
}
protected function getNextRunAt(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
{
try {
return Utilities::getScheduleNextRunDate(
$minute,
$hour,
$dayOfMonth,
$month,
$dayOfWeek
);
} catch (Exception) {
throw new DisplayException('The cron data provided does not evaluate to a valid expression.');
}
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSchedule extends EditRecord
{
protected static string $resource = ScheduleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource;
use App\Models\Schedule;
use App\Tables\Columns\DateTimeColumn;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListSchedules extends ListRecords
{
protected static string $resource = ScheduleResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('cron')
->state(fn (Schedule $schedule) => $schedule->cron_minute . ' ' . $schedule->cron_hour . ' ' . $schedule->cron_day_of_month . ' ' . $schedule->cron_month . ' ' . $schedule->cron_day_of_week),
TextColumn::make('status')
->state(fn (Schedule $schedule) => !$schedule->is_active ? 'Inactive' : ($schedule->is_processing ? 'Processing' : 'Active')),
IconColumn::make('only_when_online')
->boolean()
->sortable(),
DateTimeColumn::make('last_run_at')
->label('Last run')
->since()
->sortable(),
DateTimeColumn::make('next_run_at')
->label('Next run')
->since()
->sortable(),
])
->actions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewSchedule extends ViewRecord
{
protected static string $resource = ScheduleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Filament\Server\Resources\ScheduleResource\RelationManagers;
use App\Facades\Activity;
use App\Models\Schedule;
use App\Models\Task;
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\Table;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
public function table(Table $table): Table
{
/** @var Schedule $schedule */
$schedule = $this->getOwnerRecord();
return $table
->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
}),
TextColumn::make('time_offset')
->hidden(fn () => config('queue.default') === 'sync')
->suffix(' Seconds'),
IconColumn::make('continue_on_failure')
->boolean(),
])
->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'),
])
->action(function ($data) use ($schedule) {
$sequenceId = ($schedule->tasks()->orderByDesc('sequence_id')->first()->sequence_id ?? 0) + 1;
$task = Task::query()->create([
'schedule_id' => $schedule->id,
'sequence_id' => $sequenceId,
'action' => $data['action'],
'payload' => $data['payload'] ?? '',
'time_offset' => $data['time_offset'] ?? 0,
'continue_on_failure' => (bool) $data['continue_on_failure'],
]);
Activity::event('server:task.create')
->subject($schedule, $task)
->property(['name' => $schedule->name, 'action' => $task->action, 'payload' => $task->payload])
->log();
}),
]);
}
}

View File

@ -0,0 +1,469 @@
<?php
namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\UserResource\Pages;
use App\Models\Permission;
use App\Models\Server;
use App\Models\Subuser;
use App\Models\User;
use App\Services\Subusers\SubuserDeletionService;
use App\Services\Subusers\SubuserUpdateService;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\DeleteAction;
use Filament\Resources\Resource;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?int $navigationSort = 5;
protected static ?string $navigationIcon = 'tabler-users';
protected static ?string $tenantOwnershipRelationshipName = 'subServers';
// 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_USER_READ, Filament::getTenant());
}
public static function canCreate(): bool
{
return auth()->user()->can(Permission::ACTION_USER_CREATE, Filament::getTenant());
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_USER_UPDATE, Filament::getTenant());
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can(Permission::ACTION_USER_DELETE, Filament::getTenant());
}
public static function table(Table $table): Table
{
/** @var Server $server */
$server = Filament::getTenant();
return $table
->paginated(false)
->searchable(false)
->columns([
ImageColumn::make('picture')
->visibleFrom('lg')
->label('')
->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
TextColumn::make('username')
->searchable(),
TextColumn::make('email')
->searchable(),
TextColumn::make('permissions')
->state(fn (User $user) => count(Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first()->permissions)),
])
->actions([
DeleteAction::make()
->label('Remove User')
->hidden(fn (User $user) => auth()->user()->id === $user->id)
->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuser = Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first();
$subuserDeletionService->handle($subuser, $server);
}),
EditAction::make()
->label('Edit User')
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server))
->modalHeading(fn (User $user) => 'Editing ' . $user->email)
->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) {
$subuser = Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first();
if (in_array('console', $data['control'])) {
$data['websocket'][0] = 'connect';
}
$permissions = collect($data)->forget('email')->map(fn ($permissions, $key) => collect($permissions)->map(fn ($permission) => "$key.$permission"))->flatten()->all();
$subuserUpdateService->handle($subuser, $server, $permissions);
Notification::make()
->title('User Updated!')
->success()
->send();
return redirect(self::getUrl(tenant: $server));
})
->form([
Grid::make()
->columnSpanFull()
->columns([
'default' => 1,
'sm' => 1,
'md' => 5,
'lg' => 6,
])
->schema([
TextInput::make('email')
->inlineLabel()
->disabled()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 4,
'lg' => 5,
]),
Actions::make([
Action::make('assignAll')
->label('Assign All')
->action(function (Set $set) {
$permissions = [
'control' => [
'console',
'start',
'stop',
'restart',
],
'user' => [
'read',
'create',
'update',
'delete',
],
'file' => [
'read',
'read-content',
'create',
'update',
'delete',
'archive',
'sftp',
],
'backup' => [
'read',
'create',
'delete',
'download',
'restore',
],
'allocation' => [
'read',
'create',
'update',
'delete',
],
'startup' => [
'read',
'update',
'docker-image',
],
'database' => [
'read',
'create',
'update',
'delete',
'view_password',
],
'schedule' => [
'read',
'create',
'update',
'delete',
],
'settings' => [
'rename',
'reinstall',
'activity',
],
];
foreach ($permissions as $key => $value) {
$allValues = array_unique($value);
$set($key, $allValues);
}
}),
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Tabs::make()
->columnSpanFull()
->schema([
Tab::make('Console')
->schema([
Section::make()
->description(trans('server/users.permissions.control_desc'))
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->formatStateUsing(function (User $user, Set $set) use ($server) {
$permissionsArray = Subuser::query()
->where('user_id', $user->id)
->where('server_id', $server->id)
->first()
->permissions;
$transformedPermissions = [];
foreach ($permissionsArray as $permission) {
[$group, $action] = explode('.', $permission, 2);
$transformedPermissions[$group][] = $action;
}
foreach ($transformedPermissions as $key => $value) {
$set($key, $value);
}
return $transformedPermissions['control'] ?? [];
})
->bulkToggleable()
->label('')
->options([
'console' => 'Console',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
])
->descriptions([
'console' => trans('server/users.permissions.control_console'),
'start' => trans('server/users.permissions.control_start'),
'stop' => trans('server/users.permissions.control_stop'),
'restart' => trans('server/users.permissions.control_restart'),
]),
]),
]),
Tab::make('User')
->schema([
Section::make()
->description(trans('server/users.permissions.user_desc'))
->icon('tabler-users')
->schema([
CheckboxList::make('user')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'create' => trans('server/users.permissions.user_create'),
'read' => trans('server/users.permissions.user_read'),
'update' => trans('server/users.permissions.user_update'),
'delete' => trans('server/users.permissions.user_delete'),
]),
]),
]),
Tab::make('File')
->schema([
Section::make()
->description(trans('server/users.permissions.file_desc'))
->icon('tabler-folders')
->schema([
CheckboxList::make('file')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'read-content' => 'Read Content',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'archive' => 'Archive',
'sftp' => 'SFTP',
])
->descriptions([
'create' => trans('server/users.permissions.file_create'),
'read' => trans('server/users.permissions.file_read'),
'read-content' => trans('server/users.permissions.file_read_content'),
'update' => trans('server/users.permissions.file_update'),
'delete' => trans('server/users.permissions.file_delete'),
'archive' => trans('server/users.permissions.file_archive'),
'sftp' => trans('server/users.permissions.file_sftp'),
]),
]),
]),
Tab::make('Backup')
->schema([
Section::make()
->description(trans('server/users.permissions.backup_desc'))
->icon('tabler-download')
->schema([
CheckboxList::make('backup')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'delete' => 'Delete',
'download' => 'Download',
'restore' => 'Restore',
])
->descriptions([
'create' => trans('server/users.permissions.backup_create'),
'read' => trans('server/users.permissions.backup_read'),
'delete' => trans('server/users.permissions.backup_delete'),
'download' => trans('server/users.permissions.backup_download'),
'restore' => trans('server/users.permissions.backup_restore'),
]),
]),
]),
Tab::make('Allocation')
->schema([
Section::make()
->description(trans('server/users.permissions.allocation_desc'))
->icon('tabler-network')
->schema([
CheckboxList::make('allocation')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.allocation_read'),
'create' => trans('server/users.permissions.allocation_create'),
'update' => trans('server/users.permissions.allocation_update'),
'delete' => trans('server/users.permissions.allocation_delete'),
]),
]),
]),
Tab::make('Startup')
->schema([
Section::make()
->description(trans('server/users.permissions.startup_desc'))
->icon('tabler-question-mark')
->schema([
CheckboxList::make('startup')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'update' => 'Update',
'docker-image' => 'Docker Image',
])
->descriptions([
'read' => trans('server/users.permissions.startup_read'),
'update' => trans('server/users.permissions.startup_update'),
'docker-image' => trans('server/users.permissions.startup_docker_image'),
]),
]),
]),
Tab::make('Database')
->schema([
Section::make()
->description(trans('server/users.permissions.database_desc'))
->icon('tabler-database')
->schema([
CheckboxList::make('database')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'view_password' => 'View Password',
])
->descriptions([
'read' => trans('server/users.permissions.database_read'),
'create' => trans('server/users.permissions.database_create'),
'update' => trans('server/users.permissions.database_update'),
'delete' => trans('server/users.permissions.database_delete'),
'view_password' => trans('server/users.permissions.database_view_password'),
]),
]),
]),
Tab::make('Schedule')
->schema([
Section::make()
->description(trans('server/users.permissions.schedule_desc'))
->icon('tabler-clock')
->schema([
CheckboxList::make('schedule')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.schedule_read'),
'create' => trans('server/users.permissions.schedule_create'),
'update' => trans('server/users.permissions.schedule_update'),
'delete' => trans('server/users.permissions.schedule_delete'),
]),
]),
]),
Tab::make('Settings')
->schema([
Section::make()
->description(trans('server/users.permissions.settings_desc'))
->icon('tabler-settings')
->schema([
CheckboxList::make('settings')
->bulkToggleable()
->label('')
->options([
'rename' => 'Rename',
'reinstall' => 'Reinstall',
'activity' => 'Activity',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.setting_activity'),
]),
]),
]),
]),
]),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
];
}
}

View File

@ -0,0 +1,390 @@
<?php
namespace App\Filament\Server\Resources\UserResource\Pages;
use App\Filament\Server\Resources\UserResource;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Subusers\SubuserCreationService;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions as assignAll;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
return [
Actions\CreateAction::make('invite')
->label('Invite User')
->createAnother(false)
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_CREATE, $server))
->form([
Grid::make()
->columnSpanFull()
->columns([
'default' => 1,
'sm' => 1,
'md' => 5,
'lg' => 6,
])
->schema([
TextInput::make('email')
->email()
->inlineLabel()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 4,
'lg' => 5,
])
->required()
->unique(),
assignAll::make([
Action::make('assignAll')
->label('Assign All')
->action(function (Set $set, Get $get) {
$permissions = [
'control' => [
'console',
'start',
'stop',
'restart',
'kill',
],
'user' => [
'read',
'create',
'update',
'delete',
],
'file' => [
'read',
'read-content',
'create',
'update',
'delete',
'archive',
'sftp',
],
'backup' => [
'read',
'create',
'delete',
'download',
'restore',
],
'allocation' => [
'read',
'create',
'update',
'delete',
],
'startup' => [
'read',
'update',
'docker-image',
],
'database' => [
'read',
'create',
'update',
'delete',
'view_password',
],
'schedule' => [
'read',
'create',
'update',
'delete',
],
'settings' => [
'rename',
'reinstall',
'activity',
],
];
foreach ($permissions as $key => $value) {
$currentValues = $get($key) ?? [];
$allValues = array_unique(array_merge($currentValues, $value));
$set($key, $allValues);
}
}),
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Tabs::make()
->columnSpanFull()
->schema([
Tabs\Tab::make('Console')
->schema([
Section::make()
->description(trans('server/users.permissions.control_desc'))
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->bulkToggleable()
->label('')
->columns(2)
->options([
'console' => 'Console',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
])
->descriptions([
'console' => trans('server/users.permissions.control_console'),
'start' => trans('server/users.permissions.control_start'),
'stop' => trans('server/users.permissions.control_stop'),
'restart' => trans('server/users.permissions.control_restart'),
]),
]),
]),
Tabs\Tab::make('User')
->schema([
Section::make()
->description(trans('server/users.permissions.user_desc'))
->icon('tabler-users')
->schema([
CheckboxList::make('user')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'create' => trans('server/users.permissions.user_create'),
'read' => trans('server/users.permissions.user_read'),
'update' => trans('server/users.permissions.user_update'),
'delete' => trans('server/users.permissions.user_delete'),
]),
]),
]),
Tabs\Tab::make('File')
->schema([
Section::make()
->description(trans('server/users.permissions.file_desc'))
->icon('tabler-folders')
->schema([
CheckboxList::make('file')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'read-content' => 'Read Content',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'archive' => 'Archive',
'sftp' => 'SFTP',
])
->descriptions([
'create' => trans('server/users.permissions.file_create'),
'read' => trans('server/users.permissions.file_read'),
'read-content' => trans('server/users.permissions.file_read_content'),
'update' => trans('server/users.permissions.file_update'),
'delete' => trans('server/users.permissions.file_delete'),
'archive' => trans('server/users.permissions.file_archive'),
'sftp' => trans('server/users.permissions.file_sftp'),
]),
]),
]),
Tabs\Tab::make('Backup')
->schema([
Section::make()
->description(trans('server/users.permissions.backup_desc'))
->icon('tabler-download')
->schema([
CheckboxList::make('backup')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'delete' => 'Delete',
'download' => 'Download',
'restore' => 'Restore',
])
->descriptions([
'create' => trans('server/users.permissions.backup_create'),
'read' => trans('server/users.permissions.backup_read'),
'delete' => trans('server/users.permissions.backup_delete'),
'download' => trans('server/users.permissions.backup_download'),
'restore' => trans('server/users.permissions.backup_restore'),
]),
]),
]),
Tabs\Tab::make('Allocation')
->schema([
Section::make()
->description(trans('server/users.permissions.allocation_desc'))
->icon('tabler-network')
->schema([
CheckboxList::make('allocation')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.allocation_read'),
'create' => trans('server/users.permissions.allocation_create'),
'update' => trans('server/users.permissions.allocation_update'),
'delete' => trans('server/users.permissions.allocation_delete'),
]),
]),
]),
Tabs\Tab::make('Startup')
->schema([
Section::make()
->description(trans('server/users.permissions.startup_desc'))
->icon('tabler-question-mark')
->schema([
CheckboxList::make('startup')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'update' => 'Update',
'docker-image' => 'Docker Image',
])
->descriptions([
'read' => trans('server/users.permissions.startup_read'),
'update' => trans('server/users.permissions.startup_update'),
'docker-image' => trans('server/users.permissions.startup_docker_image'),
]),
]),
]),
Tabs\Tab::make('Database')
->schema([
Section::make()
->description(trans('server/users.permissions.database_desc'))
->icon('tabler-database')
->schema([
CheckboxList::make('database')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'view_password' => 'View Password',
])
->descriptions([
'read' => trans('server/users.permissions.database_read'),
'create' => trans('server/users.permissions.database_create'),
'update' => trans('server/users.permissions.database_update'),
'delete' => trans('server/users.permissions.database_delete'),
'view_password' => trans('server/users.permissions.database_view_password'),
]),
]),
]),
Tabs\Tab::make('Schedule')
->schema([
Section::make()
->description(trans('server/users.permissions.schedule_desc'))
->icon('tabler-clock')
->schema([
CheckboxList::make('schedule')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.schedule_read'),
'create' => trans('server/users.permissions.schedule_create'),
'update' => trans('server/users.permissions.schedule_update'),
'delete' => trans('server/users.permissions.schedule_delete'),
]),
]),
]),
Tabs\Tab::make('Settings')
->schema([
Section::make()
->description(trans('server/users.permissions.settings_desc'))
->icon('tabler-settings')
->schema([
CheckboxList::make('settings')
->bulkToggleable()
->label('')
->columns(2)
->options([
'rename' => 'Rename',
'reinstall' => 'Reinstall',
'activity' => 'Activity',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.setting_activity'),
]),
]),
]),
]),
]),
])
->modalHeading('Invite User')
->modalSubmitActionLabel('Invite')
->action(function (array $data, SubuserCreationService $service) use ($server) {
$email = $data['email'];
if (in_array('console', $data['control'])) {
$data['websocket'][0] = 'connect';
}
$permissions = collect($data)->forget('email')->map(fn ($permissions, $key) => collect($permissions)->map(fn ($permission) => "$key.$permission"))->flatten()->all();
$service->handle($server, $email, $permissions);
Notification::make()
->title('User Invited!')
->success()
->send();
return redirect(self::getUrl(tenant: $server));
}),
];
}
public function getBreadcrumbs(): array
{
return [];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Filament\Server\Widgets;
use App\Models\Server;
use App\Models\User;
use Filament\Widgets\Widget;
use Illuminate\Support\Arr;
use Livewire\Attributes\On;
class ServerConsole extends Widget
{
protected static string $view = 'filament.components.server-console';
protected int|string|array $columnSpan = 'full';
protected static bool $isLazy = false;
public ?Server $server = null;
public ?User $user = null;
public array $history = [];
public int $historyIndex = 0;
public string $input = '';
public function up(): void
{
$this->historyIndex = min($this->historyIndex + 1, count($this->history) - 1);
$this->input = $this->history[$this->historyIndex] ?? '';
}
public function down(): void
{
$this->historyIndex = max($this->historyIndex - 1, -1);
$this->input = $this->history[$this->historyIndex] ?? '';
}
public function enter(): void
{
if (!empty($this->input)) {
$this->dispatch('sendServerCommand', command: $this->input);
$this->history = Arr::prepend($this->history, $this->input);
$this->historyIndex = -1;
$this->input = '';
}
}
#[On('storeStats')]
public function storeStats(string $data): void
{
$data = json_decode($data);
$timestamp = now()->getTimestamp();
foreach ($data as $key => $value) {
$cacheKey = "servers.{$this->server->id}.$key";
$data = cache()->get($cacheKey, []);
$data[$timestamp] = $value;
cache()->put($cacheKey, $data, now()->addMinute());
}
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
class ServerCpuChart extends ChartWidget
{
protected static ?string $pollingInterval = '1s';
protected static ?string $maxHeight = '300px';
public ?Server $server = null;
protected function getData(): array
{
$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'),
])
->all();
return [
'datasets' => [
[
'data' => array_column($cpu, 'cpu'),
'backgroundColor' => [
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
],
'labels' => array_column($cpu, 'timestamp'),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): RawJs
{
return RawJs::make(<<<'JS'
{
scales: {
y: {
min: 0,
},
x: {
display: false,
}
},
plugins: {
legend: {
display: false,
}
}
}
JS);
}
public function getHeading(): string
{
$cpu = Number::format(collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))->last() ?? 0, maxPrecision: 2, locale: auth()->user()->language) . '%';
$max = Number::format($this->server->cpu, locale: auth()->user()->language) . '%';
return 'CPU - ' . $cpu . ($this->server->cpu > 0 ? ' Of ' . $max : '');
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
class ServerMemoryChart extends ChartWidget
{
protected static ?string $pollingInterval = '1s';
protected static ?string $maxHeight = '300px';
public ?Server $server = null;
protected function getData(): array
{
$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'),
])
->all();
return [
'datasets' => [
[
'data' => array_column($memUsed, 'memory'),
'backgroundColor' => [
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
],
'labels' => array_column($memUsed, 'timestamp'),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): RawJs
{
return RawJs::make(<<<'JS'
{
scales: {
y: {
min: 0,
},
x: {
display: false,
}
},
plugins: {
legend: {
display: false,
}
}
}
JS);
}
public function getHeading(): string
{
$latestMemoryUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))->last() ?? 0;
$totalMemory = collect(cache()->get("servers.{$this->server->id}.memory_limit_bytes"))->last() ?? 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($this->server->memory / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($this->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 'Memory - ' . $used . ($this->server->memory > 0 ? ' Of ' . $total : '');
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
class ServerNetworkChart extends ChartWidget
{
protected static ?string $heading = 'Network';
protected static ?string $pollingInterval = '1s';
protected static ?string $maxHeight = '300px';
public ?Server $server = null;
protected function getData(): array
{
$data = cache()->get("servers.{$this->server->id}.network");
$rx = collect($data)
->slice(-10)
->map(fn ($value, $key) => [
'rx' => $value->rx_bytes,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
$tx = collect($data)
->slice(-10)
->map(fn ($value, $key) => [
'tx' => $value->rx_bytes,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
return [
'datasets' => [
[
'label' => 'Inbound',
'data' => array_column($rx, 'rx'),
'backgroundColor' => [
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
[
'label' => 'Outbound',
'data' => array_column($tx, 'tx'),
'backgroundColor' => [
'rgba(165, 96, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
],
'labels' => array_column($rx, 'timestamp'),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): RawJs
{
return RawJs::make(<<<'JS'
{
scales: {
x: {
grid: {
display: false,
},
ticks: {
display: true,
},
display: false, //debug
},
y: {
ticks: {
display: true,
},
},
}
}
JS);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\CarbonInterface;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Str;
class ServerOverview extends StatsOverviewWidget
{
protected static ?string $pollingInterval = '1s';
public ?Server $server = null;
protected function getStats(): array
{
return [
Stat::make('Name', $this->server->name)
->description($this->server->description),
Stat::make('Status', Str::title($this->server->condition)),
Stat::make('Uptime', $this->uptime()),
];
}
private function uptime(): string
{
$uptime = collect(cache()->get("servers.{$this->server->id}.uptime"))->last() ?? 0;
if ($uptime === 0) {
return 'Offline';
}
return now()->subMillis($uptime)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 2);
}
}

View File

@ -3,17 +3,16 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Models\User;
use App\Notifications\RemovedFromServer;
use App\Services\Subusers\SubuserDeletionService;
use App\Services\Subusers\SubuserUpdateService;
use Illuminate\Http\Request;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use App\Facades\Activity;
use App\Models\Permission;
use App\Services\Subusers\SubuserCreationService;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Transformers\Api\Client\SubuserTransformer;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest;
use App\Http\Requests\Api\Client\Servers\Subusers\StoreSubuserRequest;
use App\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest;
@ -26,7 +25,8 @@ class SubuserController extends ClientApiController
*/
public function __construct(
private SubuserCreationService $creationService,
private DaemonServerRepository $serverRepository
private SubuserUpdateService $updateService,
private SubuserDeletionService $deletionService
) {
parent::__construct();
}
@ -89,40 +89,7 @@ class SubuserController extends ClientApiController
/** @var \App\Models\Subuser $subuser */
$subuser = $request->attributes->get('subuser');
$permissions = $this->getDefaultPermissions($request);
$current = $subuser->permissions;
sort($permissions);
sort($current);
$log = Activity::event('server:subuser.update')
->subject($subuser->user)
->property([
'email' => $subuser->user->email,
'old' => $current,
'new' => $permissions,
'revoked' => true,
]);
// Only update the database and hit up the daemon instance to invalidate JTI's if the permissions
// have actually changed for the user.
if ($permissions !== $current) {
$log->transaction(function ($instance) use ($request, $subuser, $server) {
$subuser->update(['permissions' => $this->getDefaultPermissions($request)]);
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
} catch (DaemonConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance. Chances are it is
// offline and the token will be invalid once daemon boots back.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
$instance->property('revoked', false);
}
});
}
$log->reset();
$this->updateService->handle($subuser, $server, $this->getDefaultPermissions($request));
return $this->fractal->item($subuser->refresh())
->transformWith($this->getTransformer(SubuserTransformer::class))
@ -137,28 +104,7 @@ class SubuserController extends ClientApiController
/** @var \App\Models\Subuser $subuser */
$subuser = $request->attributes->get('subuser');
$log = Activity::event('server:subuser.delete')
->subject($subuser->user)
->property('email', $subuser->user->email)
->property('revoked', true);
$log->transaction(function ($instance) use ($server, $subuser) {
$subuser->delete();
$subuser->user->notify(new RemovedFromServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
]));
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
} catch (DaemonConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
$instance->property('revoked', false);
}
});
$this->deletionService->handle($subuser, $server);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}

View File

@ -20,6 +20,11 @@ class ServerSubject
public function handle(Request $request, \Closure $next): Response
{
$server = $request->route()->parameter('server');
if ($request->route()->hasParameter('tenant')) {
$server = Server::find($request->route()->parameter('tenant'));
}
if ($server instanceof Server) {
LogTarget::setActor($request->user());
LogTarget::setSubject($server);

View File

@ -105,7 +105,7 @@ class Allocation extends Model
protected function address(): Attribute
{
return Attribute::make(
get: fn () => "$this->ip:$this->port",
get: fn () => "$this->alias:$this->port",
);
}

177
app/Models/File.php Normal file
View File

@ -0,0 +1,177 @@
<?php
namespace App\Models;
use App\Repositories\Daemon\DaemonFileRepository;
use Carbon\Carbon;
use Exception;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder;
use Sushi\Sushi;
/**
* @property string $name
* @property Carbon $created_at
* @property Carbon $modified_at
* @property string $mode
* @property int $mode_bits
* @property int $size
* @property bool $is_directory
* @property bool $is_file
* @property bool $is_symlink
* @property string $mime_type
*/
class File extends Model
{
use Sushi;
protected $primaryKey = 'name';
public $incrementing = false;
protected $keyType = 'string';
public const ARCHIVE_MIMES = [
'application/vnd.rar', // .rar
'application/x-rar-compressed', // .rar (2)
'application/x-tar', // .tar
'application/x-br', // .tar.br
'application/x-bzip2', // .tar.bz2, .bz2
'application/gzip', // .tar.gz, .gz
'application/x-gzip',
'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct)
'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct)
'application/x-xz', // .tar.xz, .xz
'application/x-7z-compressed', // .7z
'application/zstd', // .tar.zst, .zst
'application/zip', // .zip
];
protected static Server $server;
protected static string $path;
protected static ?string $searchTerm;
public static function get(Server $server, string $path = '/', ?string $searchTerm = null): Builder
{
self::$server = $server;
self::$path = $path;
self::$searchTerm = $searchTerm;
return self::query();
}
public function isArchive(): bool
{
return $this->is_file && in_array($this->mime_type, self::ARCHIVE_MIMES);
}
public function isImage(): bool
{
return preg_match('/^image\/(?!svg\+xml)/', $this->mime_type);
}
public function getIcon(): string
{
if ($this->is_directory) {
return 'tabler-folder';
}
if ($this->isArchive()) {
return 'tabler-file-zip';
}
if ($this->isImage()) {
return 'tabler-photo';
}
return $this->is_symlink ? 'tabler-file-symlink' : 'tabler-file';
}
public function canEdit(): bool
{
if ($this->is_directory || $this->isArchive() || $this->is_symlink || $this->isImage()) {
return false;
}
return $this->is_file && !in_array($this->mime_type, ['application/jar', 'application/octet-stream', 'inode/directory']);
}
public function server(): Server
{
return self::$server;
}
protected function casts(): array
{
return [
'created_at' => 'datetime',
'modified_at' => 'datetime',
];
}
public function getSchema(): array
{
return [
'name' => 'string',
'created_at' => 'string',
'modified_at' => 'string',
'mode' => 'string',
'mode_bits' => 'integer',
'size' => 'integer',
'is_directory' => 'boolean',
'is_file' => 'boolean',
'is_symlink' => 'boolean',
'mime_type' => 'string',
];
}
public function getRows(): array
{
try {
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class)->setServer(self::$server); // @phpstan-ignore-line
if (!is_null(self::$searchTerm)) {
$contents = cache()->remember('file_search_' . self::$path . '_' . self::$searchTerm, now()->addMinute(), fn () => $fileRepository->search(self::$searchTerm, self::$path));
} else {
$contents = $fileRepository->getDirectory(self::$path ?? '/');
}
if (isset($contents['error'])) {
throw new Exception($contents['error']);
}
return array_map(function ($file) {
return [
'name' => $file['name'],
'created_at' => Carbon::parse($file['created']),
'modified_at' => Carbon::parse($file['modified']),
'mode' => $file['mode'],
'mode_bits' => (int) $file['mode_bits'],
'size' => (int) $file['size'],
'is_directory' => $file['directory'],
'is_file' => $file['file'],
'is_symlink' => $file['symlink'],
'mime_type' => $file['mime'],
];
}, $contents);
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Error loading files')
->body($exception->getMessage())
->danger()
->send();
return [];
}
}
protected function sushiShouldCache(): bool
{
return false;
}
}

View File

@ -93,7 +93,7 @@ class Permission extends Model
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
public const ACTION_ACTIVITY_READ = 'activity.read';
public const ACTION_ACTIVITY_READ = 'settings.activity';
/**
* Should timestamps be used on this model.

View File

@ -33,6 +33,9 @@ class Role extends BaseRole
'view',
'update',
],
'activity' => [
'seeIps',
],
];
public function isRootAdmin(): bool

View File

@ -44,7 +44,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @property string $image
* @property int|null $allocation_limit
* @property int|null $database_limit
* @property int $backup_limit
* @property int|null $backup_limit
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $installed_at
@ -287,6 +287,14 @@ class Server extends Model
return $this->hasMany(ServerVariable::class);
}
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);
});
}
/**
* Gets information for the node associated with this server.
*/
@ -358,6 +366,11 @@ class Server extends Model
};
}
public function isInConflictState(): bool
{
return $this->isSuspended() || $this->node->isUnderMaintenance() || !$this->isInstalled() || $this->status === ServerState::RestoringBackup || !is_null($this->transfer);
}
/**
* Checks if the server is currently in a user-accessible state. If not, an
* exception is raised. This should be called whenever something needs to make
@ -367,13 +380,7 @@ class Server extends Model
*/
public function validateCurrentState(): void
{
if (
$this->isSuspended() ||
$this->node->isUnderMaintenance() ||
!$this->isInstalled() ||
$this->status === ServerState::RestoringBackup ||
!is_null($this->transfer)
) {
if ($this->isInConflictState()) {
throw new ServerStateConflictException($this);
}
}

View File

@ -9,7 +9,10 @@ use DateTimeZone;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasName;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\In;
use Illuminate\Auth\Authenticatable;
@ -86,7 +89,7 @@ use Spatie\Permission\Traits\HasRoles;
* @method static Builder|User whereUsername($value)
* @method static Builder|User whereUuid($value)
*/
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAvatar, HasName
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAvatar, HasName, HasTenants
{
use Authenticatable;
use Authorizable {can as protected canned; }
@ -323,6 +326,11 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $this->hasMany(Subuser::class);
}
public function subServers(): BelongsToMany
{
return $this->belongsToMany(Server::class, 'subusers');
}
protected function checkPermission(Server $server, string $permission = ''): bool
{
if ($this->isRootAdmin() || $server->owner_id === $this->id) {
@ -377,7 +385,11 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return true;
}
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1;
if ($panel->getId() === 'admin') {
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1;
}
return true;
}
public function getFilamentName(): string
@ -398,4 +410,24 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $user instanceof User && !$user->isRootAdmin();
}
public function getTenants(Panel $panel): array|Collection
{
return $this->accessibleServers()->get();
}
public function canAccessTenant(IlluminateModel $tenant): bool
{
if ($tenant instanceof Server) {
if ($this->isRootAdmin() || $tenant->owner_id === $this->id) {
return true;
}
$subuser = $tenant->subusers->where('user_id', $this->id)->first();
return $subuser !== null;
}
return false;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Filament\Server\Pages\Console;
use App\Models;
use App\Models\ApiKey;
use App\Models\Node;
@ -11,6 +12,8 @@ use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;
use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Event;
@ -92,6 +95,12 @@ class AppServiceProvider extends ServiceProvider
'warning' => Color::Amber,
]);
FilamentView::registerRenderHook(
PanelsRenderHook::CONTENT_START,
fn () => view('filament.server-conflict-banner'),
scopes: Console::class,
);
Gate::before(function (User $user, $ability) {
return $user->isRootAdmin() ? true : null;
});

View File

@ -0,0 +1,64 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Resources\UserResource\Pages\EditProfile;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AppPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('app')
->path('app')
->spa()
->breadcrumbs(false)
->brandName(config('app.name', 'Pelican'))
->brandLogo(config('app.logo'))
->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', true))
->maxContentWidth(MaxWidth::ScreenTwoExtraLarge)
->profile(EditProfile::class, false)
->login(Login::class)
->userMenuItems([
MenuItem::make()
->label('Admin')
->url('/admin')
->icon('tabler-arrow-forward')
->sort(5)
->visible(fn (): bool => auth()->user()->canAccessPanel(Filament::getPanel('admin'))),
])
->discoverResources(in: app_path('Filament/App/Resources'), for: 'App\\Filament\\App\\Resources')
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Providers\Filament;
use App\Filament\App\Resources\ServerResource\Pages\ListServers;
use App\Filament\Pages\Auth\Login;
use App\Filament\Resources\UserResource\Pages\EditProfile;
use App\Http\Middleware\Activity\ServerSubject;
use App\Models\Server;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ServerPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('server')
->path('app/server')
->homeUrl('/app')
->spa()
->tenant(Server::class)
->brandName(config('app.name', 'Pelican'))
->brandLogo(config('app.logo'))
->brandLogoHeight('2rem')
->favicon(config('app.favicon', '/pelican.ico'))
->topNavigation(config('panel.filament.top-navigation', true))
->maxContentWidth(MaxWidth::ScreenTwoExtraLarge)
->login(Login::class)
->userMenuItems([
'profile' => MenuItem::make()->label('Profile')->url(fn () => EditProfile::getUrl(panel: 'app')),
MenuItem::make()
->label('Server List')
->icon('tabler-brand-docker')
->url(fn () => ListServers::getUrl(panel: 'app'))
->sort(6),
MenuItem::make()
->label('Admin')
->icon('tabler-arrow-forward')
->url(fn () => Filament::getPanel('admin')->getUrl())
->sort(5)
->visible(fn (): bool => auth()->user()->canAccessPanel(Filament::getPanel('admin'))),
])
->discoverResources(in: app_path('Filament/Server/Resources'), for: 'App\\Filament\\Server\\Resources')
->discoverPages(in: app_path('Filament/Server/Pages'), for: 'App\\Filament\\Server\\Pages')
->discoverWidgets(in: app_path('Filament/Server/Widgets'), for: 'App\\Filament\\Server\\Widgets')
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
ServerSubject::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@ -273,4 +273,30 @@ class DaemonFileRepository extends DaemonRepository
throw new DaemonConnectionException($exception);
}
}
/**
* Searches all files in the directory (and its subdirectories) for the given search term.
*
* @throws \App\Exceptions\Http\Connection\DaemonConnectionException
*/
public function search(string $searchTerm, ?string $directory): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient()
->timeout(120)
->get(
sprintf('/api/servers/%s/files/search', $this->server->uuid),
[
'pattern' => $searchTerm,
'directory' => $directory ?? '/',
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
return $response->json();
}
}

View File

@ -9,6 +9,8 @@ use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Request;
use App\Models\ActivityLogSubject;
use App\Models\Server;
use Filament\Facades\Filament;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Contracts\Auth\Factory as AuthFactory;
@ -205,6 +207,10 @@ class ActivityLogService
if ($subject = $this->targetable->subject()) {
$this->subject($subject);
} elseif ($tenant = Filament::getTenant()) {
if ($tenant instanceof Server) {
$this->subject($tenant);
}
}
if ($actor = $this->targetable->actor()) {

View File

@ -44,8 +44,6 @@ class SubuserCreationService
$user = $this->userCreationService->handle([
'email' => $email,
'username' => $username,
'name_first' => 'Server',
'name_last' => 'Subuser',
'root_admin' => false,
]);
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services\Subusers;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Facades\Activity;
use App\Models\Server;
use App\Models\Subuser;
use App\Notifications\RemovedFromServer;
use App\Repositories\Daemon\DaemonServerRepository;
class SubuserDeletionService
{
public function __construct(
private DaemonServerRepository $serverRepository,
) {}
public function handle(Subuser $subuser, Server $server): void
{
$log = Activity::event('server:subuser.delete')
->subject($subuser->user)
->property('email', $subuser->user->email)
->property('revoked', true);
$log->transaction(function ($instance) use ($server, $subuser) {
$subuser->delete();
$subuser->user->notify(new RemovedFromServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
]));
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
} catch (DaemonConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
$instance->property('revoked', false);
}
});
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Services\Subusers;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Facades\Activity;
use App\Models\Server;
use App\Models\Subuser;
use App\Repositories\Daemon\DaemonServerRepository;
class SubuserUpdateService
{
public function __construct(
private DaemonServerRepository $serverRepository,
) {}
public function handle(Subuser $subuser, Server $server, array $permissions): void
{
$current = $subuser->permissions;
sort($permissions);
sort($current);
$log = Activity::event('server:subuser.update')
->subject($subuser->user)
->property([
'email' => $subuser->user->email,
'old' => $current,
'new' => $permissions,
'revoked' => true,
]);
// Only update the database and hit up the daemon instance to invalidate JTI's if the permissions
// have actually changed for the user.
if ($permissions !== $current) {
$log->transaction(function ($instance) use ($subuser, $permissions, $server) {
$subuser->update(['permissions' => $permissions]);
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
} catch (DaemonConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance. Chances are it is
// offline and the token will be invalid once daemon boots back.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
$instance->property('revoked', false);
}
});
}
$log->reset();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Tables\Columns;
use Filament\Tables\Columns\TextColumn;
class BytesColumn extends TextColumn
{
protected function setUp(): void
{
parent::setUp();
$this->formatStateUsing(fn ($state) => $state ? convert_bytes_to_readable($state) : '');
}
}

View File

@ -10,6 +10,19 @@ class DateTimeColumn extends TextColumn
{
parent::setUp();
$this->dateTime(timezone: auth()->user()?->timezone ?? config('app.timezone', 'UTC'));
$this->dateTime();
}
public function since(?string $timezone = null): static
{
$this->formatStateUsing(fn ($state) => $state->diffForHumans());
$this->tooltip(fn ($state) => $state?->timezone($this->getTimezone()));
return $this;
}
public function getTimezone(): string
{
return auth()->user()?->timezone ?? config('app.timezone', 'UTC');
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Tables\Columns;
use Filament\Tables\Columns\Column;
class ServerEntryColumn extends Column
{
protected string $view = 'tables.columns.server-entry-column';
}

View File

@ -17,3 +17,54 @@ if (!function_exists('is_ip')) {
return $address !== null && filter_var($address, FILTER_VALIDATE_IP) !== false;
}
}
if (!function_exists('convert_bytes_to_readable')) {
function convert_bytes_to_readable(int $bytes, int $decimals = 2): string
{
$conversionUnit = config('panel.use_binary_prefix') ? 1024 : 1000;
$suffix = config('panel.use_binary_prefix') ? ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'] : ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if ($bytes <= 0) {
return '0 ' . $suffix[0];
}
$base = log($bytes) / log($conversionUnit);
$f_base = floor($base);
return round(pow($conversionUnit, $base - $f_base), $decimals) . ' ' . $suffix[$f_base];
}
}
if (!function_exists('join_paths')) {
function join_paths(string $base, string ...$paths): string
{
if ($base === '/') {
return str_replace('//', '', implode('/', $paths));
}
return str_replace('//', '', $base . '/' . implode('/', $paths));
}
}
if (!function_exists('resolve_path')) {
function resolve_path(string $path): string
{
// @phpstan-ignore-next-line
$parts = array_filter(explode('/', $path), 'strlen');
$absolutes = [];
foreach ($parts as $part) {
if ($part == '.') {
continue;
}
if ($part == '..') {
array_pop($absolutes);
} else {
$absolutes[] = $part;
}
}
return implode('/', $absolutes);
}
}

View File

@ -6,6 +6,8 @@ return [
App\Providers\BackupsServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\AppPanelProvider::class,
App\Providers\Filament\ServerPanelProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\ViewComposerServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,

View File

@ -8,8 +8,9 @@
"ext-mbstring": "*",
"ext-pdo": "*",
"ext-zip": "*",
"abdelhamiderrahmouni/filament-monaco-editor": "0.2.1",
"abdelhamiderrahmouni/filament-monaco-editor": "^0.2.5",
"aws/aws-sdk-php": "~3.288.1",
"calebporzio/sushi": "^2.5",
"chillerlan/php-qrcode": "^5.0.2",
"coderflex/filament-turnstile": "^2.2",
"dedoc/scramble": "^0.10.0",

66
composer.lock generated
View File

@ -8,16 +8,16 @@
"packages": [
{
"name": "abdelhamiderrahmouni/filament-monaco-editor",
"version": "v0.2.1",
"version": "v0.2.5",
"source": {
"type": "git",
"url": "https://github.com/abdelhamiderrahmouni/filament-monaco-editor.git",
"reference": "3a74de11f74bee5b782df74c998c071c64a17ac0"
"reference": "19ee073a593fe02865d8cfcad18db996f21b5fc5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/abdelhamiderrahmouni/filament-monaco-editor/zipball/3a74de11f74bee5b782df74c998c071c64a17ac0",
"reference": "3a74de11f74bee5b782df74c998c071c64a17ac0",
"url": "https://api.github.com/repos/abdelhamiderrahmouni/filament-monaco-editor/zipball/19ee073a593fe02865d8cfcad18db996f21b5fc5",
"reference": "19ee073a593fe02865d8cfcad18db996f21b5fc5",
"shasum": ""
},
"require": {
@ -88,7 +88,7 @@
"type": "github"
}
],
"time": "2024-04-20T01:01:49+00:00"
"time": "2024-05-16T10:37:32+00:00"
},
{
"name": "anourvalar/eloquent-serialize",
@ -515,6 +515,60 @@
],
"time": "2023-11-29T23:19:16+00:00"
},
{
"name": "calebporzio/sushi",
"version": "v2.5.2",
"source": {
"type": "git",
"url": "https://github.com/calebporzio/sushi.git",
"reference": "01dd34fe3374f5fb7ce63756c0419385e31cd532"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/calebporzio/sushi/zipball/01dd34fe3374f5fb7ce63756c0419385e31cd532",
"reference": "01dd34fe3374f5fb7ce63756c0419385e31cd532",
"shasum": ""
},
"require": {
"ext-pdo_sqlite": "*",
"ext-sqlite3": "*",
"illuminate/database": "^5.8 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0",
"illuminate/support": "^5.8 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0",
"php": "^7.1.3|^8.0"
},
"require-dev": {
"doctrine/dbal": "^2.9 || ^3.1.4",
"orchestra/testbench": "3.8.* || 3.9.* || ^4.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0",
"phpunit/phpunit": "^7.5 || ^8.4 || ^9.0 || ^10.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Sushi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "Eloquent's missing \"array\" driver.",
"support": {
"source": "https://github.com/calebporzio/sushi/tree/v2.5.2"
},
"funding": [
{
"url": "https://github.com/calebporzio",
"type": "github"
}
],
"time": "2024-04-24T15:23:03+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
"version": "1.0.0",
@ -13702,5 +13756,5 @@
"ext-zip": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.3.0"
}

View File

@ -2,15 +2,15 @@
return [
'general' => [
'enable-preview' => true,
'enable-preview' => false,
'show-full-screen-toggle' => true,
'show-placeholder' => true,
'placeholder-text' => 'Your code here...',
'show-loader' => true,
'font-size' => '15px',
'font-size' => '16px',
'line-numbers-min-chars' => true,
'automatic-layout' => true,
'default-theme' => 'iPlastic',
'default-theme' => 'blackboard',
],
'themes' => [
'blackboard' => [

7
config/livewire.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'temporary_file_upload' => [
'rules' => 'file',
],
];

View File

@ -22,7 +22,7 @@ return [
],
'user' => [
'account' => [
'email-changed' => 'Changed email from :old to :new',
'email-changed' => 'Changed email from <b>:old</b> to <b>:new</b>',
'password-changed' => 'Changed password',
],
'api-key' => [
@ -50,28 +50,28 @@ return [
'kill' => 'Killed the server process',
],
'backup' => [
'download' => 'Downloaded the :name backup',
'delete' => 'Deleted the :name backup',
'restore' => 'Restored the :name backup (deleted files: :truncate)',
'restore-complete' => 'Completed restoration of the :name backup',
'restore-failed' => 'Failed to complete restoration of the :name backup',
'start' => 'Started a new backup :name',
'complete' => 'Marked the :name backup as complete',
'fail' => 'Marked the :name backup as failed',
'lock' => 'Locked the :name backup',
'unlock' => 'Unlocked the :name backup',
'download' => 'Downloaded the <b>:name</b> backup',
'delete' => 'Deleted the <b>:name</b> backup',
'restore' => 'Restored the <b>:name</b> backup (deleted files: :truncate)',
'restore-complete' => 'Completed restoration of the <b>:name</b> backup',
'restore-failed' => 'Failed to complete restoration of the <b>:name</b> backup',
'start' => 'Started a new backup <b>:name</b>',
'complete' => 'Marked the <b>:name</b> backup as complete',
'fail' => 'Marked the <b>:name</b> backup as failed',
'lock' => 'Locked the <b>:name</b> backup',
'unlock' => 'Unlocked the <b>:name</b> backup',
],
'database' => [
'create' => 'Created new database :name',
'rotate-password' => 'Password rotated for database :name',
'delete' => 'Deleted database :name',
'create' => 'Created new database <b>:name</b>',
'rotate-password' => 'Password rotated for database <b>:name</b>',
'delete' => 'Deleted database <b>:name</b>',
],
'file' => [
'compress_one' => 'Compressed :directory:file',
'compress_other' => 'Compressed :count files in :directory',
'read' => 'Viewed the contents of :file',
'copy' => 'Created a copy of :file',
'create-directory' => 'Created directory :directory:name',
'create-directory' => 'Created directory :directory<b>:name</b>',
'decompress' => 'Decompressed :files in :directory',
'delete_one' => 'Deleted :directory:files.0',
'delete_other' => 'Deleted :count files in :directory',
@ -98,33 +98,33 @@ return [
],
'allocation' => [
'create' => 'Added :allocation to the server',
'notes' => 'Updated the notes for :allocation from ":old" to ":new"',
'notes' => 'Updated the notes for :allocation from "<b>:old</b>" to "<b>:new</b>"',
'primary' => 'Set :allocation as the primary server allocation',
'delete' => 'Deleted the :allocation allocation',
],
'schedule' => [
'create' => 'Created the :name schedule',
'update' => 'Updated the :name schedule',
'execute' => 'Manually executed the :name schedule',
'delete' => 'Deleted the :name schedule',
'create' => 'Created the <b>:name</b> schedule',
'update' => 'Updated the <b>:name</b> schedule',
'execute' => 'Manually executed the <b>:name</b> schedule',
'delete' => 'Deleted the <b>:name</b> schedule',
],
'task' => [
'create' => 'Created a new ":action" task for the :name schedule',
'update' => 'Updated the ":action" task for the :name schedule',
'delete' => 'Deleted a task for the :name schedule',
'create' => 'Created a new "<b>:action</b>" task for the <b>:name</b> schedule',
'update' => 'Updated the "<b>:action</b>" task for the <b>:name</b> schedule',
'delete' => 'Deleted a task for the <b>:name</b> schedule',
],
'settings' => [
'rename' => 'Renamed the server from :old to :new',
'description' => 'Changed the server description from :old to :new',
'rename' => 'Renamed the server from <b>:old</b> to <b>:new</b>',
'description' => 'Changed the server description from <b>:old</b> to <b>:new</b>',
],
'startup' => [
'edit' => 'Changed the :variable variable from ":old" to ":new"',
'image' => 'Updated the Docker Image for the server from :old to :new',
'edit' => 'Changed the <b>:variable</b> variable from "<b>:old</b>" to "<b>:new</b>"',
'image' => 'Updated the Docker Image for the server from <b>:old</b> to <b>:new</b>',
],
'subuser' => [
'create' => 'Added :email as a subuser',
'update' => 'Updated the subuser permissions for :email',
'delete' => 'Removed :email as a subuser',
'create' => 'Added <b>:email</b> as a subuser',
'update' => 'Updated the subuser permissions for <b>:email</b>',
'delete' => 'Removed <b>:email</b> as a subuser',
],
],
];

View File

@ -2,26 +2,44 @@
return [
'permissions' => [
'websocket_*' => 'Allows access to the websocket for this server.',
'control_console' => 'Allows the user to send data to the server console.',
'control_start' => 'Allows the user to start the server instance.',
'control_stop' => 'Allows the user to stop the server instance.',
'control_restart' => 'Allows the user to restart the server instance.',
'control_kill' => 'Allows the user to kill the server instance.',
'user_create' => 'Allows the user to create new user accounts for the server.',
'user_read' => 'Allows the user permission to view users associated with this server.',
'user_update' => 'Allows the user to modify other users associated with this server.',
'user_delete' => 'Allows the user to delete other users associated with this server.',
'file_create' => 'Allows the user permission to create new files and directories.',
'file_read' => 'Allows the user to see files and folders associated with this server instance, as well as view their contents.',
'file_update' => 'Allows the user to update files and folders associated with the server.',
'file_delete' => 'Allows the user to delete files and directories.',
'file_archive' => 'Allows the user to create file archives and decompress existing archives.',
'file_sftp' => 'Allows the user to perform the above file actions using a SFTP client.',
'allocation_read' => 'Allows access to the server allocation management pages.',
'allocation_update' => 'Allows user permission to make modifications to the server\'s allocations.',
'database_create' => 'Allows user permission to create a new database for the server.',
'database_read' => 'Allows user permission to view the server databases.',
'startup_desc' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'settings_desc' => 'Permissions that control a user\'s access to the schedule management for this server.',
'control_desc' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.',
'user_desc' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.',
'file_desc' => 'Permissions that control a user\'s ability to modify the filesystem for this server.',
'allocation_desc' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
'database_desc' => 'Permissions that control a user\'s access to the database management for this server.',
'backup_desc' => 'Permissions that control a user\'s ability to generate and manage server backups.',
'schedule_desc' => 'Permissions that control a user\'s access to the schedule management for this server.',
'startup_read' => 'Allows a user to view the startup variables for a server.',
'startup_update' => 'Allows a user to modify the startup variables for the server.',
'startup_docker_image' => 'Allows a user to modify the Docker image used when running the server.',
'setting_reinstall' => 'Allows a user to trigger a reinstall of this server.',
'setting_rename' => 'Allows a user to rename this server and change the description of it.',
'setting_activity' => 'Allows a user to view the activity logs for the server.',
'websocket_*' => 'Allows a user access to the websocket for this server.',
'control_console' => 'Allows a user to send data to the server console.',
'control_start' => 'Allows a user to start the server instance.',
'control_stop' => 'Allows a user to stop the server instance.',
'control_restart' => 'Allows a user to restart the server instance.',
'control_kill' => 'Allows a user to kill the server instance.',
'user_create' => 'Allows a user to create new user accounts for the server.',
'user_read' => 'Allows a user permission to view users associated with this server.',
'user_update' => 'Allows a user to modify other users associated with this server.',
'user_delete' => 'Allows a user to delete other users associated with this server.',
'file_create' => 'Allows a user permission to create new files and directories.',
'file_read' => 'Allows a user to view the contents of a directory, but not view the contents of or download files.',
'file_read_content' => 'Allows a user to view the contents of a given file. This will also allow the user to download files.',
'file_update' => 'Allows a user to update files and folders associated with the server.',
'file_delete' => 'Allows a user to delete files and directories.',
'file_archive' => 'Allows a user to create file archives and decompress existing archives.',
'file_sftp' => 'Allows a user to perform the above file actions using a SFTP client.',
'allocation_read' => 'Allows a user to view all allocations currently assigned to this server. Users with any level of access to this server can always view the primary allocation.',
'allocation_update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.',
'allocation_delete' => 'Allows a user to delete an allocation from the server.',
'allocation_create' => 'Allows a user to assign additional allocations to the server.',
'database_create' => 'Allows a user permission to create a new database for the server.',
'database_read' => 'Allows a user permission to view the server databases.',
'database_update' => 'Allows a user permission to make modifications to a database. If the user does not have the "View Password" permission as well they will not be able to modify the password.',
'database_delete' => 'Allows a user permission to delete a database instance.',
'database_view_password' => 'Allows a user permission to view a database password in the system.',
@ -29,5 +47,10 @@ return [
'schedule_read' => 'Allows a user permission to view schedules for a server.',
'schedule_update' => 'Allows a user permission to make modifications to an existing server schedule.',
'schedule_delete' => 'Allows a user to delete a schedule for the server.',
'backup_create' => 'Allows a user to create new backups for this server.',
'backup_read' => 'Allows a user to view all backups that exist for this server.',
'backup_delete' => 'Allows a user to remove backups from the system.',
'backup_download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'backup_restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
],
];

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { useState } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCogs, faLayerGroup, faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
import { faCogs, faHandSparkles, faLayerGroup, faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
import { useStoreState } from 'easy-peasy';
import { useTranslation } from 'react-i18next';
import { ApplicationStore } from '@/state';
@ -64,6 +64,11 @@ export default () => {
</div>
<RightNavigation className={'flex h-full items-center justify-center'}>
<SearchContainer />
<Tooltip placement={'bottom'} content={'New Client Area'}>
<NavLink to={'/app/'} target={'_blank'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faHandSparkles} />
</NavLink>
</Tooltip>
<Tooltip placement={'bottom'} content={t<string>('dashboard')}>
<NavLink to={'/'} exact>
<FontAwesomeIcon icon={faLayerGroup} />

View File

@ -0,0 +1,134 @@
<x-filament::widget>
@assets
<script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.5.0/xterm.js" integrity="sha512-Gujw5GajF5is3nMoGv9X+tCMqePLL/60qvAv1LofUZTV9jK8ENbM9L+maGmOsNzuZaiuyc/fpph1KT9uR5w3CQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.5.0/xterm.css" integrity="sha512-AbNrj/oSHJaILgcdnkYm+DQ08SqVbZ8jlkJbFyyS1WDcAaXAcAfxJnCH69el7oVgTwVwyA5u5T+RdFyUykrV3Q==" crossorigin="anonymous" referrerpolicy="no-referrer" />
@endassets
<div id="terminal" wire:ignore></div>
<div>
<input
class="w-full bg-transparent"
type="text"
placeholder="Type a command..."
wire:model="input"
wire:keydown.enter="enter"
wire:keydown.up.prevent="up"
wire:keydown.down="down"
>
</div>
@script
<script>
let options = {
fontSize: 18,
// fontFamily: th('fontFamily.mono'),
disableStdin: true,
cursorStyle: 'underline',
allowTransparency: true,
rows: 35,
cols: 110,
// theme: theme,
};
const terminal = new Terminal(options);
// TODO: load addons
terminal.open(document.getElementById('terminal'));
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mpelican@' + '{{ \Filament\Facades\Filament::getTenant()->name }}' + ' ~ \u001b[0m';
const handleConsoleOutput = (line, prelude = false) =>
terminal.writeln((prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m');
const handleTransferStatus = (status) => {
switch (status) {
// Sent by either the source or target node if a failure occurs.
case 'failure':
terminal.writeln(TERMINAL_PRELUDE + 'Transfer has failed.\u001b[0m');
return;
}
};
const handleDaemonErrorOutput = (line) =>
terminal.writeln(
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m'
);
const handlePowerChangeEvent = (state) =>
terminal.writeln(TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m');
@php
if ($user->cannot(\App\Models\Permission::ACTION_WEBSOCKET_CONNECT, $server)) {
throw new \App\Exceptions\Http\HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
$permissions = app(\App\Services\Servers\GetUserPermissionsService::class)->handle($server, $user);
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress());
$socket .= sprintf('/api/servers/%s/ws', $server->uuid);
$token = app(\App\Services\Nodes\NodeJWTService::class)
->setExpiresAt(now()->addMinutes(10)->toImmutable())
->setUser($user)
->setClaims([
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->handle($server->node, $user->id . $server->uuid);
@endphp
const socket = new WebSocket("{{ $socket }}");
const token = '{{ $token->toString() }}';
socket.onmessage = function(websocketMessageEvent) {
let eventData = JSON.parse(websocketMessageEvent.data);
if (eventData.event === 'console output' || eventData.event === 'install output') {
handleConsoleOutput(eventData.args[0]);
}
if (eventData.event === 'status') {
handlePowerChangeEvent(eventData.args[0]);
}
if (eventData.event === 'daemon error') {
handleDaemonErrorOutput(eventData.args[0]);
}
if (eventData.event === 'stats') {
$wire.dispatchSelf('storeStats', { data: eventData.args[0] });
}
if (eventData.event === 'auth success') {
socket.send(JSON.stringify({
'event': 'send logs',
'args': [null]
}));
}
// TODO: handle "token expiring" and "token expired"
};
socket.onopen = (event) => {
socket.send(JSON.stringify({
'event': 'auth',
'args': [token]
}));
};
Livewire.on('setServerState', ({ state }) => {
socket.send(JSON.stringify({
'event': 'set state',
'args': [state]
}));
});
Livewire.on('sendServerCommand', ({ command }) => {
socket.send(JSON.stringify({
'event': 'send command',
'args': [command]
}));
});
</script>
@endscript
</x-filament::widget>

View File

@ -14,6 +14,14 @@
automaticLayout: {{ (int) $getAutomaticLayout() }},
monacoId: $id('monaco-editor'),
toggleFullScreenMode() {
this.fullScreenModeEnabled = !this.fullScreenModeEnabled;
this.fullScreenModeEnabled ? document.body.classList.add('overflow-hidden')
: document.body.classList.remove('overflow-hidden');
$el.style.width = this.fullScreenModeEnabled ? '100vw'
: $el.parentElement.clientWidth + 'px';
},
monacoEditor(editor){
editor.onDidChangeModelContent((e) => {
this.monacoContent = editor.getValue();
@ -45,11 +53,21 @@
monacoEditorAddLoaderScriptToHead() {
script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min/vs/loader.min.js';
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs/loader.min.js';
document.head.appendChild(script);
},
wrapPreview(value){
return `<head>{{ $getPreviewHeadEndContent() }}</head>` +
`&lt;body {{ $getPreviewBodyAttributes() }}&gt;` +
`{{ $getPreviewBodyStartContent() }}` +
`${value}` +
`{{ $getPreviewBodyEndContent() }}` +
`&lt;/body&gt;`;
},
}" x-init="
previewContent = wrapPreview(monacoContent);
$el.style.height = '500px';
$watch('fullScreenModeEnabled', value => {
if (value) {
@ -67,8 +85,8 @@
if(typeof _amdLoaderGlobal !== 'undefined'){
// Based on https://jsfiddle.net/developit/bwgkr6uq/ which works without needing service worker. Provided by loader.min.js.
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min/vs' }});
let proxy = URL.createObjectURL(new Blob([` self.MonacoEnvironment = { baseUrl: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min' }; importScripts('https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min/vs/base/worker/workerMain.min.js');`], { type: 'text/javascript' }));
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs' }});
let proxy = URL.createObjectURL(new Blob([` self.MonacoEnvironment = { baseUrl: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min' }; importScripts('https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs/base/worker/workerMain.min.js');`], { type: 'text/javascript' }));
window.MonacoEnvironment = { getWorkerUrl: () => proxy };
require(['vs/editor/editor.main'], () => {
@ -83,7 +101,9 @@
language: monacoLanguage,
scrollbar: {
horizontal: 'auto',
horizontalScrollbarSize: 15
horizontalScrollbarSize: 15,
vertical: 'auto',
verticalScrollbarSize: 15
},
});
@ -100,14 +120,20 @@
}, 5); " :id="monacoId"
class="fme-wrapper"
:class="{ 'fme-full-screen': fullScreenModeEnabled }" x-cloak>
<div class="h-full w-full">
<div class="fme-container" style="padding-top: 0">
<div class="flex items-center ml-auto">
@if($getShowFullScreenToggle())
<button type="button" aria-label="{{ __("full_screen_btn_label") }}" class="fme-full-screen-btn" @click="toggleFullScreenMode()">
<svg class="fme-full-screen-btn-icon" x-show="!fullScreenModeEnabled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16 4l4 0l0 4" /><path d="M14 10l6 -6" /><path d="M8 20l-4 0l0 -4" /><path d="M4 20l6 -6" /><path d="M16 20l4 0l0 -4" /><path d="M14 14l6 6" /><path d="M8 4l-4 0l0 4" /><path d="M4 4l6 6" /></svg>
<svg class="fme-full-screen-btn-icon" x-show="fullScreenModeEnabled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 9l4 0l0 -4" /><path d="M3 3l6 6" /><path d="M5 15l4 0l0 4" /><path d="M3 21l6 -6" /><path d="M19 9l-4 0l0 -4" /><path d="M15 9l6 -6" /><path d="M19 15l-4 0l0 4" /><path d="M15 15l6 6" /></svg>
</button>
@endif
</div>
<div class="fme-container" x-show="!showPreview">
<!-- Editor -->
<div x-show="!monacoLoader" class="fme-element-wrapper">
<div x-ref="monacoEditorElement" class="fme-element"></div>
<div x-ref="monacoEditorElement" class="fme-element" wire:ignore style="height: 100%"></div>
<div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()" :style="'font-size: ' + monacoFontSize" class="fme-placeholder" x-text="monacoPlaceholderText"></div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
@php
$shouldShow = false;
try {
\Filament\Facades\Filament::getTenant()->validateCurrentState();
} catch (\App\Exceptions\Http\Server\ServerStateConflictException $exception) {
$shouldShow = true;
$message = $exception->getMessage();
}
@endphp
@if ($shouldShow)
<div class="mt-2 p-2 rounded-lg text-white" style="background-color: #D97706;">
<div class="flex items-center">
<x-filament::icon icon="tabler-alert-triangle" class="h-6 w-6 mr-2 text-gray-500 dark:text-gray-400 text-white" />
<p>{!! $message !!}</p>
</div>
</div>
@endif

View File

@ -0,0 +1,7 @@
<x-filament-panels::page class="fi-console-page">
<x-filament-widgets::widgets
:columns="$this->getColumns()"
:data="$this->getWidgetData()"
:widgets="$this->getVisibleWidgets()"
/>
</x-filament-panels::page>

View File

@ -0,0 +1,11 @@
<x-filament-panels::page class="fi-resource-edit-file-page">
<x-filament-panels::form
id="form"
:wire:key="$this->getId() . '.forms.' . $this->getFormStatePath()"
wire:submit="save"
>
{{ $this->form }}
</x-filament-panels::form>
<x-filament-panels::page.unsaved-data-changes-alert />
</x-filament-panels::page>

View File

@ -0,0 +1,9 @@
<x-filament-panels::page>
<x-filament-panels::form
id="form"
:wire:key="$this->getId() . '.forms.' . $this->getFormStatePath()"
wire:submit="save"
>
{{ $this->form }}
</x-filament-panels::form>
</x-filament-panels::page>

View File

@ -0,0 +1,37 @@
<div class="w-full grid gap-y-2">
<div class="flex items-center gap-x-2">
<x-filament::icon-button
:icon="$getRecord()->conditionIcon()"
:color="$getRecord()->conditionColor()"
:tooltip="\Illuminate\Support\Str::title($getRecord()->condition)" size="xl"
/>
<span class="text-2xl font-semibold text-gray-500 dark:text-gray-400">
{{ $getRecord()->name }} ({{ $this->uptime($getRecord()) }})
</span>
</div>
<div class="flex">
<div class="flex-1 text-sm font-medium text-gray-850 dark:text-white">
<strong>Allocation:</strong> {{ $getRecord()->allocation->address }}
</div>
<div class="flex-1 text-sm font-medium text-gray-850 dark:text-white">
<strong>Egg:</strong> {{ $getRecord()->egg->name }}
</div>
<div class="flex-1 text-sm font-medium text-gray-850 dark:text-white">
<strong>Owner:</strong> {{ $getRecord()->user->username }}
</div>
</div>
<div class="flex">
<div class="flex-1 text-sm font-medium text-gray-850 dark:text-white">
<strong>CPU:</strong> {{ $this->cpu($getRecord()) }}
</div>
<div class="flex-1 text-sm font-medium text-gray-850 dark:text-white">
<strong>RAM:</strong> {{ $this->memory($getRecord()) }}
</div>
<div class="flex-1 text-sm font-medium text-gray-850 dark:text-white">
<strong>Disk:</strong> {{ $this->disk($getRecord()) }}
</div>
</div>
</div>