Compare commits
12 Commits
0414337add
...
cc1196a390
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc1196a390 | ||
|
|
7c2d780b9d | ||
|
|
0d33c99f30 | ||
|
|
5208992deb | ||
|
|
9d866cbac0 | ||
|
|
5c2da51538 | ||
|
|
abee59cd43 | ||
|
|
0691521ecf | ||
|
|
7079cbef29 | ||
|
|
25a645c047 | ||
|
|
1d34cdf28a | ||
|
|
b49e3937a5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ yarn-error.log
|
|||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
/.claude
|
/.claude
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -75,24 +75,6 @@ VERSION=0.9-rc
|
|||||||
|-------|--------------|--------|
|
|-------|--------------|--------|
|
||||||
| `VERSION` | Every push | `0.9-rc`, `0.9-rc1`, `0.9-rc2`, ... |
|
| `VERSION` | Every push | `0.9-rc`, `0.9-rc1`, `0.9-rc2`, ... |
|
||||||
|
|
||||||
## Test Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# SSH Access
|
|
||||||
ssh test-server
|
|
||||||
|
|
||||||
# IMPORTANT: When running git or artisan commands on test server,
|
|
||||||
# always cd to /var/www/jabali first:
|
|
||||||
ssh test-server "cd /var/www/jabali && git pull"
|
|
||||||
ssh test-server "cd /var/www/jabali && php artisan optimize:clear"
|
|
||||||
```
|
|
||||||
|
|
||||||
| Panel | URL | Email | Password |
|
|
||||||
|-------|-----|-------|----------|
|
|
||||||
| Admin | https://mx.jabali-panel.com/jabali-admin/ | admin@mx.jabali-panel.com | PycpS1dUuLvxMMQs |
|
|
||||||
| User | https://mx.jabali-panel.com/jabali-panel/ | user@jabali-panel.com | PycpS1dUuLvxMMQs |
|
|
||||||
| Webmail | https://mx.jabali-panel.com/webmail/ | (use mailbox credentials) | |
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
|
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
|
||||||
|
|
||||||
Version: 0.9-rc2 (release candidate)
|
Version: 0.9-rc8 (release candidate)
|
||||||
|
|
||||||
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
||||||
|
|
||||||
|
|||||||
158
app/Console/Commands/CollectUserUsage.php
Normal file
158
app/Console/Commands/CollectUserUsage.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Mailbox;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserResourceUsage;
|
||||||
|
use App\Models\UserSetting;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CollectUserUsage extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'jabali:collect-user-usage {--user=} {--retain=90}';
|
||||||
|
|
||||||
|
protected $description = 'Collect per-user resource usage snapshots';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$users = User::query()
|
||||||
|
->where('is_admin', false)
|
||||||
|
->where('is_active', true);
|
||||||
|
|
||||||
|
$userFilter = $this->option('user');
|
||||||
|
if ($userFilter) {
|
||||||
|
$users->where(function ($query) use ($userFilter) {
|
||||||
|
$query->where('id', $userFilter)
|
||||||
|
->orWhere('username', $userFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $users->get();
|
||||||
|
if ($users->isEmpty()) {
|
||||||
|
$this->info('No users found for usage collection.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$capturedAt = now();
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$diskBytes = $user->getDiskUsageBytes();
|
||||||
|
$mailBytes = (int) Mailbox::where('user_id', $user->id)->sum('quota_used_bytes');
|
||||||
|
$dbBytes = $this->getDatabaseUsage($agent, $user);
|
||||||
|
$bandwidthTotal = $this->getBandwidthTotal($agent, $user);
|
||||||
|
$resourceStats = $this->getUserResourceStats($agent, $user);
|
||||||
|
$cpuPercent = (int) round($resourceStats['cpu_percent'] ?? 0);
|
||||||
|
$memoryBytes = (int) ($resourceStats['memory_bytes'] ?? 0);
|
||||||
|
$diskIoTotal = (int) ($resourceStats['disk_io_total_bytes'] ?? 0);
|
||||||
|
$lastBandwidthTotal = (int) UserSetting::getForUser($user->id, 'bandwidth_total_bytes', 0);
|
||||||
|
$lastDiskIoTotal = (int) UserSetting::getForUser($user->id, 'disk_io_total_bytes', 0);
|
||||||
|
$bandwidthDelta = $bandwidthTotal >= $lastBandwidthTotal
|
||||||
|
? $bandwidthTotal - $lastBandwidthTotal
|
||||||
|
: $bandwidthTotal;
|
||||||
|
$diskIoDelta = $diskIoTotal >= $lastDiskIoTotal
|
||||||
|
? $diskIoTotal - $lastDiskIoTotal
|
||||||
|
: $diskIoTotal;
|
||||||
|
|
||||||
|
$this->storeMetric($user->id, 'disk_bytes', $diskBytes, $capturedAt);
|
||||||
|
$this->storeMetric($user->id, 'mail_bytes', $mailBytes, $capturedAt);
|
||||||
|
$this->storeMetric($user->id, 'database_bytes', $dbBytes, $capturedAt);
|
||||||
|
$this->storeMetric($user->id, 'bandwidth_bytes', $bandwidthDelta, $capturedAt);
|
||||||
|
$this->storeMetric($user->id, 'cpu_percent', $cpuPercent, $capturedAt);
|
||||||
|
$this->storeMetric($user->id, 'memory_bytes', $memoryBytes, $capturedAt);
|
||||||
|
$this->storeMetric($user->id, 'disk_io_bytes', $diskIoDelta, $capturedAt);
|
||||||
|
|
||||||
|
UserSetting::setForUser($user->id, 'bandwidth_total_bytes', $bandwidthTotal);
|
||||||
|
UserSetting::setForUser($user->id, 'disk_io_total_bytes', $diskIoTotal);
|
||||||
|
|
||||||
|
$this->line("Collected usage for {$user->username}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$retainDays = (int) $this->option('retain');
|
||||||
|
if ($retainDays > 0) {
|
||||||
|
UserResourceUsage::where('captured_at', '<', now()->subDays($retainDays))->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDatabaseUsage(AgentClient $agent, User $user): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $agent->mysqlListDatabases($user->username);
|
||||||
|
$databases = $result['databases'] ?? [];
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($databases as $database) {
|
||||||
|
$total += (int) ($database['size_bytes'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->warn("Failed to fetch database usage for {$user->username}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getBandwidthTotal(AgentClient $agent, User $user): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $agent->send('usage.bandwidth_total', [
|
||||||
|
'username' => $user->username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
return (int) ($result['total_bytes'] ?? 0);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->warn("Failed to fetch bandwidth usage for {$user->username}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{cpu_percent: float, memory_bytes: int, disk_io_total_bytes: int}
|
||||||
|
*/
|
||||||
|
protected function getUserResourceStats(AgentClient $agent, User $user): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $agent->send('usage.user_resources', [
|
||||||
|
'username' => $user->username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
return [
|
||||||
|
'cpu_percent' => (float) ($result['cpu_percent'] ?? 0),
|
||||||
|
'memory_bytes' => (int) ($result['memory_bytes'] ?? 0),
|
||||||
|
'disk_io_total_bytes' => (int) ($result['disk_io_total_bytes'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->warn("Failed to fetch CPU/memory/disk IO for {$user->username}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cpu_percent' => 0.0,
|
||||||
|
'memory_bytes' => 0,
|
||||||
|
'disk_io_total_bytes' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function storeMetric(int $userId, string $metric, int $value, $capturedAt): void
|
||||||
|
{
|
||||||
|
UserResourceUsage::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'metric' => $metric,
|
||||||
|
'value' => max(0, $value),
|
||||||
|
'captured_at' => $capturedAt,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Filament/Admin/Pages/AutomationApi.php
Normal file
152
app/Filament/Admin/Pages/AutomationApi.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\CheckboxList;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class AutomationApi extends Page implements HasActions, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 17;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'automation-api';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.automation-api';
|
||||||
|
|
||||||
|
public array $tokens = [];
|
||||||
|
|
||||||
|
public ?string $plainToken = null;
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('API for Automation');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('API Access');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadTokens(): void
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->tokens = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tokens = $user->tokens()
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
->map(function ($token) {
|
||||||
|
return [
|
||||||
|
'id' => $token->id,
|
||||||
|
'name' => $token->name,
|
||||||
|
'abilities' => implode(', ', $token->abilities ?? []),
|
||||||
|
'last_used_at' => $token->last_used_at?->format('Y-m-d H:i') ?? __('Never'),
|
||||||
|
'created_at' => $token->created_at?->format('Y-m-d H:i') ?? '',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->tokens)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('Token')),
|
||||||
|
TextColumn::make('abilities')
|
||||||
|
->label(__('Abilities'))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('last_used_at')
|
||||||
|
->label(__('Last Used')),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label(__('Created')),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('revoke')
|
||||||
|
->label(__('Revoke'))
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$user->tokens()->where('id', $record['id'])->delete();
|
||||||
|
Notification::make()->title(__('Token revoked'))->success()->send();
|
||||||
|
$this->loadTokens();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
Action::make('createToken')
|
||||||
|
->label(__('Create Token'))
|
||||||
|
->icon('heroicon-o-plus-circle')
|
||||||
|
->color('primary')
|
||||||
|
->modalHeading(__('Create API Token'))
|
||||||
|
->form([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label(__('Token Name'))
|
||||||
|
->required(),
|
||||||
|
CheckboxList::make('abilities')
|
||||||
|
->label(__('Abilities'))
|
||||||
|
->options([
|
||||||
|
'automation' => __('Automation API'),
|
||||||
|
'read' => __('Read Only'),
|
||||||
|
'write' => __('Write'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->default(['automation']),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$abilities = $data['abilities'] ?? ['automation'];
|
||||||
|
if (empty($abilities)) {
|
||||||
|
$abilities = ['automation'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $user->createToken($data['name'], $abilities);
|
||||||
|
$this->plainToken = $token->plainTextToken;
|
||||||
|
Notification::make()->title(__('Token created'))->success()->send();
|
||||||
|
$this->loadTokens();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No API tokens yet'))
|
||||||
|
->emptyStateDescription(__('Create a token to access the automation API.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/Filament/Admin/Pages/DatabaseTuning.php
Normal file
135
app/Filament/Admin/Pages/DatabaseTuning.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class DatabaseTuning extends Page implements HasActions, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 19;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'database-tuning';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.database-tuning';
|
||||||
|
|
||||||
|
public array $variables = [];
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Database Tuning');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Database Tuning');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadVariables();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadVariables(): void
|
||||||
|
{
|
||||||
|
$names = [
|
||||||
|
'innodb_buffer_pool_size',
|
||||||
|
'max_connections',
|
||||||
|
'tmp_table_size',
|
||||||
|
'max_heap_table_size',
|
||||||
|
'innodb_log_file_size',
|
||||||
|
'innodb_flush_log_at_trx_commit',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$placeholders = implode("','", $names);
|
||||||
|
$rows = DB::connection('mysql')->select("SHOW VARIABLES WHERE Variable_name IN ('$placeholders')");
|
||||||
|
|
||||||
|
$this->variables = collect($rows)->map(function ($row) {
|
||||||
|
return [
|
||||||
|
'name' => $row->Variable_name,
|
||||||
|
'value' => $row->Value,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->variables = [];
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Unable to load MySQL variables'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->variables)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('Variable'))
|
||||||
|
->fontFamily('mono'),
|
||||||
|
TextColumn::make('value')
|
||||||
|
->label(__('Current Value'))
|
||||||
|
->fontFamily('mono'),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('update')
|
||||||
|
->label(__('Update'))
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->form([
|
||||||
|
TextInput::make('value')
|
||||||
|
->label(__('New Value'))
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $record, array $data): void {
|
||||||
|
try {
|
||||||
|
DB::connection('mysql')->statement('SET GLOBAL '.$record['name'].' = ?', [$data['value']]);
|
||||||
|
try {
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->databasePersistTuning($record['name'], (string) $data['value']);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Variable updated'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Variable updated, but not persisted'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()->title(__('Update failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadVariables();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No variables found'))
|
||||||
|
->emptyStateDescription(__('Database variables could not be loaded.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
156
app/Filament/Admin/Pages/EmailQueue.php
Normal file
156
app/Filament/Admin/Pages/EmailQueue.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
class EmailQueue extends Page implements HasActions, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQueueList;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 15;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'email-queue';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.email-queue';
|
||||||
|
|
||||||
|
public array $queueItems = [];
|
||||||
|
|
||||||
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Email Queue Manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Email Queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAgent(): AgentClient
|
||||||
|
{
|
||||||
|
return $this->agent ??= new AgentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadQueue(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('mail.queue_list');
|
||||||
|
$this->queueItems = $result['queue'] ?? [];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->queueItems = [];
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to load mail queue'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->queueItems)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('id')
|
||||||
|
->label(__('Queue ID'))
|
||||||
|
->fontFamily('mono')
|
||||||
|
->copyable(),
|
||||||
|
TextColumn::make('arrival')
|
||||||
|
->label(__('Arrival')),
|
||||||
|
TextColumn::make('sender')
|
||||||
|
->label(__('Sender'))
|
||||||
|
->wrap()
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('recipients')
|
||||||
|
->label(__('Recipients'))
|
||||||
|
->formatStateUsing(function (array $record): string {
|
||||||
|
$recipients = $record['recipients'] ?? [];
|
||||||
|
if (empty($recipients)) {
|
||||||
|
return __('Unknown');
|
||||||
|
}
|
||||||
|
$first = $recipients[0] ?? '';
|
||||||
|
$count = count($recipients);
|
||||||
|
|
||||||
|
return $count > 1 ? $first.' +'.($count - 1) : $first;
|
||||||
|
})
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('size')
|
||||||
|
->label(__('Size'))
|
||||||
|
->formatStateUsing(fn (array $record): string => $record['size'] ?? ''),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label(__('Status'))
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('retry')
|
||||||
|
->label(__('Retry'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('info')
|
||||||
|
->action(function (array $record): void {
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('mail.queue_retry', ['id' => $record['id'] ?? '']);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('Message retried'))->success()->send();
|
||||||
|
$this->loadQueue();
|
||||||
|
} else {
|
||||||
|
throw new \Exception($result['error'] ?? __('Failed to retry message'));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()->title(__('Retry failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Action::make('delete')
|
||||||
|
->label(__('Delete'))
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('mail.queue_delete', ['id' => $record['id'] ?? '']);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('Message deleted'))->success()->send();
|
||||||
|
$this->loadQueue();
|
||||||
|
} else {
|
||||||
|
throw new \Exception($result['error'] ?? __('Failed to delete message'));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('Mail queue is empty'))
|
||||||
|
->emptyStateDescription(__('No deferred messages found.'))
|
||||||
|
->headerActions([
|
||||||
|
Action::make('refresh')
|
||||||
|
->label(__('Refresh'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(fn () => $this->loadQueue()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
app/Filament/Admin/Pages/ResourceUsage.php
Normal file
257
app/Filament/Admin/Pages/ResourceUsage.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserResourceUsage;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
class ResourceUsage extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 14;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'resource-usage';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.resource-usage';
|
||||||
|
|
||||||
|
public array $usageFormData = [];
|
||||||
|
|
||||||
|
public array $chartData = [];
|
||||||
|
|
||||||
|
public array $performanceChartData = [];
|
||||||
|
|
||||||
|
public bool $hasPerformanceData = false;
|
||||||
|
|
||||||
|
public ?int $cpuLimitPercent = null;
|
||||||
|
|
||||||
|
public ?float $memoryLimitGb = null;
|
||||||
|
|
||||||
|
public array $cpuStats = [];
|
||||||
|
|
||||||
|
public array $memoryStats = [];
|
||||||
|
|
||||||
|
public array $summary = [];
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Resource Usage Reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Resource Usage');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$defaultUser = User::query()->where('is_admin', false)->orderBy('name')->first();
|
||||||
|
$this->usageFormData = [
|
||||||
|
'user_id' => $defaultUser?->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->loadUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getForms(): array
|
||||||
|
{
|
||||||
|
return ['usageForm'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usageForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('usageFormData')
|
||||||
|
->schema([
|
||||||
|
Section::make(__('Filters'))
|
||||||
|
->schema([
|
||||||
|
Select::make('user_id')
|
||||||
|
->label(__('User'))
|
||||||
|
->options($this->getUserOptions())
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function () {
|
||||||
|
$this->loadUsage();
|
||||||
|
})
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->columns(1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUserOptions(): array
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('is_admin', false)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadUsage(): void
|
||||||
|
{
|
||||||
|
$userId = $this->usageFormData['user_id'] ?? null;
|
||||||
|
$userId = $userId ? (int) $userId : null;
|
||||||
|
if (! $userId) {
|
||||||
|
$this->chartData = [];
|
||||||
|
$this->summary = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->with('hostingPackage')->find($userId);
|
||||||
|
$package = $user?->hostingPackage;
|
||||||
|
$this->cpuLimitPercent = $package?->cpu_limit_percent ?: null;
|
||||||
|
$memoryLimitMb = $package?->memory_limit_mb ?: null;
|
||||||
|
$this->memoryLimitGb = $memoryLimitMb ? $this->bytesToGb((int) ($memoryLimitMb * 1024 * 1024)) : null;
|
||||||
|
|
||||||
|
$usageMetrics = ['disk_bytes', 'mail_bytes', 'database_bytes', 'bandwidth_bytes'];
|
||||||
|
$performanceMetrics = ['cpu_percent', 'memory_bytes', 'disk_io_bytes'];
|
||||||
|
$metrics = array_merge($usageMetrics, $performanceMetrics);
|
||||||
|
$start = now()->subDays(30);
|
||||||
|
|
||||||
|
$records = UserResourceUsage::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereIn('metric', $metrics)
|
||||||
|
->where('captured_at', '>=', $start)
|
||||||
|
->orderBy('captured_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$labels = $records
|
||||||
|
->map(fn ($record) => $record->captured_at->format('Y-m-d H:00'))
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$grouped = $records->groupBy('metric')->map(function ($collection) {
|
||||||
|
return $collection->keyBy(fn ($item) => $item->captured_at->format('Y-m-d H:00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$series = [];
|
||||||
|
foreach ($usageMetrics as $metric) {
|
||||||
|
$series[$metric] = [];
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$value = $grouped[$metric][$label]->value ?? 0;
|
||||||
|
$series[$metric][] = $this->bytesToGb((int) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->chartData = [
|
||||||
|
'labels' => $labels->toArray(),
|
||||||
|
'disk' => $series['disk_bytes'],
|
||||||
|
'mail' => $series['mail_bytes'],
|
||||||
|
'database' => $series['database_bytes'],
|
||||||
|
'bandwidth' => $series['bandwidth_bytes'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$performanceSeries = [];
|
||||||
|
foreach ($performanceMetrics as $metric) {
|
||||||
|
$performanceSeries[$metric] = [];
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$value = $grouped[$metric][$label]->value ?? 0;
|
||||||
|
if ($metric === 'cpu_percent') {
|
||||||
|
$performanceSeries[$metric][] = (int) $value;
|
||||||
|
} elseif ($metric === 'memory_bytes') {
|
||||||
|
$performanceSeries[$metric][] = $this->bytesToGb((int) $value);
|
||||||
|
} else {
|
||||||
|
$performanceSeries[$metric][] = $this->bytesToMb((int) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$performanceRecords = $records->whereIn('metric', $performanceMetrics);
|
||||||
|
$this->hasPerformanceData = $performanceRecords->isNotEmpty();
|
||||||
|
$this->performanceChartData = [
|
||||||
|
'labels' => $labels->toArray(),
|
||||||
|
'cpu' => $performanceSeries['cpu_percent'],
|
||||||
|
'memory' => $performanceSeries['memory_bytes'],
|
||||||
|
'disk_io' => $performanceSeries['disk_io_bytes'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->cpuStats = $this->buildPercentageStats($performanceRecords->where('metric', 'cpu_percent')->pluck('value'));
|
||||||
|
$this->memoryStats = $this->buildBytesStats($performanceRecords->where('metric', 'memory_bytes')->pluck('value'));
|
||||||
|
|
||||||
|
$this->summary = [
|
||||||
|
'disk' => $this->formatMetric($userId, 'disk_bytes'),
|
||||||
|
'mail' => $this->formatMetric($userId, 'mail_bytes'),
|
||||||
|
'database' => $this->formatMetric($userId, 'database_bytes'),
|
||||||
|
'bandwidth' => $this->formatMetric($userId, 'bandwidth_bytes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatMetric(int $userId, string $metric): string
|
||||||
|
{
|
||||||
|
$latest = UserResourceUsage::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('metric', $metric)
|
||||||
|
->latest('captured_at')
|
||||||
|
->value('value');
|
||||||
|
|
||||||
|
if ($latest === null) {
|
||||||
|
return __('No data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format($this->bytesToGb((int) $latest), 2).' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function bytesToGb(int $bytes): float
|
||||||
|
{
|
||||||
|
return round($bytes / 1024 / 1024 / 1024, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function bytesToMb(int $bytes): float
|
||||||
|
{
|
||||||
|
return round($bytes / 1024 / 1024, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Illuminate\Support\Collection<int, int|float> $values
|
||||||
|
* @return array{avg: ?string, max: ?string}
|
||||||
|
*/
|
||||||
|
protected function buildPercentageStats($values): array
|
||||||
|
{
|
||||||
|
if ($values->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'avg' => null,
|
||||||
|
'max' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avg' => number_format((float) $values->avg(), 1).'%',
|
||||||
|
'max' => number_format((float) $values->max(), 1).'%',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Illuminate\Support\Collection<int, int|float> $values
|
||||||
|
* @return array{avg: ?string, max: ?string}
|
||||||
|
*/
|
||||||
|
protected function buildBytesStats($values): array
|
||||||
|
{
|
||||||
|
if ($values->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'avg' => null,
|
||||||
|
'max' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avg' => number_format($this->bytesToGb((int) $values->avg()), 2).' GB',
|
||||||
|
'max' => number_format($this->bytesToGb((int) $values->max()), 2).' GB',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Admin\Pages;
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
use App\Filament\Admin\Widgets\Security\BannedIpsTable;
|
use App\Filament\Admin\Widgets\Security\BannedIpsTable;
|
||||||
|
use App\Filament\Admin\Widgets\Security\Fail2banLogsTable;
|
||||||
use App\Filament\Admin\Widgets\Security\JailsTable;
|
use App\Filament\Admin\Widgets\Security\JailsTable;
|
||||||
use App\Filament\Admin\Widgets\Security\LynisResultsTable;
|
use App\Filament\Admin\Widgets\Security\LynisResultsTable;
|
||||||
use App\Filament\Admin\Widgets\Security\NiktoResultsTable;
|
use App\Filament\Admin\Widgets\Security\NiktoResultsTable;
|
||||||
@@ -90,6 +91,8 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
public ?int $totalBanned = null;
|
public ?int $totalBanned = null;
|
||||||
|
|
||||||
|
public array $fail2banLogs = [];
|
||||||
|
|
||||||
public int $maxRetry = 5;
|
public int $maxRetry = 5;
|
||||||
|
|
||||||
public int $banTime = 600;
|
public int $banTime = 600;
|
||||||
@@ -545,6 +548,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]),
|
EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]),
|
||||||
])
|
])
|
||||||
->visible(fn () => ($this->totalBanned ?? 0) > 0),
|
->visible(fn () => ($this->totalBanned ?? 0) > 0),
|
||||||
|
Section::make(__('Fail2ban Logs'))
|
||||||
|
->icon('heroicon-o-document-text')
|
||||||
|
->schema([
|
||||||
|
EmbeddedTable::make(Fail2banLogsTable::class, ['logs' => $this->fail2banLogs]),
|
||||||
|
])
|
||||||
|
->collapsible(),
|
||||||
])->visible(fn () => $this->fail2banInstalled),
|
])->visible(fn () => $this->fail2banInstalled),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1012,6 +1021,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->jails = [];
|
$this->jails = [];
|
||||||
$this->availableJails = [];
|
$this->availableJails = [];
|
||||||
$this->totalBanned = null;
|
$this->totalBanned = null;
|
||||||
|
$this->fail2banLogs = [];
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->fail2banInstalled = false;
|
$this->fail2banInstalled = false;
|
||||||
$this->fail2banRunning = false;
|
$this->fail2banRunning = false;
|
||||||
@@ -1019,6 +1029,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->jails = [];
|
$this->jails = [];
|
||||||
$this->availableJails = [];
|
$this->availableJails = [];
|
||||||
$this->totalBanned = null;
|
$this->totalBanned = null;
|
||||||
|
$this->fail2banLogs = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,12 +1050,16 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
$jailsResult = $this->getAgent()->send('fail2ban.list_jails');
|
$jailsResult = $this->getAgent()->send('fail2ban.list_jails');
|
||||||
$this->availableJails = $jailsResult['jails'] ?? [];
|
$this->availableJails = $jailsResult['jails'] ?? [];
|
||||||
|
|
||||||
|
$logsResult = $this->getAgent()->send('fail2ban.logs');
|
||||||
|
$this->fail2banLogs = $logsResult['logs'] ?? [];
|
||||||
} else {
|
} else {
|
||||||
$this->fail2banRunning = false;
|
$this->fail2banRunning = false;
|
||||||
$this->fail2banVersion = '';
|
$this->fail2banVersion = '';
|
||||||
$this->jails = [];
|
$this->jails = [];
|
||||||
$this->availableJails = [];
|
$this->availableJails = [];
|
||||||
$this->totalBanned = null;
|
$this->totalBanned = null;
|
||||||
|
$this->fail2banLogs = [];
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->fail2banInstalled = false;
|
$this->fail2banInstalled = false;
|
||||||
@@ -1053,6 +1068,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->jails = [];
|
$this->jails = [];
|
||||||
$this->availableJails = [];
|
$this->availableJails = [];
|
||||||
$this->totalBanned = null;
|
$this->totalBanned = null;
|
||||||
|
$this->fail2banLogs = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
124
app/Filament/Admin/Pages/ServerUpdates.php
Normal file
124
app/Filament/Admin/Pages/ServerUpdates.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
class ServerUpdates extends Page implements HasActions, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowPathRoundedSquare;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 16;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'server-updates';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.server-updates';
|
||||||
|
|
||||||
|
public array $packages = [];
|
||||||
|
|
||||||
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Server Updates');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Server Updates');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAgent(): AgentClient
|
||||||
|
{
|
||||||
|
return $this->agent ??= new AgentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadUpdates(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->updatesList();
|
||||||
|
$this->packages = $result['packages'] ?? [];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->packages = [];
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to load updates'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runUpdates(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->getAgent()->updatesRun();
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Updates completed'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Update failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->packages)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('Package'))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('current_version')
|
||||||
|
->label(__('Current Version')),
|
||||||
|
TextColumn::make('new_version')
|
||||||
|
->label(__('New Version')),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No updates available'))
|
||||||
|
->emptyStateDescription(__('Your system packages are up to date.'))
|
||||||
|
->headerActions([
|
||||||
|
Action::make('refresh')
|
||||||
|
->label(__('Refresh'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(fn () => $this->loadUpdates()),
|
||||||
|
Action::make('runUpdates')
|
||||||
|
->label(__('Run Updates'))
|
||||||
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(__('Install updates'))
|
||||||
|
->modalDescription(__('This will run apt-get upgrade on the server. Continue?'))
|
||||||
|
->action(fn () => $this->runUpdates()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/Filament/Admin/Pages/Waf.php
Normal file
119
app/Filament/Admin/Pages/Waf.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
class Waf extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'waf';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.waf';
|
||||||
|
|
||||||
|
public bool $wafInstalled = false;
|
||||||
|
|
||||||
|
public array $wafFormData = [];
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('ModSecurity / WAF');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('ModSecurity / WAF');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->wafInstalled = $this->detectWaf();
|
||||||
|
$this->wafFormData = [
|
||||||
|
'enabled' => Setting::get('waf_enabled', '0') === '1',
|
||||||
|
'paranoia' => Setting::get('waf_paranoia', '1'),
|
||||||
|
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function detectWaf(): bool
|
||||||
|
{
|
||||||
|
return file_exists('/etc/nginx/modsec/main.conf') || file_exists('/etc/nginx/modsecurity.conf');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getForms(): array
|
||||||
|
{
|
||||||
|
return ['wafForm'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function wafForm(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('wafFormData')
|
||||||
|
->schema([
|
||||||
|
Section::make(__('WAF Settings'))
|
||||||
|
->schema([
|
||||||
|
Toggle::make('enabled')
|
||||||
|
->label(__('Enable ModSecurity')),
|
||||||
|
Select::make('paranoia')
|
||||||
|
->label(__('Paranoia Level'))
|
||||||
|
->options([
|
||||||
|
'1' => '1 - Basic',
|
||||||
|
'2' => '2 - Moderate',
|
||||||
|
'3' => '3 - Strict',
|
||||||
|
'4' => '4 - Very Strict',
|
||||||
|
])
|
||||||
|
->default('1'),
|
||||||
|
Toggle::make('audit_log')
|
||||||
|
->label(__('Enable Audit Log')),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveWafSettings(): void
|
||||||
|
{
|
||||||
|
$data = $this->wafForm->getState();
|
||||||
|
Setting::set('waf_enabled', ! empty($data['enabled']) ? '1' : '0');
|
||||||
|
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
|
||||||
|
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->wafApplySettings(
|
||||||
|
! empty($data['enabled']),
|
||||||
|
(string) ($data['paranoia'] ?? '1'),
|
||||||
|
! empty($data['audit_log'])
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('WAF settings applied'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('WAF settings saved, but apply failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\GeoBlockRules;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\Pages\CreateGeoBlockRule;
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\Pages\EditGeoBlockRule;
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\Pages\ListGeoBlockRules;
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\Schemas\GeoBlockRuleForm;
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\Tables\GeoBlockRulesTable;
|
||||||
|
use App\Models\GeoBlockRule;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class GeoBlockRuleResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = GeoBlockRule::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 22;
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Geographic Blocking');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('Geo Rule');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('Geo Rules');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return GeoBlockRuleForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return GeoBlockRulesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListGeoBlockRules::route('/'),
|
||||||
|
'create' => CreateGeoBlockRule::route('/create'),
|
||||||
|
'edit' => EditGeoBlockRule::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
|
||||||
|
use App\Services\System\GeoBlockService;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateGeoBlockRule extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = GeoBlockRuleResource::class;
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
app(GeoBlockService::class)->applyCurrentRules();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Geo rules applied'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Geo rules apply failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
|
||||||
|
use App\Services\System\GeoBlockService;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditGeoBlockRule extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = GeoBlockRuleResource::class;
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$this->applyGeoRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make()
|
||||||
|
->after(fn () => $this->applyGeoRules()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyGeoRules(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
app(GeoBlockService::class)->applyCurrentRules();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Geo rules applied'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Geo rules apply failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListGeoBlockRules extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = GeoBlockRuleResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\GeoBlockRules\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class GeoBlockRuleForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->columns(1)
|
||||||
|
->components([
|
||||||
|
Section::make(__('Geo Rule'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('country_code')
|
||||||
|
->label(__('Country Code'))
|
||||||
|
->maxLength(2)
|
||||||
|
->minLength(2)
|
||||||
|
->required()
|
||||||
|
->helperText(__('Use ISO-3166 alpha-2 code (e.g., US, DE, FR).')),
|
||||||
|
Select::make('action')
|
||||||
|
->label(__('Action'))
|
||||||
|
->options([
|
||||||
|
'block' => __('Block'),
|
||||||
|
'allow' => __('Allow'),
|
||||||
|
])
|
||||||
|
->default('block')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('notes')
|
||||||
|
->label(__('Notes')),
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label(__('Active'))
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\GeoBlockRules\Tables;
|
||||||
|
|
||||||
|
use App\Services\System\GeoBlockService;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class GeoBlockRulesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('country_code')
|
||||||
|
->label(__('Country'))
|
||||||
|
->formatStateUsing(fn ($state) => strtoupper($state))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('action')
|
||||||
|
->label(__('Action'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => $state === 'block' ? 'danger' : 'success'),
|
||||||
|
TextColumn::make('notes')
|
||||||
|
->label(__('Notes'))
|
||||||
|
->wrap(),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label(__('Active'))
|
||||||
|
->boolean(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->after(function () {
|
||||||
|
try {
|
||||||
|
app(GeoBlockService::class)->applyCurrentRules();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Geo rules sync failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No geo rules'))
|
||||||
|
->emptyStateDescription(__('Add a country rule to allow or block traffic.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\HostingPackages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\Pages\CreateHostingPackage;
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\Pages\EditHostingPackage;
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\Pages\ListHostingPackages;
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\Schemas\HostingPackageForm;
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\Tables\HostingPackagesTable;
|
||||||
|
use App\Models\HostingPackage;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class HostingPackageResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = HostingPackage::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCube;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 13;
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Hosting Packages');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('Hosting Package');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('Hosting Packages');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return HostingPackageForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return HostingPackagesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListHostingPackages::route('/'),
|
||||||
|
'create' => CreateHostingPackage::route('/create'),
|
||||||
|
'edit' => EditHostingPackage::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateHostingPackage extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = HostingPackageResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditHostingPackage extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = HostingPackageResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListHostingPackages extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = HostingPackageResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\HostingPackages\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class HostingPackageForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->columns(1)
|
||||||
|
->components([
|
||||||
|
Section::make(__('Package Details'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->required()
|
||||||
|
->maxLength(120)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
Textarea::make('description')
|
||||||
|
->label(__('Description'))
|
||||||
|
->rows(3)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label(__('Active'))
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make(__('Resource Limits'))
|
||||||
|
->description(__('Leave blank for unlimited.'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('disk_quota_mb')
|
||||||
|
->label(__('Disk Quota (MB)'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->helperText(__('Example: 10240 = 10 GB')),
|
||||||
|
TextInput::make('bandwidth_gb')
|
||||||
|
->label(__('Bandwidth (GB / month)'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0),
|
||||||
|
TextInput::make('domains_limit')
|
||||||
|
->label(__('Domains Limit'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0),
|
||||||
|
TextInput::make('databases_limit')
|
||||||
|
->label(__('Databases Limit'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0),
|
||||||
|
TextInput::make('mailboxes_limit')
|
||||||
|
->label(__('Mailboxes Limit'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make(__('System Resource Limits'))
|
||||||
|
->description(__('Optional cgroup limits applied to users in this package. Leave blank for unlimited.'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('cpu_limit_percent')
|
||||||
|
->label(__('CPU Limit (%)'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(100)
|
||||||
|
->helperText(__('Example: 50 = 50% CPU quota')),
|
||||||
|
TextInput::make('memory_limit_mb')
|
||||||
|
->label(__('Memory Limit (MB)'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->helperText(__('Example: 1024 = 1 GB RAM')),
|
||||||
|
TextInput::make('io_limit_mb')
|
||||||
|
->label(__('Disk IO Limit (MB/s)'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->helperText(__('Applied to read/write bandwidth')),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\HostingPackages\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class HostingPackagesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('disk_quota_mb')
|
||||||
|
->label(__('Disk'))
|
||||||
|
->getStateUsing(function ($record) {
|
||||||
|
$quota = $record->disk_quota_mb;
|
||||||
|
if (! $quota) {
|
||||||
|
return __('Unlimited');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $quota >= 1024
|
||||||
|
? number_format($quota / 1024, 1).' GB'
|
||||||
|
: $quota.' MB';
|
||||||
|
}),
|
||||||
|
TextColumn::make('bandwidth_gb')
|
||||||
|
->label(__('Bandwidth'))
|
||||||
|
->getStateUsing(fn ($record) => $record->bandwidth_gb ? $record->bandwidth_gb.' GB' : __('Unlimited')),
|
||||||
|
TextColumn::make('domains_limit')
|
||||||
|
->label(__('Domains'))
|
||||||
|
->getStateUsing(fn ($record) => $record->domains_limit ?: __('Unlimited')),
|
||||||
|
TextColumn::make('databases_limit')
|
||||||
|
->label(__('Databases'))
|
||||||
|
->getStateUsing(fn ($record) => $record->databases_limit ?: __('Unlimited')),
|
||||||
|
TextColumn::make('mailboxes_limit')
|
||||||
|
->label(__('Mailboxes'))
|
||||||
|
->getStateUsing(fn ($record) => $record->mailboxes_limit ?: __('Unlimited')),
|
||||||
|
TextColumn::make('resource_limits')
|
||||||
|
->label(__('System Limits'))
|
||||||
|
->getStateUsing(function ($record) {
|
||||||
|
$cpu = $record->cpu_limit_percent ? $record->cpu_limit_percent.'%' : null;
|
||||||
|
$memory = $record->memory_limit_mb ? $record->memory_limit_mb.' MB' : null;
|
||||||
|
$io = $record->io_limit_mb ? $record->io_limit_mb.' MB/s' : null;
|
||||||
|
|
||||||
|
$parts = array_filter([
|
||||||
|
$cpu ? __('CPU: :value', ['value' => $cpu]) : null,
|
||||||
|
$memory ? __('RAM: :value', ['value' => $memory]) : null,
|
||||||
|
$io ? __('IO: :value', ['value' => $io]) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ! empty($parts) ? implode(', ', $parts) : __('Unlimited');
|
||||||
|
}),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label(__('Active'))
|
||||||
|
->boolean(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->defaultSort('name');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,21 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Admin\Resources\Users\Pages;
|
namespace App\Filament\Admin\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Admin\Resources\Users\UserResource;
|
use App\Filament\Admin\Resources\Users\UserResource;
|
||||||
|
use App\Models\HostingPackage;
|
||||||
|
use App\Models\UserResourceLimit;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use App\Services\System\LinuxUserService;
|
use App\Services\System\LinuxUserService;
|
||||||
|
use App\Services\System\ResourceLimitService;
|
||||||
|
use Exception;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
protected ?HostingPackage $selectedPackage = null;
|
||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
{
|
{
|
||||||
// Generate SFTP password (same as user password or random)
|
// Generate SFTP password (same as user password or random)
|
||||||
@@ -23,6 +27,14 @@ class CreateUser extends CreateRecord
|
|||||||
$data['sftp_password'] = $data['password'];
|
$data['sftp_password'] = $data['password'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! empty($data['hosting_package_id'])) {
|
||||||
|
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
|
||||||
|
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
|
||||||
|
} else {
|
||||||
|
$this->selectedPackage = null;
|
||||||
|
$data['disk_quota_mb'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +44,7 @@ class CreateUser extends CreateRecord
|
|||||||
|
|
||||||
if ($createLinuxUser) {
|
if ($createLinuxUser) {
|
||||||
try {
|
try {
|
||||||
$linuxService = new LinuxUserService();
|
$linuxService = new LinuxUserService;
|
||||||
|
|
||||||
// Get the plain password before it was hashed
|
// Get the plain password before it was hashed
|
||||||
$password = $this->data['sftp_password'] ?? null;
|
$password = $this->data['sftp_password'] ?? null;
|
||||||
@@ -47,6 +59,9 @@ class CreateUser extends CreateRecord
|
|||||||
|
|
||||||
// Apply disk quota if enabled
|
// Apply disk quota if enabled
|
||||||
$this->applyDiskQuota();
|
$this->applyDiskQuota();
|
||||||
|
|
||||||
|
// Apply resource limits from package
|
||||||
|
$this->syncResourceLimitsFromPackage($this->selectedPackage, true);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Linux user creation failed'))
|
->title(__('Linux user creation failed'))
|
||||||
@@ -54,6 +69,17 @@ class CreateUser extends CreateRecord
|
|||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Store resource limits even if the Linux user was not created yet
|
||||||
|
$this->syncResourceLimitsFromPackage($this->selectedPackage, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->record->hosting_package_id) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('No hosting package selected'))
|
||||||
|
->body(__('This user has unlimited resource limits.'))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +92,7 @@ class CreateUser extends CreateRecord
|
|||||||
|
|
||||||
// Always try to apply quota when set
|
// Always try to apply quota when set
|
||||||
try {
|
try {
|
||||||
$agent = new AgentClient();
|
$agent = new AgentClient;
|
||||||
$result = $agent->quotaSet($this->record->username, (int) $quotaMb);
|
$result = $agent->quotaSet($this->record->username, (int) $quotaMb);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
@@ -87,4 +113,50 @@ class CreateUser extends CreateRecord
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function syncResourceLimitsFromPackage(?HostingPackage $package, bool $apply): void
|
||||||
|
{
|
||||||
|
if (! $this->record) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cpu = $package?->cpu_limit_percent;
|
||||||
|
$memory = $package?->memory_limit_mb;
|
||||||
|
$io = $package?->io_limit_mb;
|
||||||
|
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
|
||||||
|
|
||||||
|
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
|
||||||
|
|
||||||
|
if (! $package || ! $hasLimits) {
|
||||||
|
if ($limit) {
|
||||||
|
$limit->fill([
|
||||||
|
'cpu_limit_percent' => null,
|
||||||
|
'memory_limit_mb' => null,
|
||||||
|
'io_limit_mb' => null,
|
||||||
|
'is_active' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
app(ResourceLimitService::class)->clear($limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $limit) {
|
||||||
|
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit->fill([
|
||||||
|
'cpu_limit_percent' => $cpu,
|
||||||
|
'memory_limit_mb' => $memory,
|
||||||
|
'io_limit_mb' => $io,
|
||||||
|
'is_active' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
app(ResourceLimitService::class)->apply($limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Admin\Resources\Users\Pages;
|
namespace App\Filament\Admin\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Admin\Resources\Users\UserResource;
|
use App\Filament\Admin\Resources\Users\UserResource;
|
||||||
|
use App\Models\HostingPackage;
|
||||||
|
use App\Models\UserResourceLimit;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use App\Services\System\LinuxUserService;
|
use App\Services\System\LinuxUserService;
|
||||||
|
use App\Services\System\ResourceLimitService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
@@ -19,6 +22,8 @@ class EditUser extends EditRecord
|
|||||||
|
|
||||||
protected ?int $originalQuota = null;
|
protected ?int $originalQuota = null;
|
||||||
|
|
||||||
|
protected ?HostingPackage $selectedPackage = null;
|
||||||
|
|
||||||
protected function mutateFormDataBeforeFill(array $data): array
|
protected function mutateFormDataBeforeFill(array $data): array
|
||||||
{
|
{
|
||||||
$this->originalQuota = $data['disk_quota_mb'] ?? null;
|
$this->originalQuota = $data['disk_quota_mb'] ?? null;
|
||||||
@@ -26,13 +31,23 @@ class EditUser extends EditRecord
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
if (! empty($data['hosting_package_id'])) {
|
||||||
|
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
|
||||||
|
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
|
||||||
|
} else {
|
||||||
|
$this->selectedPackage = null;
|
||||||
|
$data['disk_quota_mb'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterSave(): void
|
protected function afterSave(): void
|
||||||
{
|
{
|
||||||
$newQuota = $this->record->disk_quota_mb;
|
$newQuota = $this->record->disk_quota_mb;
|
||||||
if ($newQuota === $this->originalQuota) {
|
if ($newQuota !== $this->originalQuota) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always try to apply quota when changed
|
// Always try to apply quota when changed
|
||||||
try {
|
try {
|
||||||
$agent = new AgentClient;
|
$agent = new AgentClient;
|
||||||
@@ -61,6 +76,47 @@ class EditUser extends EditRecord
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->syncResourceLimitsFromPackage($this->selectedPackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function syncResourceLimitsFromPackage(?HostingPackage $package): void
|
||||||
|
{
|
||||||
|
$cpu = $package?->cpu_limit_percent;
|
||||||
|
$memory = $package?->memory_limit_mb;
|
||||||
|
$io = $package?->io_limit_mb;
|
||||||
|
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
|
||||||
|
|
||||||
|
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
|
||||||
|
|
||||||
|
if (! $package || ! $hasLimits) {
|
||||||
|
if ($limit) {
|
||||||
|
$limit->fill([
|
||||||
|
'cpu_limit_percent' => null,
|
||||||
|
'memory_limit_mb' => null,
|
||||||
|
'io_limit_mb' => null,
|
||||||
|
'is_active' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
app(ResourceLimitService::class)->clear($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $limit) {
|
||||||
|
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit->fill([
|
||||||
|
'cpu_limit_percent' => $cpu,
|
||||||
|
'memory_limit_mb' => $memory,
|
||||||
|
'io_limit_mb' => $io,
|
||||||
|
'is_active' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
app(ResourceLimitService::class)->apply($limit);
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -2,18 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Filament\Admin\Resources\Users\Schemas;
|
namespace App\Filament\Admin\Resources\Users\Schemas;
|
||||||
|
|
||||||
use App\Models\DnsSetting;
|
use App\Models\HostingPackage;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Components\Group;
|
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\HtmlString;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class UserForm
|
class UserForm
|
||||||
{
|
{
|
||||||
@@ -125,6 +122,24 @@ class UserForm
|
|||||||
->helperText(__('Inactive users cannot log in'))
|
->helperText(__('Inactive users cannot log in'))
|
||||||
->inline(false),
|
->inline(false),
|
||||||
|
|
||||||
|
Placeholder::make('package_notice')
|
||||||
|
->label(__('Hosting Package'))
|
||||||
|
->content(__('No hosting package selected. This user will have unlimited resources.'))
|
||||||
|
->visible(fn ($get) => blank($get('hosting_package_id'))),
|
||||||
|
|
||||||
|
\Filament\Forms\Components\Select::make('hosting_package_id')
|
||||||
|
->label(__('Hosting Package'))
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->options(fn () => HostingPackage::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->toArray())
|
||||||
|
->placeholder(__('Unlimited (no package)'))
|
||||||
|
->helperText(__('Assign a package to set resource limits.'))
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Toggle::make('create_linux_user')
|
Toggle::make('create_linux_user')
|
||||||
->label(__('Create Linux User'))
|
->label(__('Create Linux User'))
|
||||||
->default(true)
|
->default(true)
|
||||||
@@ -138,63 +153,6 @@ class UserForm
|
|||||||
])
|
])
|
||||||
->columns(4),
|
->columns(4),
|
||||||
|
|
||||||
Section::make(__('Disk Quota'))
|
|
||||||
->schema([
|
|
||||||
Placeholder::make('current_usage')
|
|
||||||
->label(__('Current Usage'))
|
|
||||||
->content(function ($record) {
|
|
||||||
if (!$record) {
|
|
||||||
return __('N/A');
|
|
||||||
}
|
|
||||||
|
|
||||||
$used = $record->disk_usage_formatted;
|
|
||||||
$quotaMb = $record->disk_quota_mb;
|
|
||||||
|
|
||||||
if (!$quotaMb || $quotaMb <= 0) {
|
|
||||||
return "{$used} (" . __('Unlimited') . ")";
|
|
||||||
}
|
|
||||||
|
|
||||||
$quota = $quotaMb >= 1024
|
|
||||||
? number_format($quotaMb / 1024, 1) . ' GB'
|
|
||||||
: $quotaMb . ' MB';
|
|
||||||
$percent = $record->disk_usage_percent;
|
|
||||||
|
|
||||||
return "{$used} / {$quota} ({$percent}%)";
|
|
||||||
})
|
|
||||||
->visibleOn('edit'),
|
|
||||||
|
|
||||||
Toggle::make('unlimited_quota')
|
|
||||||
->label(__('Unlimited Quota'))
|
|
||||||
->helperText(__('No disk space limit'))
|
|
||||||
->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120) === 0)
|
|
||||||
->live()
|
|
||||||
->afterStateHydrated(function (Toggle $component, $state, $record) {
|
|
||||||
if ($record) {
|
|
||||||
$component->state(!$record->disk_quota_mb || $record->disk_quota_mb <= 0);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->afterStateUpdated(function ($state, callable $set) {
|
|
||||||
if ($state) {
|
|
||||||
$set('disk_quota_mb', 0);
|
|
||||||
} else {
|
|
||||||
$set('disk_quota_mb', (int) DnsSetting::get('default_quota_mb', 5120));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->inline(false),
|
|
||||||
|
|
||||||
TextInput::make('disk_quota_mb')
|
|
||||||
->label(__('Disk Quota'))
|
|
||||||
->numeric()
|
|
||||||
->minValue(1)
|
|
||||||
->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120))
|
|
||||||
->helperText(fn ($state) => $state && $state > 0 ? number_format($state / 1024, 1) . ' GB' : null)
|
|
||||||
->suffix('MB')
|
|
||||||
->visible(fn ($get) => !$get('unlimited_quota'))
|
|
||||||
->required(fn ($get) => !$get('unlimited_quota')),
|
|
||||||
])
|
|
||||||
->description(fn () => !(bool) DnsSetting::get('quotas_enabled', false) ? __('Note: Quotas are currently disabled in Server Settings.') : null)
|
|
||||||
->columns(3),
|
|
||||||
|
|
||||||
Section::make(__('System Information'))
|
Section::make(__('System Information'))
|
||||||
->schema([
|
->schema([
|
||||||
Placeholder::make('home_directory_display')
|
Placeholder::make('home_directory_display')
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateWebhookEndpoint extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WebhookEndpointResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditWebhookEndpoint extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WebhookEndpointResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWebhookEndpoints extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WebhookEndpointResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\WebhookEndpoints\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\CheckboxList;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class WebhookEndpointForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->columns(1)
|
||||||
|
->components([
|
||||||
|
Section::make(__('Webhook Details'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->required()
|
||||||
|
->maxLength(120),
|
||||||
|
TextInput::make('url')
|
||||||
|
->label(__('URL'))
|
||||||
|
->url()
|
||||||
|
->required(),
|
||||||
|
CheckboxList::make('events')
|
||||||
|
->label(__('Events'))
|
||||||
|
->options([
|
||||||
|
'backup.completed' => __('Backup Completed'),
|
||||||
|
'ssl.expiring' => __('SSL Expiring'),
|
||||||
|
'migration.completed' => __('Migration Completed'),
|
||||||
|
'user.suspended' => __('User Suspended'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
TextInput::make('secret_token')
|
||||||
|
->label(__('Secret Token'))
|
||||||
|
->helperText(__('Used to sign webhook payloads'))
|
||||||
|
->default(fn () => Str::random(32))
|
||||||
|
->password()
|
||||||
|
->revealable(),
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label(__('Active'))
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\WebhookEndpoints\Tables;
|
||||||
|
|
||||||
|
use App\Models\WebhookEndpoint;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class WebhookEndpointsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(WebhookEndpoint::query())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('url')
|
||||||
|
->label(__('URL'))
|
||||||
|
->limit(40)
|
||||||
|
->tooltip(fn (WebhookEndpoint $record) => $record->url),
|
||||||
|
TextColumn::make('events')
|
||||||
|
->label(__('Events'))
|
||||||
|
->formatStateUsing(function ($state): string {
|
||||||
|
if (is_array($state)) {
|
||||||
|
return implode(', ', $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $state;
|
||||||
|
})
|
||||||
|
->wrap(),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label(__('Active'))
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('last_triggered_at')
|
||||||
|
->label(__('Last Triggered'))
|
||||||
|
->since(),
|
||||||
|
TextColumn::make('last_response_code')
|
||||||
|
->label(__('Last Response')),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('test')
|
||||||
|
->label(__('Test'))
|
||||||
|
->icon('heroicon-o-signal')
|
||||||
|
->color('info')
|
||||||
|
->action(function (WebhookEndpoint $record): void {
|
||||||
|
$payload = [
|
||||||
|
'event' => 'test',
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'request_id' => Str::uuid()->toString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$headers = [];
|
||||||
|
if (! empty($record->secret_token)) {
|
||||||
|
$signature = hash_hmac('sha256', json_encode($payload), $record->secret_token);
|
||||||
|
$headers['X-Jabali-Signature'] = $signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders($headers)->post($record->url, $payload);
|
||||||
|
$record->update([
|
||||||
|
'last_response_code' => $response->status(),
|
||||||
|
'last_triggered_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Webhook delivered'))
|
||||||
|
->body(__('Status: :status', ['status' => $response->status()]))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$record->update([
|
||||||
|
'last_response_code' => null,
|
||||||
|
'last_triggered_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Webhook failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No webhooks configured'))
|
||||||
|
->emptyStateDescription(__('Add a webhook to receive system notifications.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Resources\WebhookEndpoints;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\CreateWebhookEndpoint;
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\EditWebhookEndpoint;
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\ListWebhookEndpoints;
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\Schemas\WebhookEndpointForm;
|
||||||
|
use App\Filament\Admin\Resources\WebhookEndpoints\Tables\WebhookEndpointsTable;
|
||||||
|
use App\Models\WebhookEndpoint;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class WebhookEndpointResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = WebhookEndpoint::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBellAlert;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 18;
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Webhook Notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('Webhook');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('Webhooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return WebhookEndpointForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return WebhookEndpointsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListWebhookEndpoints::route('/'),
|
||||||
|
'create' => CreateWebhookEndpoint::route('/create'),
|
||||||
|
'edit' => EditWebhookEndpoint::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Filament/Admin/Widgets/Security/Fail2banLogsTable.php
Normal file
70
app/Filament/Admin/Widgets/Security/Fail2banLogsTable.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Widgets\Security;
|
||||||
|
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||||
|
use Filament\Schemas\Contracts\HasSchemas;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Fail2banLogsTable extends Component implements HasActions, HasSchemas, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithSchemas;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public array $logs = [];
|
||||||
|
|
||||||
|
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->logs)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('time')
|
||||||
|
->label(__('Time'))
|
||||||
|
->fontFamily('mono')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('jail')
|
||||||
|
->label(__('Jail'))
|
||||||
|
->badge()
|
||||||
|
->color('gray'),
|
||||||
|
TextColumn::make('action')
|
||||||
|
->label(__('Action'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match (strtolower($state)) {
|
||||||
|
'ban' => 'danger',
|
||||||
|
'unban' => 'success',
|
||||||
|
'found' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextColumn::make('ip')
|
||||||
|
->label(__('IP'))
|
||||||
|
->fontFamily('mono')
|
||||||
|
->copyable()
|
||||||
|
->placeholder('-'),
|
||||||
|
TextColumn::make('message')
|
||||||
|
->label(__('Message'))
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No log entries'))
|
||||||
|
->emptyStateDescription(__('Fail2ban logs will appear here once activity is detected.'))
|
||||||
|
->striped();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return $this->getTable()->render();
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/Filament/Jabali/Pages/CdnIntegration.php
Normal file
171
app/Filament/Jabali/Pages/CdnIntegration.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\HasPageTour;
|
||||||
|
use App\Models\CloudflareZone;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use BackedEnum;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class CdnIntegration extends Page implements HasActions, HasTable
|
||||||
|
{
|
||||||
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cloud';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'cdn-integration';
|
||||||
|
|
||||||
|
protected string $view = 'filament.jabali.pages.cdn-integration';
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('CDN Integration');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('CDN Integration');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(Domain::query()->where('user_id', Auth::id()))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('domain')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
IconColumn::make('cloudflare')
|
||||||
|
->label(__('Cloudflare'))
|
||||||
|
->boolean()
|
||||||
|
->state(fn (Domain $record) => $this->getZone($record) !== null)
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('gray'),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('connect')
|
||||||
|
->label(__('Connect'))
|
||||||
|
->icon('heroicon-o-link')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (Domain $record) => $this->getZone($record) === null)
|
||||||
|
->form([
|
||||||
|
TextInput::make('api_token')
|
||||||
|
->label(__('Cloudflare API Token'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (Domain $record, array $data): void {
|
||||||
|
$token = $data['api_token'];
|
||||||
|
try {
|
||||||
|
$zone = $this->fetchCloudflareZone($record->domain, $token);
|
||||||
|
CloudflareZone::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'domain_id' => $record->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'zone_id' => $zone['id'],
|
||||||
|
'account_id' => $zone['account']['id'] ?? null,
|
||||||
|
'api_token' => $token,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()->title(__('Cloudflare connected'))->success()->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()->title(__('Connection failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Action::make('purge')
|
||||||
|
->label(__('Purge Cache'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (Domain $record) => $this->getZone($record) !== null)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Domain $record): void {
|
||||||
|
$zone = $this->getZone($record);
|
||||||
|
if (! $zone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withToken($zone->api_token)
|
||||||
|
->post("https://api.cloudflare.com/client/v4/zones/{$zone->zone_id}/purge_cache", [
|
||||||
|
'purge_everything' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! ($response->json('success') ?? false)) {
|
||||||
|
throw new Exception($response->json('errors.0.message') ?? 'Cloudflare request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Cache purged'))->success()->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()->title(__('Purge failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Action::make('disconnect')
|
||||||
|
->label(__('Disconnect'))
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Domain $record) => $this->getZone($record) !== null)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Domain $record): void {
|
||||||
|
$zone = $this->getZone($record);
|
||||||
|
if ($zone) {
|
||||||
|
$zone->delete();
|
||||||
|
}
|
||||||
|
Notification::make()->title(__('Cloudflare disconnected'))->success()->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No domains found'))
|
||||||
|
->emptyStateDescription(__('Add a domain before connecting to a CDN'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getZone(Domain $record): ?CloudflareZone
|
||||||
|
{
|
||||||
|
return CloudflareZone::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->where('domain_id', $record->id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function fetchCloudflareZone(string $domain, string $token): array
|
||||||
|
{
|
||||||
|
$response = Http::withToken($token)->get('https://api.cloudflare.com/client/v4/zones', [
|
||||||
|
'name' => $domain,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! ($response->json('success') ?? false)) {
|
||||||
|
throw new Exception($response->json('errors.0.message') ?? 'Cloudflare request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zone = $response->json('result.0');
|
||||||
|
if (! $zone) {
|
||||||
|
throw new Exception(__('Zone not found for :domain', ['domain' => $domain]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $zone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -452,6 +452,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
|
|||||||
->helperText(__('This name will be used for both the database and user')),
|
->helperText(__('This name will be used for both the database and user')),
|
||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
|
$limit = Auth::user()?->hostingPackage?->databases_limit;
|
||||||
|
if ($limit && count($this->databases) >= $limit) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Database limit reached'))
|
||||||
|
->body(__('Your hosting package allows up to :limit databases.', ['limit' => $limit]))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$name = $this->getUsername().'_'.$data['name'];
|
$name = $this->getUsername().'_'.$data['name'];
|
||||||
$password = $this->generateSecurePassword();
|
$password = $this->generateSecurePassword();
|
||||||
|
|
||||||
@@ -513,6 +524,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
|
|||||||
->helperText(__('Only alphanumeric characters allowed')),
|
->helperText(__('Only alphanumeric characters allowed')),
|
||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
|
$limit = Auth::user()?->hostingPackage?->databases_limit;
|
||||||
|
if ($limit && count($this->databases) >= $limit) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Database limit reached'))
|
||||||
|
->body(__('Your hosting package allows up to :limit databases.', ['limit' => $limit]))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$name = $this->getUsername().'_'.$data['name'];
|
$name = $this->getUsername().'_'.$data['name'];
|
||||||
try {
|
try {
|
||||||
$this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name);
|
$this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Filament\Jabali\Pages;
|
|||||||
|
|
||||||
use App\Filament\Concerns\HasPageTour;
|
use App\Filament\Concerns\HasPageTour;
|
||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
|
use App\Models\DomainAlias;
|
||||||
use App\Models\DomainHotlinkSetting;
|
use App\Models\DomainHotlinkSetting;
|
||||||
use App\Models\DomainRedirect;
|
use App\Models\DomainRedirect;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
@@ -35,12 +36,12 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class Domains extends Page implements HasForms, HasActions, HasTable
|
class Domains extends Page implements HasActions, HasForms, HasTable
|
||||||
{
|
{
|
||||||
use InteractsWithForms;
|
|
||||||
use InteractsWithActions;
|
|
||||||
use InteractsWithTable;
|
|
||||||
use HasPageTour;
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
|
||||||
|
|
||||||
@@ -63,8 +64,9 @@ class Domains extends Page implements HasForms, HasActions, HasTable
|
|||||||
public function getAgent(): AgentClient
|
public function getAgent(): AgentClient
|
||||||
{
|
{
|
||||||
if ($this->agent === null) {
|
if ($this->agent === null) {
|
||||||
$this->agent = new AgentClient();
|
$this->agent = new AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->agent;
|
return $this->agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +138,28 @@ class Domains extends Page implements HasForms, HasActions, HasTable
|
|||||||
->form(fn (Domain $record) => $this->getRedirectsForm($record))
|
->form(fn (Domain $record) => $this->getRedirectsForm($record))
|
||||||
->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record))
|
->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record))
|
||||||
->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)),
|
->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)),
|
||||||
|
Action::make('aliases')
|
||||||
|
->label(__('Aliases'))
|
||||||
|
->icon('heroicon-o-link')
|
||||||
|
->color('info')
|
||||||
|
->modalHeading(fn (Domain $record) => __('Aliases for :domain', ['domain' => $record->domain]))
|
||||||
|
->modalDescription(__('Point additional domains to the same website content.'))
|
||||||
|
->modalWidth(Width::Large)
|
||||||
|
->modalSubmitActionLabel(__('Save Aliases'))
|
||||||
|
->form(fn (Domain $record) => $this->getAliasesForm($record))
|
||||||
|
->fillForm(fn (Domain $record) => $this->getAliasesFormData($record))
|
||||||
|
->action(fn (Domain $record, array $data) => $this->saveAliases($record, $data)),
|
||||||
|
Action::make('errorPages')
|
||||||
|
->label(__('Error Pages'))
|
||||||
|
->icon('heroicon-o-document-text')
|
||||||
|
->color('gray')
|
||||||
|
->modalHeading(fn (Domain $record) => __('Custom Error Pages for :domain', ['domain' => $record->domain]))
|
||||||
|
->modalDescription(__('Customize the 404, 500 and 503 pages shown to visitors.'))
|
||||||
|
->modalWidth(Width::TwoExtraLarge)
|
||||||
|
->modalSubmitActionLabel(__('Save Error Pages'))
|
||||||
|
->form(fn (Domain $record) => $this->getErrorPagesForm())
|
||||||
|
->fillForm(fn (Domain $record) => $this->getErrorPagesFormData($record))
|
||||||
|
->action(fn (Domain $record, array $data) => $this->saveErrorPages($record, $data)),
|
||||||
Action::make('hotlink')
|
Action::make('hotlink')
|
||||||
->label(__('Hotlink Protection'))
|
->label(__('Hotlink Protection'))
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
@@ -379,6 +403,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
|
|||||||
'redirect_url' => '',
|
'redirect_url' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'is_enabled' => $setting->is_enabled,
|
'is_enabled' => $setting->is_enabled,
|
||||||
'allowed_domains' => $setting->allowed_domains,
|
'allowed_domains' => $setting->allowed_domains,
|
||||||
@@ -435,6 +460,18 @@ class Domains extends Page implements HasForms, HasActions, HasTable
|
|||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
try {
|
try {
|
||||||
|
$user = Auth::user();
|
||||||
|
$limit = $user?->hostingPackage?->domains_limit;
|
||||||
|
if ($limit && Domain::where('user_id', $user->id)->count() >= $limit) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Domain limit reached'))
|
||||||
|
->body(__('Your hosting package allows up to :limit domains.', ['limit' => $limit]))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']);
|
$result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
@@ -792,4 +829,178 @@ class Domains extends Page implements HasForms, HasActions, HasTable
|
|||||||
$path = str_replace('/home/'.$this->getUsername().'/', '', $domain->document_root);
|
$path = str_replace('/home/'.$this->getUsername().'/', '', $domain->document_root);
|
||||||
$this->redirect(route('filament.jabali.pages.files', ['path' => $path]));
|
$this->redirect(route('filament.jabali.pages.files', ['path' => $path]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getAliasesForm(Domain $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Repeater::make('aliases')
|
||||||
|
->label(__('Domain Aliases'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('alias')
|
||||||
|
->label(__('Alias Domain'))
|
||||||
|
->placeholder('alias-example.com')
|
||||||
|
->required()
|
||||||
|
->rule('regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\\.[a-z]{2,}$/')
|
||||||
|
->helperText(__('Enter a full domain name.')),
|
||||||
|
])
|
||||||
|
->addActionLabel(__('Add Alias'))
|
||||||
|
->columns(1)
|
||||||
|
->defaultItems(0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAliasesFormData(Domain $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'aliases' => $record->aliases()
|
||||||
|
->orderBy('alias')
|
||||||
|
->get()
|
||||||
|
->map(fn (DomainAlias $alias) => ['alias' => $alias->alias])
|
||||||
|
->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function saveAliases(Domain $record, array $data): void
|
||||||
|
{
|
||||||
|
$aliases = collect($data['aliases'] ?? [])
|
||||||
|
->pluck('alias')
|
||||||
|
->map(fn ($alias) => strtolower(trim((string) $alias)))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$existing = $record->aliases()->pluck('alias')->map(fn ($alias) => strtolower($alias));
|
||||||
|
|
||||||
|
$toAdd = $aliases->diff($existing);
|
||||||
|
$toRemove = $existing->diff($aliases);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($toAdd as $alias) {
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->domainAliasAdd($this->getUsername(), $record->domain, $alias);
|
||||||
|
if (! ($result['success'] ?? false)) {
|
||||||
|
throw new Exception($result['error'] ?? 'Failed to add alias');
|
||||||
|
}
|
||||||
|
DomainAlias::firstOrCreate([
|
||||||
|
'domain_id' => $record->id,
|
||||||
|
'alias' => $alias,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$errors[] = $alias.': '.$e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($toRemove as $alias) {
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->domainAliasRemove($this->getUsername(), $record->domain, $alias);
|
||||||
|
if (! ($result['success'] ?? false)) {
|
||||||
|
throw new Exception($result['error'] ?? 'Failed to remove alias');
|
||||||
|
}
|
||||||
|
$record->aliases()->where('alias', $alias)->delete();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$errors[] = $alias.': '.$e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($errors)) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Some aliases failed'))
|
||||||
|
->body(implode("\n", $errors))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Aliases updated'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getErrorPagesForm(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Textarea::make('page_404')
|
||||||
|
->label(__('404 Page (Not Found)'))
|
||||||
|
->rows(6)
|
||||||
|
->placeholder(__('HTML content for your 404 page')),
|
||||||
|
Textarea::make('page_500')
|
||||||
|
->label(__('500 Page (Server Error)'))
|
||||||
|
->rows(6)
|
||||||
|
->placeholder(__('HTML content for your 500 page')),
|
||||||
|
Textarea::make('page_503')
|
||||||
|
->label(__('503 Page (Maintenance)'))
|
||||||
|
->rows(6)
|
||||||
|
->placeholder(__('HTML content for your 503 page')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getErrorPagesFormData(Domain $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page_404' => $this->readErrorPageContent($record, '404.html'),
|
||||||
|
'page_500' => $this->readErrorPageContent($record, '500.html'),
|
||||||
|
'page_503' => $this->readErrorPageContent($record, '503.html'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function saveErrorPages(Domain $record, array $data): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->domainEnsureErrorPages($this->getUsername(), $record->domain);
|
||||||
|
if (! ($result['success'] ?? false)) {
|
||||||
|
throw new Exception($result['error'] ?? 'Failed to enable error pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['404' => 'page_404', '500' => 'page_500', '503' => 'page_503'] as $code => $key) {
|
||||||
|
$content = trim((string) ($data[$key] ?? ''));
|
||||||
|
$path = $this->getErrorPagePath($record, "{$code}.html");
|
||||||
|
|
||||||
|
if ($content === '') {
|
||||||
|
try {
|
||||||
|
$this->getAgent()->fileDelete($this->getUsername(), $path);
|
||||||
|
} catch (Exception) {
|
||||||
|
// Ignore missing file
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->getAgent()->fileWrite($this->getUsername(), $path, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Error pages updated'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to update error pages'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function readErrorPageContent(Domain $record, string $filename): string
|
||||||
|
{
|
||||||
|
$path = $this->getErrorPagePath($record, $filename);
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->fileRead($this->getUsername(), $path);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
return (string) base64_decode($result['content'] ?? '');
|
||||||
|
}
|
||||||
|
} catch (Exception) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getErrorPagePath(Domain $record, string $filename): string
|
||||||
|
{
|
||||||
|
return "domains/{$record->domain}/public_html/{$filename}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use App\Models\Domain;
|
|||||||
use App\Models\EmailDomain;
|
use App\Models\EmailDomain;
|
||||||
use App\Models\EmailForwarder;
|
use App\Models\EmailForwarder;
|
||||||
use App\Models\Mailbox;
|
use App\Models\Mailbox;
|
||||||
|
use App\Models\UserSetting;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
|
use App\Services\System\MailRoutingSyncService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@@ -65,6 +67,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
public string $credPassword = '';
|
public string $credPassword = '';
|
||||||
|
|
||||||
|
public array $spamFormData = [];
|
||||||
|
|
||||||
protected ?AgentClient $agent = null;
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
public function getTitle(): string|Htmlable
|
public function getTitle(): string|Htmlable
|
||||||
@@ -76,11 +80,18 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
{
|
{
|
||||||
// Normalize the tab value from URL
|
// Normalize the tab value from URL
|
||||||
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
||||||
|
|
||||||
|
if ($this->activeTab === 'spam') {
|
||||||
|
$this->loadSpamSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedActiveTab(): void
|
public function updatedActiveTab(): void
|
||||||
{
|
{
|
||||||
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
||||||
|
if ($this->activeTab === 'spam') {
|
||||||
|
$this->loadSpamSettings();
|
||||||
|
}
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +110,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'autoresponders', 'Autoresponders' => 'autoresponders',
|
'autoresponders', 'Autoresponders' => 'autoresponders',
|
||||||
'catchall', 'catch-all', 'Catch-All' => 'catchall',
|
'catchall', 'catch-all', 'Catch-All' => 'catchall',
|
||||||
'logs', 'Logs' => 'logs',
|
'logs', 'Logs' => 'logs',
|
||||||
|
'spam', 'Spam' => 'spam',
|
||||||
default => 'mailboxes',
|
default => 'mailboxes',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -111,13 +123,14 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'autoresponders' => 3,
|
'autoresponders' => 3,
|
||||||
'catchall' => 4,
|
'catchall' => 4,
|
||||||
'logs' => 5,
|
'logs' => 5,
|
||||||
|
'spam' => 6,
|
||||||
default => 1,
|
default => 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getForms(): array
|
protected function getForms(): array
|
||||||
{
|
{
|
||||||
return ['emailForm'];
|
return ['emailForm', 'spamForm'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function emailForm(Schema $schema): Schema
|
public function emailForm(Schema $schema): Schema
|
||||||
@@ -127,9 +140,37 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function spamForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('spamFormData')
|
||||||
|
->schema([
|
||||||
|
Section::make(__('Spam Settings'))
|
||||||
|
->schema([
|
||||||
|
Textarea::make('whitelist')
|
||||||
|
->label(__('Whitelist (one per line)'))
|
||||||
|
->rows(6)
|
||||||
|
->placeholder("friend@example.com\ntrusted.com"),
|
||||||
|
Textarea::make('blacklist')
|
||||||
|
->label(__('Blacklist (one per line)'))
|
||||||
|
->rows(6)
|
||||||
|
->placeholder("spam@example.com\nbad-domain.com"),
|
||||||
|
TextInput::make('score')
|
||||||
|
->label(__('Spam Score Threshold'))
|
||||||
|
->numeric()
|
||||||
|
->default(6.0)
|
||||||
|
->helperText(__('Lower values are stricter, higher values are more permissive.')),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function setTab(string $tab): void
|
public function setTab(string $tab): void
|
||||||
{
|
{
|
||||||
$this->activeTab = $this->normalizeTabName($tab);
|
$this->activeTab = $this->normalizeTabName($tab);
|
||||||
|
if ($this->activeTab === 'spam') {
|
||||||
|
$this->loadSpamSettings();
|
||||||
|
}
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +211,60 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
return str_shuffle($password);
|
return str_shuffle($password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function loadSpamSettings(): void
|
||||||
|
{
|
||||||
|
$settings = UserSetting::getForUser(Auth::id(), 'spam_settings', [
|
||||||
|
'whitelist' => [],
|
||||||
|
'blacklist' => [],
|
||||||
|
'score' => 6.0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->spamFormData = [
|
||||||
|
'whitelist' => implode("\n", $settings['whitelist'] ?? []),
|
||||||
|
'blacklist' => implode("\n", $settings['blacklist'] ?? []),
|
||||||
|
'score' => $settings['score'] ?? 6.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSpamSettings(): void
|
||||||
|
{
|
||||||
|
$data = $this->spamForm->getState();
|
||||||
|
$whitelist = $this->linesToArray($data['whitelist'] ?? '');
|
||||||
|
$blacklist = $this->linesToArray($data['blacklist'] ?? '');
|
||||||
|
$score = isset($data['score']) && $data['score'] !== '' ? (float) $data['score'] : null;
|
||||||
|
|
||||||
|
UserSetting::setForUser(Auth::id(), 'spam_settings', [
|
||||||
|
'whitelist' => $whitelist,
|
||||||
|
'blacklist' => $blacklist,
|
||||||
|
'score' => $score,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->getAgent()->rspamdUserSettings($this->getUsername(), $whitelist, $blacklist, $score);
|
||||||
|
if (! ($result['success'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to update spam settings'))
|
||||||
|
->body($result['error'] ?? '')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Spam settings updated'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function linesToArray(string $value): array
|
||||||
|
{
|
||||||
|
return collect(preg_split('/\\r\\n|\\r|\\n/', $value))
|
||||||
|
->map(fn ($line) => trim((string) $line))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@@ -178,6 +273,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'autoresponders' => $this->autorespondersTable($table),
|
'autoresponders' => $this->autorespondersTable($table),
|
||||||
'catchall' => $this->catchAllTable($table),
|
'catchall' => $this->catchAllTable($table),
|
||||||
'logs' => $this->emailLogsTable($table),
|
'logs' => $this->emailLogsTable($table),
|
||||||
|
'spam' => $this->mailboxesTable($table),
|
||||||
default => $this->mailboxesTable($table),
|
default => $this->mailboxesTable($table),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -778,6 +874,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
// Generate DKIM
|
// Generate DKIM
|
||||||
try {
|
try {
|
||||||
$dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain);
|
$dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain);
|
||||||
@@ -830,6 +928,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
return $emailDomain;
|
return $emailDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function syncMailRouting(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
app(MailRoutingSyncService::class)->sync();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Mail routing sync failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function regenerateDnsZone(Domain $domain): void
|
protected function regenerateDnsZone(Domain $domain): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -924,6 +1035,17 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
->helperText(__('Storage limit in megabytes')),
|
->helperText(__('Storage limit in megabytes')),
|
||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
|
$limit = Auth::user()?->hostingPackage?->mailboxes_limit;
|
||||||
|
if ($limit && Mailbox::where('user_id', Auth::id())->count() >= $limit) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Mailbox limit reached'))
|
||||||
|
->body(__('Your hosting package allows up to :limit mailboxes.', ['limit' => $limit]))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$domain = Domain::where('user_id', Auth::id())->find($data['domain_id']);
|
$domain = Domain::where('user_id', Auth::id())->find($data['domain_id']);
|
||||||
if (! $domain) {
|
if (! $domain) {
|
||||||
Notification::make()->title(__('Domain not found'))->danger()->send();
|
Notification::make()->title(__('Domain not found'))->danger()->send();
|
||||||
@@ -965,6 +1087,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
$this->credEmail = $email;
|
$this->credEmail = $email;
|
||||||
$this->credPassword = $data['password'];
|
$this->credPassword = $data['password'];
|
||||||
|
|
||||||
@@ -1016,6 +1140,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus);
|
$this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus);
|
||||||
$mailbox->update(['is_active' => $newStatus]);
|
$mailbox->update(['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled'))
|
->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled'))
|
||||||
->success()
|
->success()
|
||||||
@@ -1037,6 +1163,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
$mailbox->delete();
|
$mailbox->delete();
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()->title(__('Mailbox deleted'))->success()->send();
|
Notification::make()->title(__('Mailbox deleted'))->success()->send();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
|
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
|
||||||
@@ -1122,6 +1250,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()->title(__('Forwarder created'))->success()->send();
|
Notification::make()->title(__('Forwarder created'))->success()->send();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send();
|
Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send();
|
||||||
@@ -1149,6 +1279,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
$forwarder->update(['destinations' => $destinations]);
|
$forwarder->update(['destinations' => $destinations]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()->title(__('Forwarder updated'))->success()->send();
|
Notification::make()->title(__('Forwarder updated'))->success()->send();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
|
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
|
||||||
@@ -1173,6 +1305,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
]);
|
]);
|
||||||
$forwarder->update(['is_active' => $newStatus]);
|
$forwarder->update(['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled'))
|
->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled'))
|
||||||
->success()
|
->success()
|
||||||
@@ -1192,6 +1326,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
$forwarder->delete();
|
$forwarder->delete();
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()->title(__('Forwarder deleted'))->success()->send();
|
Notification::make()->title(__('Forwarder deleted'))->success()->send();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
|
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
|
||||||
@@ -1404,6 +1540,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
'catch_all_address' => $enabled ? $address : null,
|
'catch_all_address' => $enabled ? $address : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncMailRouting();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled'))
|
->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled'))
|
||||||
->success()
|
->success()
|
||||||
|
|||||||
316
app/Filament/Jabali/Pages/GitDeployment.php
Normal file
316
app/Filament/Jabali/Pages/GitDeployment.php
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\HasPageTour;
|
||||||
|
use App\Jobs\RunGitDeployment;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\GitDeployment as GitDeploymentModel;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
||||||
|
{
|
||||||
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-code-bracket-square';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 16;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'git-deployment';
|
||||||
|
|
||||||
|
protected string $view = 'filament.jabali.pages.git-deployment';
|
||||||
|
|
||||||
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
public ?string $deployKey = null;
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Git Deployment');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Git Deployment');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAgent(): AgentClient
|
||||||
|
{
|
||||||
|
if ($this->agent === null) {
|
||||||
|
$this->agent = new AgentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUsername(): string
|
||||||
|
{
|
||||||
|
return Auth::user()->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDomainOptions(): array
|
||||||
|
{
|
||||||
|
return Domain::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->orderBy('domain')
|
||||||
|
->pluck('domain', 'id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getWebhookUrl(GitDeploymentModel $deployment): string
|
||||||
|
{
|
||||||
|
return url("/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDeployKey(): string
|
||||||
|
{
|
||||||
|
if ($this->deployKey) {
|
||||||
|
return $this->deployKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->gitGenerateKey($this->getUsername());
|
||||||
|
$this->deployKey = $result['public_key'] ?? '';
|
||||||
|
} catch (Exception) {
|
||||||
|
$this->deployKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->deployKey ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(GitDeploymentModel::query()->where('user_id', Auth::id())->with('domain'))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('domain.domain')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('repo_url')
|
||||||
|
->label(__('Repository'))
|
||||||
|
->limit(40)
|
||||||
|
->tooltip(fn (GitDeploymentModel $record) => $record->repo_url)
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('branch')
|
||||||
|
->label(__('Branch'))
|
||||||
|
->badge()
|
||||||
|
->color('gray'),
|
||||||
|
IconColumn::make('auto_deploy')
|
||||||
|
->label(__('Auto Deploy'))
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('last_status')
|
||||||
|
->label(__('Status'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state): string => match ($state) {
|
||||||
|
'success' => 'success',
|
||||||
|
'failed' => 'danger',
|
||||||
|
'running' => 'warning',
|
||||||
|
'queued' => 'info',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->default('never'),
|
||||||
|
TextColumn::make('last_deployed_at')
|
||||||
|
->label(__('Last Deployed'))
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('deploy')
|
||||||
|
->label(__('Deploy'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->action(function (GitDeploymentModel $record): void {
|
||||||
|
$record->update(['last_status' => 'queued']);
|
||||||
|
RunGitDeployment::dispatch($record->id);
|
||||||
|
Notification::make()->title(__('Deployment queued'))->success()->send();
|
||||||
|
}),
|
||||||
|
Action::make('webhook')
|
||||||
|
->label(__('Webhook'))
|
||||||
|
->icon('heroicon-o-link')
|
||||||
|
->color('gray')
|
||||||
|
->modalHeading(__('Webhook URL'))
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel(__('Close'))
|
||||||
|
->form([
|
||||||
|
Textarea::make('webhook_url')
|
||||||
|
->label(__('Webhook URL'))
|
||||||
|
->rows(2)
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
|
Textarea::make('deploy_key')
|
||||||
|
->label(__('Deploy Key'))
|
||||||
|
->rows(3)
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
|
])
|
||||||
|
->fillForm(fn (GitDeploymentModel $record): array => [
|
||||||
|
'webhook_url' => $this->getWebhookUrl($record),
|
||||||
|
'deploy_key' => $this->getDeployKey(),
|
||||||
|
]),
|
||||||
|
Action::make('edit')
|
||||||
|
->label(__('Edit'))
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->color('gray')
|
||||||
|
->modalHeading(__('Edit Deployment'))
|
||||||
|
->form($this->getDeploymentForm())
|
||||||
|
->fillForm(fn (GitDeploymentModel $record): array => [
|
||||||
|
'domain_id' => $record->domain_id,
|
||||||
|
'repo_url' => $record->repo_url,
|
||||||
|
'branch' => $record->branch,
|
||||||
|
'deploy_path' => $record->deploy_path,
|
||||||
|
'auto_deploy' => $record->auto_deploy,
|
||||||
|
'deploy_script' => $record->deploy_script,
|
||||||
|
'framework_preset' => 'custom',
|
||||||
|
])
|
||||||
|
->action(function (GitDeploymentModel $record, array $data): void {
|
||||||
|
$record->update([
|
||||||
|
'domain_id' => $data['domain_id'],
|
||||||
|
'repo_url' => $data['repo_url'],
|
||||||
|
'branch' => $data['branch'],
|
||||||
|
'deploy_path' => $data['deploy_path'],
|
||||||
|
'auto_deploy' => $data['auto_deploy'] ?? false,
|
||||||
|
'deploy_script' => $data['deploy_script'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->title(__('Deployment updated'))->success()->send();
|
||||||
|
}),
|
||||||
|
Action::make('delete')
|
||||||
|
->label(__('Delete'))
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (GitDeploymentModel $record): void {
|
||||||
|
$record->delete();
|
||||||
|
Notification::make()->title(__('Deployment deleted'))->success()->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No deployments yet'))
|
||||||
|
->emptyStateDescription(__('Add a repository to deploy to your domain'))
|
||||||
|
->emptyStateIcon('heroicon-o-code-bracket-square');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('addDeployment')
|
||||||
|
->label(__('Add Deployment'))
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->color('primary')
|
||||||
|
->form($this->getDeploymentForm())
|
||||||
|
->action(function (array $data): void {
|
||||||
|
GitDeploymentModel::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'domain_id' => $data['domain_id'],
|
||||||
|
'repo_url' => $data['repo_url'],
|
||||||
|
'branch' => $data['branch'],
|
||||||
|
'deploy_path' => $data['deploy_path'],
|
||||||
|
'auto_deploy' => $data['auto_deploy'] ?? false,
|
||||||
|
'deploy_script' => $data['deploy_script'] ?? null,
|
||||||
|
'secret_token' => Str::random(40),
|
||||||
|
'last_status' => 'never',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->title(__('Deployment created'))->success()->send();
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
Action::make('deployKey')
|
||||||
|
->label(__('Deploy Key'))
|
||||||
|
->icon('heroicon-o-key')
|
||||||
|
->color('gray')
|
||||||
|
->modalHeading(__('SSH Deploy Key'))
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel(__('Close'))
|
||||||
|
->form([
|
||||||
|
Textarea::make('public_key')
|
||||||
|
->label(__('Public Key'))
|
||||||
|
->rows(3)
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
|
])
|
||||||
|
->fillForm(fn (): array => ['public_key' => $this->getDeployKey()]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDeploymentForm(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Select::make('domain_id')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->options($this->getDomainOptions())
|
||||||
|
->searchable()
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function ($state, callable $set): void {
|
||||||
|
if (! $state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$domain = Domain::where('id', $state)->where('user_id', Auth::id())->first();
|
||||||
|
if ($domain) {
|
||||||
|
$set('deploy_path', $domain->document_root);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TextInput::make('repo_url')
|
||||||
|
->label(__('Repository URL'))
|
||||||
|
->placeholder('git@github.com:org/repo.git')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('branch')
|
||||||
|
->label(__('Branch'))
|
||||||
|
->default('main')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('deploy_path')
|
||||||
|
->label(__('Deploy Path'))
|
||||||
|
->helperText(__('Must be inside your home directory'))
|
||||||
|
->required(),
|
||||||
|
Select::make('framework_preset')
|
||||||
|
->label(__('Framework Preset'))
|
||||||
|
->options([
|
||||||
|
'custom' => __('Custom'),
|
||||||
|
'laravel' => __('Laravel'),
|
||||||
|
'symfony' => __('Symfony'),
|
||||||
|
])
|
||||||
|
->default('custom')
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function ($state, callable $set): void {
|
||||||
|
if ($state === 'laravel') {
|
||||||
|
$set('deploy_script', "composer install --no-dev --optimize-autoloader\nphp artisan migrate --force\nphp artisan config:cache\nphp artisan route:cache\nphp artisan view:cache");
|
||||||
|
} elseif ($state === 'symfony') {
|
||||||
|
$set('deploy_script', "composer install --no-dev --optimize-autoloader\nphp bin/console cache:clear --no-warmup\nphp bin/console cache:warmup");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Textarea::make('deploy_script')
|
||||||
|
->label(__('Deploy Script (optional)'))
|
||||||
|
->rows(6)
|
||||||
|
->helperText(__('Run after code is deployed. Leave empty to skip.')),
|
||||||
|
Toggle::make('auto_deploy')
|
||||||
|
->label(__('Enable auto-deploy from webhook'))
|
||||||
|
->default(false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
157
app/Filament/Jabali/Pages/ImageOptimization.php
Normal file
157
app/Filament/Jabali/Pages/ImageOptimization.php
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\HasPageTour;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class ImageOptimization extends Page implements HasActions, HasForms
|
||||||
|
{
|
||||||
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-photo';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 21;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'image-optimization';
|
||||||
|
|
||||||
|
protected string $view = 'filament.jabali.pages.image-optimization';
|
||||||
|
|
||||||
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
public array $imageFormData = [];
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Image Optimization');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Image Optimization');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAgent(): AgentClient
|
||||||
|
{
|
||||||
|
if ($this->agent === null) {
|
||||||
|
$this->agent = new AgentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUsername(): string
|
||||||
|
{
|
||||||
|
return Auth::user()->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDomainOptions(): array
|
||||||
|
{
|
||||||
|
return Domain::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->orderBy('domain')
|
||||||
|
->pluck('domain', 'id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getForms(): array
|
||||||
|
{
|
||||||
|
return ['imageForm'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function imageForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('imageFormData')
|
||||||
|
->schema([
|
||||||
|
Section::make(__('Optimization Settings'))
|
||||||
|
->schema([
|
||||||
|
Select::make('domain_id')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->options($this->getDomainOptions())
|
||||||
|
->searchable()
|
||||||
|
->required(),
|
||||||
|
TextInput::make('path')
|
||||||
|
->label(__('Custom Path (optional)'))
|
||||||
|
->helperText(__('Leave empty to optimize the selected domain root')),
|
||||||
|
Toggle::make('convert_webp')
|
||||||
|
->label(__('Generate WebP copies'))
|
||||||
|
->default(false),
|
||||||
|
TextInput::make('quality')
|
||||||
|
->label(__('Quality (40-95)'))
|
||||||
|
->numeric()
|
||||||
|
->default(82),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runOptimization(): void
|
||||||
|
{
|
||||||
|
$data = $this->imageForm->getState();
|
||||||
|
$domainId = $data['domain_id'] ?? null;
|
||||||
|
|
||||||
|
if (! $domainId) {
|
||||||
|
Notification::make()->title(__('Select a domain'))->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = Domain::where('id', $domainId)->where('user_id', Auth::id())->first();
|
||||||
|
if (! $domain) {
|
||||||
|
Notification::make()->title(__('Domain not found'))->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim((string) ($data['path'] ?? ''));
|
||||||
|
if ($path === '') {
|
||||||
|
$path = $domain->document_root;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->imageOptimize(
|
||||||
|
$this->getUsername(),
|
||||||
|
$path,
|
||||||
|
(bool) ($data['convert_webp'] ?? false),
|
||||||
|
(int) ($data['quality'] ?? 82)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
$optimized = $result['optimized'] ?? [];
|
||||||
|
$message = __('Optimization complete');
|
||||||
|
if (! empty($optimized)) {
|
||||||
|
$message .= ' · JPG: '.($optimized['jpg'] ?? 0).', PNG: '.($optimized['png'] ?? 0).', WebP: '.($optimized['webp'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title($message)->success()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Optimization failed'))->body($result['error'] ?? '')->danger()->send();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()->title(__('Optimization failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Jabali\Pages;
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
use App\Filament\Concerns\HasPageTour;
|
use App\Filament\Concerns\HasPageTour;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\UserResourceUsage;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@@ -42,6 +44,9 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
#[Url]
|
#[Url]
|
||||||
public ?string $selectedDomain = null;
|
public ?string $selectedDomain = null;
|
||||||
|
|
||||||
|
#[Url(as: 'tab')]
|
||||||
|
public string $activeTab = 'logs';
|
||||||
|
|
||||||
public string $logType = 'access';
|
public string $logType = 'access';
|
||||||
|
|
||||||
public int $logLines = 100;
|
public int $logLines = 100;
|
||||||
@@ -64,6 +69,7 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->loadDomains();
|
$this->loadDomains();
|
||||||
|
$this->activeTab = $this->normalizeTab($this->activeTab);
|
||||||
|
|
||||||
if (! empty($this->domains) && ! $this->selectedDomain) {
|
if (! empty($this->domains) && ! $this->selectedDomain) {
|
||||||
$this->selectedDomain = $this->domains[0]['domain'] ?? null;
|
$this->selectedDomain = $this->domains[0]['domain'] ?? null;
|
||||||
@@ -74,6 +80,27 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedActiveTab(): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $this->normalizeTab($this->activeTab);
|
||||||
|
if ($this->activeTab === 'logs' && $this->selectedDomain) {
|
||||||
|
$this->loadLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTab(string $tab): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $this->normalizeTab($tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeTab(?string $tab): string
|
||||||
|
{
|
||||||
|
return match ($tab) {
|
||||||
|
'logs', 'usage', 'activity', 'stats' => (string) $tab,
|
||||||
|
default => 'logs',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected function getAgent(): AgentClient
|
protected function getAgent(): AgentClient
|
||||||
{
|
{
|
||||||
if ($this->agent === null) {
|
if ($this->agent === null) {
|
||||||
@@ -90,11 +117,15 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
|
|
||||||
protected function loadDomains(): void
|
protected function loadDomains(): void
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$result = $this->getAgent()->send('domain.list', [
|
$result = $this->getAgent()->send('domain.list', [
|
||||||
'username' => $this->getUsername(),
|
'username' => $this->getUsername(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : [];
|
$this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : [];
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$this->domains = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDomainOptions(): array
|
public function getDomainOptions(): array
|
||||||
@@ -164,6 +195,66 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUsageChartData(): array
|
||||||
|
{
|
||||||
|
$start = now()->subDays(29)->startOfDay();
|
||||||
|
$end = now()->endOfDay();
|
||||||
|
$records = UserResourceUsage::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->whereBetween('captured_at', [$start, $end])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
for ($i = 0; $i < 30; $i++) {
|
||||||
|
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
|
||||||
|
}
|
||||||
|
$index = array_flip($labels);
|
||||||
|
|
||||||
|
$metrics = ['disk_bytes', 'database_bytes', 'mail_bytes', 'bandwidth_bytes'];
|
||||||
|
$values = [];
|
||||||
|
foreach ($metrics as $metric) {
|
||||||
|
$values[$metric] = array_fill(0, count($labels), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$date = $record->captured_at?->format('Y-m-d');
|
||||||
|
if (! $date || ! isset($index[$date])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$idx = $index[$date];
|
||||||
|
$metric = $record->metric;
|
||||||
|
if (! isset($values[$metric])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($metric === 'bandwidth_bytes') {
|
||||||
|
$values[$metric][$idx] += (int) $record->value;
|
||||||
|
} else {
|
||||||
|
$values[$metric][$idx] = max($values[$metric][$idx], (int) $record->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$toGb = fn (int $bytes) => round($bytes / 1024 / 1024 / 1024, 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $labels,
|
||||||
|
'series' => [
|
||||||
|
['name' => __('Disk'), 'data' => array_map($toGb, $values['disk_bytes'])],
|
||||||
|
['name' => __('Databases'), 'data' => array_map($toGb, $values['database_bytes'])],
|
||||||
|
['name' => __('Mail'), 'data' => array_map($toGb, $values['mail_bytes'])],
|
||||||
|
['name' => __('Bandwidth'), 'data' => array_map($toGb, $values['bandwidth_bytes'])],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivityLogs()
|
||||||
|
{
|
||||||
|
return AuditLog::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->latest()
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
public function generateStats(): void
|
public function generateStats(): void
|
||||||
{
|
{
|
||||||
if (! $this->selectedDomain) {
|
if (! $this->selectedDomain) {
|
||||||
@@ -216,14 +307,14 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
->label(__('Generate Statistics'))
|
->label(__('Generate Statistics'))
|
||||||
->icon('heroicon-o-chart-bar')
|
->icon('heroicon-o-chart-bar')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn () => $this->selectedDomain !== null)
|
->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'stats')
|
||||||
->action(fn () => $this->generateStats()),
|
->action(fn () => $this->generateStats()),
|
||||||
|
|
||||||
Action::make('refreshLogs')
|
Action::make('refreshLogs')
|
||||||
->label(__('Refresh'))
|
->label(__('Refresh'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn () => $this->selectedDomain !== null)
|
->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'logs')
|
||||||
->action(fn () => $this->refreshLogs()),
|
->action(fn () => $this->refreshLogs()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
125
app/Filament/Jabali/Pages/MailingLists.php
Normal file
125
app/Filament/Jabali/Pages/MailingLists.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\HasPageTour;
|
||||||
|
use App\Models\UserSetting;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class MailingLists extends Page implements HasActions, HasForms
|
||||||
|
{
|
||||||
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-paper-airplane';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 22;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'mailing-lists';
|
||||||
|
|
||||||
|
protected string $view = 'filament.jabali.pages.mailing-lists';
|
||||||
|
|
||||||
|
public array $mailingFormData = [];
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Mailing Lists');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Mailing Lists');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mailingFormData = UserSetting::getForUser(Auth::id(), 'mailing_lists', [
|
||||||
|
'provider' => 'none',
|
||||||
|
'listmonk_url' => '',
|
||||||
|
'listmonk_token' => '',
|
||||||
|
'listmonk_list_id' => '',
|
||||||
|
'mailman_url' => '',
|
||||||
|
'mailman_admin' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getForms(): array
|
||||||
|
{
|
||||||
|
return ['mailingForm'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mailingForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('mailingFormData')
|
||||||
|
->schema([
|
||||||
|
Section::make(__('Provider'))
|
||||||
|
->schema([
|
||||||
|
Select::make('provider')
|
||||||
|
->label(__('Mailing List Provider'))
|
||||||
|
->options([
|
||||||
|
'none' => __('None'),
|
||||||
|
'listmonk' => __('Listmonk'),
|
||||||
|
'mailman' => __('Mailman'),
|
||||||
|
])
|
||||||
|
->default('none')
|
||||||
|
->live(),
|
||||||
|
]),
|
||||||
|
Section::make(__('Listmonk'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('listmonk_url')
|
||||||
|
->label(__('Listmonk URL'))
|
||||||
|
->placeholder('https://lists.example.com')
|
||||||
|
->url()
|
||||||
|
->visible(fn ($get) => $get('provider') === 'listmonk'),
|
||||||
|
TextInput::make('listmonk_token')
|
||||||
|
->label(__('API Token'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->visible(fn ($get) => $get('provider') === 'listmonk'),
|
||||||
|
TextInput::make('listmonk_list_id')
|
||||||
|
->label(__('Default List ID'))
|
||||||
|
->visible(fn ($get) => $get('provider') === 'listmonk'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make(__('Mailman'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('mailman_url')
|
||||||
|
->label(__('Mailman URL'))
|
||||||
|
->placeholder('https://lists.example.com/mailman')
|
||||||
|
->url()
|
||||||
|
->visible(fn ($get) => $get('provider') === 'mailman'),
|
||||||
|
TextInput::make('mailman_admin')
|
||||||
|
->label(__('Mailman Admin Email'))
|
||||||
|
->email()
|
||||||
|
->visible(fn ($get) => $get('provider') === 'mailman'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSettings(): void
|
||||||
|
{
|
||||||
|
UserSetting::setForUser(Auth::id(), 'mailing_lists', $this->mailingForm->getState());
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Mailing list settings saved'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
287
app/Filament/Jabali/Pages/PostgreSQL.php
Normal file
287
app/Filament/Jabali/Pages/PostgreSQL.php
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\HasPageTour;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Exception;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
|
||||||
|
class PostgreSQL extends Page implements HasActions, HasForms, HasTable
|
||||||
|
{
|
||||||
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 19;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'postgresql';
|
||||||
|
|
||||||
|
protected string $view = 'filament.jabali.pages.postgresql';
|
||||||
|
|
||||||
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
#[Url(as: 'tab')]
|
||||||
|
public string $activeTab = 'databases';
|
||||||
|
|
||||||
|
public array $databases = [];
|
||||||
|
|
||||||
|
public array $users = [];
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('PostgreSQL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('PostgreSQL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $this->normalizeTab($this->activeTab);
|
||||||
|
$this->loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedActiveTab(): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $this->normalizeTab($this->activeTab);
|
||||||
|
$this->loadData();
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeTab(string $tab): string
|
||||||
|
{
|
||||||
|
return in_array($tab, ['databases', 'users'], true) ? $tab : 'databases';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAgent(): AgentClient
|
||||||
|
{
|
||||||
|
if ($this->agent === null) {
|
||||||
|
$this->agent = new AgentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUsername(): string
|
||||||
|
{
|
||||||
|
return Auth::user()->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadData(): void
|
||||||
|
{
|
||||||
|
if ($this->activeTab === 'users') {
|
||||||
|
$this->loadUsers();
|
||||||
|
} else {
|
||||||
|
$this->loadDatabases();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadDatabases(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->postgresListDatabases($this->getUsername());
|
||||||
|
$this->databases = $result['databases'] ?? [];
|
||||||
|
} catch (Exception) {
|
||||||
|
$this->databases = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadUsers(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->postgresListUsers($this->getUsername());
|
||||||
|
$this->users = $result['users'] ?? [];
|
||||||
|
} catch (Exception) {
|
||||||
|
$this->users = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUserOptions(): array
|
||||||
|
{
|
||||||
|
if (empty($this->users)) {
|
||||||
|
$this->loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
foreach ($this->users as $user) {
|
||||||
|
$options[$user['username']] = $user['username'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
if ($this->activeTab === 'users') {
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->users)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('username')
|
||||||
|
->label(__('User'))
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('delete')
|
||||||
|
->label(__('Delete'))
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
$result = $this->getAgent()->postgresDeleteUser($this->getUsername(), $record['username']);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('User deleted'))->success()->send();
|
||||||
|
$this->loadUsers();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Deletion failed'))->body($result['error'] ?? '')->danger()->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No PostgreSQL users'))
|
||||||
|
->emptyStateDescription(__('Create a PostgreSQL user to manage databases'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $table
|
||||||
|
->records(fn () => $this->databases)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('Database'))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('size_bytes')
|
||||||
|
->label(__('Size'))
|
||||||
|
->formatStateUsing(fn ($state) => $this->formatBytes((int) $state))
|
||||||
|
->color('gray'),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('delete')
|
||||||
|
->label(__('Delete'))
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
$result = $this->getAgent()->postgresDeleteDatabase($this->getUsername(), $record['name']);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('Database deleted'))->success()->send();
|
||||||
|
$this->loadDatabases();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Deletion failed'))->body($result['error'] ?? '')->danger()->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->emptyStateHeading(__('No PostgreSQL databases'))
|
||||||
|
->emptyStateDescription(__('Create a PostgreSQL database to get started'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('createDatabase')
|
||||||
|
->label(__('Create Database'))
|
||||||
|
->icon('heroicon-o-circle-stack')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn () => $this->activeTab === 'databases')
|
||||||
|
->form([
|
||||||
|
TextInput::make('database')
|
||||||
|
->label(__('Database Name'))
|
||||||
|
->helperText(__('Use a name like :prefix_db', ['prefix' => $this->getUsername()]))
|
||||||
|
->required(),
|
||||||
|
Select::make('owner')
|
||||||
|
->label(__('Owner User'))
|
||||||
|
->options($this->getUserOptions())
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$result = $this->getAgent()->postgresCreateDatabase(
|
||||||
|
$this->getUsername(),
|
||||||
|
$data['database'],
|
||||||
|
$data['owner']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('Database created'))->success()->send();
|
||||||
|
$this->loadDatabases();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Creation failed'))->body($result['error'] ?? '')->danger()->send();
|
||||||
|
}),
|
||||||
|
Action::make('createUser')
|
||||||
|
->label(__('Create User'))
|
||||||
|
->icon('heroicon-o-user-plus')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn () => $this->activeTab === 'users')
|
||||||
|
->form([
|
||||||
|
TextInput::make('db_user')
|
||||||
|
->label(__('Username'))
|
||||||
|
->helperText(__('Use a name like :prefix_user', ['prefix' => $this->getUsername()]))
|
||||||
|
->required(),
|
||||||
|
TextInput::make('password')
|
||||||
|
->label(__('Password'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->default(fn () => Str::random(16))
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$result = $this->getAgent()->postgresCreateUser(
|
||||||
|
$this->getUsername(),
|
||||||
|
$data['db_user'],
|
||||||
|
$data['password']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('User created'))->success()->send();
|
||||||
|
$this->loadUsers();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title(__('Creation failed'))->body($result['error'] ?? '')->danger()->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatBytes(int $bytes, int $precision = 2): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$bytes = max($bytes, 0);
|
||||||
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
|
$pow = min($pow, count($units) - 1);
|
||||||
|
$bytes /= pow(1024, $pow);
|
||||||
|
|
||||||
|
return round($bytes, $precision).' '.$units[$pow];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,22 +4,25 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Jabali\Pages;
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\HasPageTour;
|
||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
use App\Models\MysqlCredential;
|
use App\Models\MysqlCredential;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Exception;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\Concerns\InteractsWithActions;
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
use Filament\Actions\Contracts\HasActions;
|
use Filament\Actions\Contracts\HasActions;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Columns\ViewColumn;
|
use Filament\Tables\Columns\ViewColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@@ -28,20 +31,18 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use App\Filament\Concerns\HasPageTour;
|
|
||||||
use BackedEnum;
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class WordPress extends Page implements HasForms, HasActions, HasTable
|
class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||||
{
|
{
|
||||||
protected static ?string $slug = 'wordpress';
|
protected static ?string $slug = 'wordpress';
|
||||||
use InteractsWithForms;
|
|
||||||
use InteractsWithActions;
|
|
||||||
use InteractsWithTable;
|
|
||||||
use HasPageTour;
|
use HasPageTour;
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 4;
|
protected static ?int $navigationSort = 4;
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
@@ -52,23 +53,32 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
protected string $view = 'filament.jabali.pages.wordpress';
|
protected string $view = 'filament.jabali.pages.wordpress';
|
||||||
|
|
||||||
public array $sites = [];
|
public array $sites = [];
|
||||||
|
|
||||||
public array $domains = [];
|
public array $domains = [];
|
||||||
|
|
||||||
public ?string $selectedSiteId = null;
|
public ?string $selectedSiteId = null;
|
||||||
|
|
||||||
// Credentials modal
|
// Credentials modal
|
||||||
public bool $showCredentials = false;
|
public bool $showCredentials = false;
|
||||||
|
|
||||||
public array $credentials = [];
|
public array $credentials = [];
|
||||||
|
|
||||||
// Scan modal
|
// Scan modal
|
||||||
public bool $showScanModal = false;
|
public bool $showScanModal = false;
|
||||||
|
|
||||||
public array $scannedSites = [];
|
public array $scannedSites = [];
|
||||||
|
|
||||||
public bool $isScanning = false;
|
public bool $isScanning = false;
|
||||||
|
|
||||||
// Security scan
|
// Security scan
|
||||||
public bool $showSecurityScanModal = false;
|
public bool $showSecurityScanModal = false;
|
||||||
|
|
||||||
public array $securityScanResults = [];
|
public array $securityScanResults = [];
|
||||||
|
|
||||||
public bool $isSecurityScanning = false;
|
public bool $isSecurityScanning = false;
|
||||||
|
|
||||||
public ?string $scanningSiteId = null;
|
public ?string $scanningSiteId = null;
|
||||||
|
|
||||||
public ?string $scanningSiteUrl = null;
|
public ?string $scanningSiteUrl = null;
|
||||||
|
|
||||||
protected ?AgentClient $agent = null;
|
protected ?AgentClient $agent = null;
|
||||||
@@ -81,8 +91,9 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
public function getAgent(): AgentClient
|
public function getAgent(): AgentClient
|
||||||
{
|
{
|
||||||
if ($this->agent === null) {
|
if ($this->agent === null) {
|
||||||
$this->agent = new AgentClient();
|
$this->agent = new AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->agent;
|
return $this->agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +216,17 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->alphaNum(),
|
->alphaNum(),
|
||||||
])
|
])
|
||||||
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
|
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
|
||||||
|
Action::make('pushStaging')
|
||||||
|
->label(__('Push to Production'))
|
||||||
|
->icon('heroicon-o-arrow-up-tray')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (array $record): bool => (bool) ($record['is_staging'] ?? false))
|
||||||
|
->modalHeading(__('Push Staging to Production'))
|
||||||
|
->modalDescription(__('This will replace the live site files and database with the staging version.'))
|
||||||
|
->modalIcon('heroicon-o-arrow-up-tray')
|
||||||
|
->modalIconColor('warning')
|
||||||
|
->action(fn (array $record) => $this->pushStaging($record['id'])),
|
||||||
Action::make('security')
|
Action::make('security')
|
||||||
->label(__('Security Scan'))
|
->label(__('Security Scan'))
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
@@ -365,6 +387,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->body(__('Please select at least one site to import.'))
|
->body(__('Please select at least one site to import.'))
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,6 +922,36 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pushStaging(string $stagingSiteId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Pushing staging to production...'))
|
||||||
|
->body(__('This may take several minutes.'))
|
||||||
|
->info()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$result = $this->getAgent()->wpPushStaging($this->getUsername(), $stagingSiteId);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Staging pushed to production'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
$this->loadData();
|
||||||
|
$this->resetTable();
|
||||||
|
} else {
|
||||||
|
throw new Exception($result['error'] ?? __('Failed to push staging site'));
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Push Failed'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function flushCache(string $siteId): void
|
public function flushCache(string $siteId): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -958,6 +1011,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->title(__('Site not found'))
|
->title(__('Site not found'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1023,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->body(__('Please contact your administrator to enable security scanning.'))
|
->body(__('Please contact your administrator to enable security scanning.'))
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,7 +1040,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
|
|
||||||
// Run WPScan
|
// Run WPScan
|
||||||
$url = $site['url'];
|
$url = $site['url'];
|
||||||
exec("wpscan --url " . escapeshellarg($url) . " --format json --no-banner 2>&1", $scanOutput, $scanCode);
|
exec('wpscan --url '.escapeshellarg($url).' --format json --no-banner 2>&1', $scanOutput, $scanCode);
|
||||||
|
|
||||||
$jsonOutput = implode("\n", $scanOutput);
|
$jsonOutput = implode("\n", $scanOutput);
|
||||||
$results = json_decode($jsonOutput, true);
|
$results = json_decode($jsonOutput, true);
|
||||||
@@ -1201,8 +1256,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->infolist([
|
->infolist([
|
||||||
Section::make(__('Discovered Sites'))
|
Section::make(__('Discovered Sites'))
|
||||||
->schema(
|
->schema(
|
||||||
collect($this->scannedSites)->map(fn ($site, $index) =>
|
collect($this->scannedSites)->map(fn ($site, $index) => TextEntry::make("site_{$index}")
|
||||||
TextEntry::make("site_{$index}")
|
|
||||||
->label($site['site_url'] ?? __('WordPress Site'))
|
->label($site['site_url'] ?? __('WordPress Site'))
|
||||||
->state($site['path'])
|
->state($site['path'])
|
||||||
->helperText(isset($site['version']) ? 'v'.$site['version'] : '')
|
->helperText(isset($site['version']) ? 'v'.$site['version'] : '')
|
||||||
@@ -1324,6 +1378,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->title(__('Site not found'))
|
->title(__('Site not found'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1348,6 +1403,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
->body(__('Screenshot script not found.'))
|
->body(__('Screenshot script not found.'))
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1399,6 +1455,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
|
|||||||
public function hasScreenshot(string $siteId): bool
|
public function hasScreenshot(string $siteId): bool
|
||||||
{
|
{
|
||||||
$filename = 'wp_'.$siteId.'.png';
|
$filename = 'wp_'.$siteId.'.png';
|
||||||
|
|
||||||
return file_exists(storage_path('app/public/screenshots/'.$filename));
|
return file_exists(storage_path('app/public/screenshots/'.$filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
app/Http/Controllers/AutomationApiController.php
Normal file
153
app/Http/Controllers/AutomationApiController.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\HostingPackage;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserResourceLimit;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use App\Services\System\LinuxUserService;
|
||||||
|
use App\Services\System\ResourceLimitService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class AutomationApiController extends Controller
|
||||||
|
{
|
||||||
|
public function listUsers(): JsonResponse
|
||||||
|
{
|
||||||
|
$users = User::query()
|
||||||
|
->where('is_admin', false)
|
||||||
|
->get(['id', 'name', 'username', 'email', 'is_active', 'hosting_package_id']);
|
||||||
|
|
||||||
|
return response()->json(['users' => $users]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createUser(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'username' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]{0,31}$/', 'unique:users,username'],
|
||||||
|
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'password' => ['required', 'string', 'min:8'],
|
||||||
|
'hosting_package_id' => ['nullable', 'exists:hosting_packages,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(['errors' => $validator->errors()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $validator->validated();
|
||||||
|
$package = null;
|
||||||
|
if (! empty($data['hosting_package_id'])) {
|
||||||
|
$package = HostingPackage::find($data['hosting_package_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'username' => $data['username'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => $data['password'],
|
||||||
|
'sftp_password' => $data['password'],
|
||||||
|
'is_admin' => false,
|
||||||
|
'is_active' => true,
|
||||||
|
'hosting_package_id' => $package?->id,
|
||||||
|
'disk_quota_mb' => $package?->disk_quota_mb,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$linux = new LinuxUserService;
|
||||||
|
$linux->createUser($user, $data['password']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
return response()->json(['error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($package && $package->disk_quota_mb) {
|
||||||
|
try {
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->quotaSet($user->username, (int) $package->disk_quota_mb);
|
||||||
|
} catch (\Exception) {
|
||||||
|
// keep user created, quota can be applied later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($package) {
|
||||||
|
$cpu = $package->cpu_limit_percent;
|
||||||
|
$memory = $package->memory_limit_mb;
|
||||||
|
$io = $package->io_limit_mb;
|
||||||
|
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
|
||||||
|
|
||||||
|
if ($hasLimits) {
|
||||||
|
$limit = UserResourceLimit::firstOrNew(['user_id' => $user->id]);
|
||||||
|
$limit->fill([
|
||||||
|
'cpu_limit_percent' => $cpu,
|
||||||
|
'memory_limit_mb' => $memory,
|
||||||
|
'io_limit_mb' => $io,
|
||||||
|
'is_active' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(ResourceLimitService::class)->apply($limit);
|
||||||
|
} catch (\Exception) {
|
||||||
|
// cgroup apply failure shouldn't block user creation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['user' => $user], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDomain(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'username' => ['nullable', 'string'],
|
||||||
|
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
|
'domain' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(['errors' => $validator->errors()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $validator->validated();
|
||||||
|
|
||||||
|
$user = null;
|
||||||
|
if (! empty($data['user_id'])) {
|
||||||
|
$user = User::where('id', $data['user_id'])->where('is_admin', false)->first();
|
||||||
|
} elseif (! empty($data['username'])) {
|
||||||
|
$user = User::where('username', $data['username'])->where('is_admin', false)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['error' => 'User not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $user->hostingPackage?->domains_limit;
|
||||||
|
if ($limit && Domain::where('user_id', $user->id)->count() >= $limit) {
|
||||||
|
return response()->json(['error' => 'Domain limit reached'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$result = $agent->domainCreate($user->username, $data['domain']);
|
||||||
|
if (! ($result['success'] ?? false)) {
|
||||||
|
return response()->json(['error' => $result['error'] ?? 'Domain creation failed'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = Domain::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'domain' => $data['domain'],
|
||||||
|
'document_root' => '/home/'.$user->username.'/domains/'.$data['domain'].'/public_html',
|
||||||
|
'is_active' => true,
|
||||||
|
'ssl_enabled' => false,
|
||||||
|
'directory_index' => 'index.php index.html',
|
||||||
|
'page_cache_enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['domain' => $domain], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Controllers/GitWebhookController.php
Normal file
28
app/Http/Controllers/GitWebhookController.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Jobs\RunGitDeployment;
|
||||||
|
use App\Models\GitDeployment;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class GitWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, GitDeployment $deployment, string $token): JsonResponse
|
||||||
|
{
|
||||||
|
if (! hash_equals($deployment->secret_token, $token)) {
|
||||||
|
return response()->json(['message' => 'Invalid token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $deployment->auto_deploy) {
|
||||||
|
return response()->json(['message' => 'Auto-deploy disabled'], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
RunGitDeployment::dispatch($deployment->id);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Deployment queued']);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Jobs/RunGitDeployment.php
Normal file
70
app/Jobs/RunGitDeployment.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\GitDeployment;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class RunGitDeployment implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public int $deploymentId) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$deployment = GitDeployment::find($this->deploymentId);
|
||||||
|
if (! $deployment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deployment->update([
|
||||||
|
'last_status' => 'running',
|
||||||
|
'last_error' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $agent->send('git.deploy', [
|
||||||
|
'username' => $deployment->user->username,
|
||||||
|
'repo_url' => $deployment->repo_url,
|
||||||
|
'branch' => $deployment->branch,
|
||||||
|
'deploy_path' => $deployment->deploy_path,
|
||||||
|
'deploy_script' => $deployment->deploy_script,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! ($result['success'] ?? false)) {
|
||||||
|
throw new Exception($result['error'] ?? 'Deployment failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deployment->update([
|
||||||
|
'last_status' => 'success',
|
||||||
|
'last_deployed_at' => now(),
|
||||||
|
'last_error' => null,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$deployment->update([
|
||||||
|
'last_status' => 'failed',
|
||||||
|
'last_error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::error('Git deployment failed', [
|
||||||
|
'deployment_id' => $deployment->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/CloudflareZone.php
Normal file
49
app/Models/CloudflareZone.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
|
class CloudflareZone extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'domain_id',
|
||||||
|
'zone_id',
|
||||||
|
'account_id',
|
||||||
|
'api_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'api_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function setApiTokenAttribute(string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['api_token'] = Crypt::encryptString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getApiTokenAttribute(): string
|
||||||
|
{
|
||||||
|
$value = $this->attributes['api_token'] ?? '';
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Crypt::decryptString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Domain::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ class Domain extends Model
|
|||||||
// Delete hotlink settings
|
// Delete hotlink settings
|
||||||
$domain->hotlinkSetting?->delete();
|
$domain->hotlinkSetting?->delete();
|
||||||
|
|
||||||
|
// Delete aliases
|
||||||
|
$domain->aliases()->delete();
|
||||||
|
|
||||||
// Delete email domain and related records
|
// Delete email domain and related records
|
||||||
if ($domain->emailDomain) {
|
if ($domain->emailDomain) {
|
||||||
// Delete mailboxes (which will cascade to autoresponders)
|
// Delete mailboxes (which will cascade to autoresponders)
|
||||||
@@ -103,6 +106,11 @@ class Domain extends Model
|
|||||||
return $this->hasMany(DomainRedirect::class);
|
return $this->hasMany(DomainRedirect::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function aliases(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DomainAlias::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function hotlinkSetting(): HasOne
|
public function hotlinkSetting(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(DomainHotlinkSetting::class);
|
return $this->hasOne(DomainHotlinkSetting::class);
|
||||||
|
|||||||
21
app/Models/DomainAlias.php
Normal file
21
app/Models/DomainAlias.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DomainAlias extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'domain_id',
|
||||||
|
'alias',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function domain(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Domain::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Models/GeoBlockRule.php
Normal file
24
app/Models/GeoBlockRule.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class GeoBlockRule extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'country_code',
|
||||||
|
'action',
|
||||||
|
'notes',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
40
app/Models/GitDeployment.php
Normal file
40
app/Models/GitDeployment.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class GitDeployment extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'domain_id',
|
||||||
|
'repo_url',
|
||||||
|
'branch',
|
||||||
|
'deploy_path',
|
||||||
|
'auto_deploy',
|
||||||
|
'deploy_script',
|
||||||
|
'last_status',
|
||||||
|
'last_deployed_at',
|
||||||
|
'last_error',
|
||||||
|
'secret_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'auto_deploy' => 'boolean',
|
||||||
|
'last_deployed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Domain::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Models/HostingPackage.php
Normal file
37
app/Models/HostingPackage.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class HostingPackage extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'disk_quota_mb',
|
||||||
|
'bandwidth_gb',
|
||||||
|
'domains_limit',
|
||||||
|
'databases_limit',
|
||||||
|
'mailboxes_limit',
|
||||||
|
'cpu_limit_percent',
|
||||||
|
'memory_limit_mb',
|
||||||
|
'io_limit_mb',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable implements FilamentUser
|
class User extends Authenticatable implements FilamentUser
|
||||||
{
|
{
|
||||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
@@ -25,6 +26,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
'sftp_password',
|
'sftp_password',
|
||||||
'is_admin',
|
'is_admin',
|
||||||
'is_active',
|
'is_active',
|
||||||
|
'hosting_package_id',
|
||||||
'locale',
|
'locale',
|
||||||
'disk_quota_mb',
|
'disk_quota_mb',
|
||||||
];
|
];
|
||||||
@@ -147,6 +149,11 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
return $this->hasMany(Domain::class);
|
return $this->hasMany(Domain::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hostingPackage(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(HostingPackage::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get disk usage in bytes.
|
* Get disk usage in bytes.
|
||||||
*/
|
*/
|
||||||
|
|||||||
31
app/Models/UserResourceLimit.php
Normal file
31
app/Models/UserResourceLimit.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserResourceLimit extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'cpu_limit_percent',
|
||||||
|
'memory_limit_mb',
|
||||||
|
'io_limit_mb',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Models/UserResourceUsage.php
Normal file
28
app/Models/UserResourceUsage.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserResourceUsage extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'metric',
|
||||||
|
'value',
|
||||||
|
'captured_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'value' => 'integer',
|
||||||
|
'captured_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Models/UserSetting.php
Normal file
65
app/Models/UserSetting.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class UserSetting extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'key',
|
||||||
|
'value',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'value' => 'json',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getForUser(int $userId, string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return Cache::remember("user_setting.{$userId}.{$key}", 3600, function () use ($userId, $key, $default) {
|
||||||
|
return static::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('key', $key)
|
||||||
|
->value('value') ?? $default;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setForUser(int $userId, string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
static::updateOrCreate(
|
||||||
|
['user_id' => $userId, 'key' => $key],
|
||||||
|
['value' => $value]
|
||||||
|
);
|
||||||
|
|
||||||
|
Cache::forget("user_setting.{$userId}.{$key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forgetForUser(int $userId, string $key): void
|
||||||
|
{
|
||||||
|
static::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('key', $key)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
Cache::forget("user_setting.{$userId}.{$key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getAllForUser(int $userId): array
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->pluck('value', 'key')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Models/WebhookEndpoint.php
Normal file
29
app/Models/WebhookEndpoint.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class WebhookEndpoint extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'events',
|
||||||
|
'is_active',
|
||||||
|
'secret_token',
|
||||||
|
'last_response_code',
|
||||||
|
'last_triggered_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'events' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_triggered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -227,6 +227,45 @@ class AgentClient
|
|||||||
return $this->send('file.list_trash', ['username' => $username]);
|
return $this->send('file.list_trash', ['username' => $username]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Git deployment
|
||||||
|
public function gitGenerateKey(string $username): array
|
||||||
|
{
|
||||||
|
return $this->send('git.generate_key', ['username' => $username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function gitDeploy(string $username, string $repoUrl, string $branch, string $deployPath, ?string $deployScript = null): array
|
||||||
|
{
|
||||||
|
return $this->send('git.deploy', [
|
||||||
|
'username' => $username,
|
||||||
|
'repo_url' => $repoUrl,
|
||||||
|
'branch' => $branch,
|
||||||
|
'deploy_path' => $deployPath,
|
||||||
|
'deploy_script' => $deployScript,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spam settings (Rspamd)
|
||||||
|
public function rspamdUserSettings(string $username, array $whitelist = [], array $blacklist = [], ?float $score = null): array
|
||||||
|
{
|
||||||
|
return $this->send('rspamd.user_settings', [
|
||||||
|
'username' => $username,
|
||||||
|
'whitelist' => $whitelist,
|
||||||
|
'blacklist' => $blacklist,
|
||||||
|
'score' => $score,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image optimization
|
||||||
|
public function imageOptimize(string $username, string $path, bool $convertWebp = false, int $quality = 82): array
|
||||||
|
{
|
||||||
|
return $this->send('image.optimize', [
|
||||||
|
'username' => $username,
|
||||||
|
'path' => $path,
|
||||||
|
'convert_webp' => $convertWebp,
|
||||||
|
'quality' => $quality,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// MySQL operations
|
// MySQL operations
|
||||||
public function mysqlListDatabases(string $username): array
|
public function mysqlListDatabases(string $username): array
|
||||||
{
|
{
|
||||||
@@ -293,12 +332,101 @@ class AgentClient
|
|||||||
return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]);
|
return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostgreSQL operations
|
||||||
|
public function postgresListDatabases(string $username): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.list_databases', ['username' => $username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresListUsers(string $username): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.list_users', ['username' => $username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresCreateDatabase(string $username, string $database, string $owner): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.create_database', [
|
||||||
|
'username' => $username,
|
||||||
|
'database' => $database,
|
||||||
|
'owner' => $owner,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresDeleteDatabase(string $username, string $database): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.delete_database', [
|
||||||
|
'username' => $username,
|
||||||
|
'database' => $database,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresCreateUser(string $username, string $dbUser, string $password): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.create_user', [
|
||||||
|
'username' => $username,
|
||||||
|
'db_user' => $dbUser,
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresDeleteUser(string $username, string $dbUser): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.delete_user', [
|
||||||
|
'username' => $username,
|
||||||
|
'db_user' => $dbUser,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresChangePassword(string $username, string $dbUser, string $password): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.change_password', [
|
||||||
|
'username' => $username,
|
||||||
|
'db_user' => $dbUser,
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgresGrantPrivileges(string $username, string $database, string $dbUser): array
|
||||||
|
{
|
||||||
|
return $this->send('postgres.grant_privileges', [
|
||||||
|
'username' => $username,
|
||||||
|
'database' => $database,
|
||||||
|
'db_user' => $dbUser,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Domain operations
|
// Domain operations
|
||||||
public function domainCreate(string $username, string $domain): array
|
public function domainCreate(string $username, string $domain): array
|
||||||
{
|
{
|
||||||
return $this->send('domain.create', ['username' => $username, 'domain' => $domain]);
|
return $this->send('domain.create', ['username' => $username, 'domain' => $domain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function domainAliasAdd(string $username, string $domain, string $alias): array
|
||||||
|
{
|
||||||
|
return $this->send('domain.alias_add', [
|
||||||
|
'username' => $username,
|
||||||
|
'domain' => $domain,
|
||||||
|
'alias' => $alias,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domainAliasRemove(string $username, string $domain, string $alias): array
|
||||||
|
{
|
||||||
|
return $this->send('domain.alias_remove', [
|
||||||
|
'username' => $username,
|
||||||
|
'domain' => $domain,
|
||||||
|
'alias' => $alias,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domainEnsureErrorPages(string $username, string $domain): array
|
||||||
|
{
|
||||||
|
return $this->send('domain.ensure_error_pages', [
|
||||||
|
'username' => $username,
|
||||||
|
'domain' => $domain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array
|
public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array
|
||||||
{
|
{
|
||||||
return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]);
|
return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]);
|
||||||
@@ -360,6 +488,23 @@ class AgentClient
|
|||||||
return $this->send('wp.import', $params);
|
return $this->send('wp.import', $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function wpCreateStaging(string $username, string $siteId, string $subdomain): array
|
||||||
|
{
|
||||||
|
return $this->send('wp.create_staging', [
|
||||||
|
'username' => $username,
|
||||||
|
'site_id' => $siteId,
|
||||||
|
'subdomain' => $subdomain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function wpPushStaging(string $username, string $stagingSiteId): array
|
||||||
|
{
|
||||||
|
return $this->send('wp.push_staging', [
|
||||||
|
'username' => $username,
|
||||||
|
'staging_site_id' => $stagingSiteId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// WordPress Cache Methods
|
// WordPress Cache Methods
|
||||||
public function wpCacheEnable(string $username, string $siteId): array
|
public function wpCacheEnable(string $username, string $siteId): array
|
||||||
{
|
{
|
||||||
@@ -554,6 +699,15 @@ class AgentClient
|
|||||||
return $this->send('email.sync_virtual_users', ['domain' => $domain]);
|
return $this->send('email.sync_virtual_users', ['domain' => $domain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function emailSyncMaps(array $domains, array $mailboxes, array $aliases): array
|
||||||
|
{
|
||||||
|
return $this->send('email.sync_maps', [
|
||||||
|
'domains' => $domains,
|
||||||
|
'mailboxes' => $mailboxes,
|
||||||
|
'aliases' => $aliases,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function emailReloadServices(): array
|
public function emailReloadServices(): array
|
||||||
{
|
{
|
||||||
return $this->send('email.reload_services');
|
return $this->send('email.reload_services');
|
||||||
@@ -1151,4 +1305,77 @@ class AgentClient
|
|||||||
{
|
{
|
||||||
return $this->send('scanner.get_scan_status', ['scanner' => $scanner]);
|
return $this->send('scanner.get_scan_status', ['scanner' => $scanner]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mail queue operations
|
||||||
|
public function mailQueueList(): array
|
||||||
|
{
|
||||||
|
return $this->send('mail.queue_list');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mailQueueRetry(string $id): array
|
||||||
|
{
|
||||||
|
return $this->send('mail.queue_retry', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mailQueueDelete(string $id): array
|
||||||
|
{
|
||||||
|
return $this->send('mail.queue_delete', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server updates
|
||||||
|
public function updatesList(): array
|
||||||
|
{
|
||||||
|
return $this->send('updates.list');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatesRun(): array
|
||||||
|
{
|
||||||
|
return $this->send('updates.run');
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAF / Geo / Resource limits
|
||||||
|
public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog): array
|
||||||
|
{
|
||||||
|
return $this->send('waf.apply', [
|
||||||
|
'enabled' => $enabled,
|
||||||
|
'paranoia' => $paranoia,
|
||||||
|
'audit_log' => $auditLog,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function geoApplyRules(array $rules): array
|
||||||
|
{
|
||||||
|
return $this->send('geo.apply_rules', [
|
||||||
|
'rules' => $rules,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cgroupApplyUserLimits(string $username, ?int $cpuLimit, ?int $memoryLimit, ?int $ioLimit, bool $isActive = true): array
|
||||||
|
{
|
||||||
|
if (! $isActive) {
|
||||||
|
return $this->cgroupClearUserLimits($username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->send('cgroup.apply_user_limits', [
|
||||||
|
'username' => $username,
|
||||||
|
'cpu_limit_percent' => $cpuLimit,
|
||||||
|
'memory_limit_mb' => $memoryLimit,
|
||||||
|
'io_limit_mb' => $ioLimit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cgroupClearUserLimits(string $username): array
|
||||||
|
{
|
||||||
|
return $this->send('cgroup.clear_user_limits', [
|
||||||
|
'username' => $username,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function databasePersistTuning(string $name, string $value): array
|
||||||
|
{
|
||||||
|
return $this->send('database.persist_tuning', [
|
||||||
|
'name' => $name,
|
||||||
|
'value' => $value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
app/Services/System/GeoBlockService.php
Normal file
30
app/Services/System/GeoBlockService.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\System;
|
||||||
|
|
||||||
|
use App\Models\GeoBlockRule;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
|
||||||
|
class GeoBlockService
|
||||||
|
{
|
||||||
|
public function applyCurrentRules(): void
|
||||||
|
{
|
||||||
|
$rules = GeoBlockRule::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->get(['country_code', 'action'])
|
||||||
|
->map(static function ($rule): array {
|
||||||
|
return [
|
||||||
|
'country_code' => strtoupper((string) $rule->country_code),
|
||||||
|
'action' => $rule->action,
|
||||||
|
'is_active' => true,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->geoApplyRules($rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Services/System/MailRoutingSyncService.php
Normal file
68
app/Services/System/MailRoutingSyncService.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\System;
|
||||||
|
|
||||||
|
use App\Models\EmailDomain;
|
||||||
|
use App\Models\EmailForwarder;
|
||||||
|
use App\Models\Mailbox;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
|
||||||
|
class MailRoutingSyncService
|
||||||
|
{
|
||||||
|
public function sync(): void
|
||||||
|
{
|
||||||
|
$domains = EmailDomain::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('domain')
|
||||||
|
->get()
|
||||||
|
->map(fn ($domain) => $domain->domain?->domain)
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$mailboxes = Mailbox::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('emailDomain.domain')
|
||||||
|
->get()
|
||||||
|
->map(function (Mailbox $mailbox): array {
|
||||||
|
return [
|
||||||
|
'email' => $mailbox->email,
|
||||||
|
'path' => $mailbox->maildir_path,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$aliases = EmailForwarder::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('emailDomain.domain')
|
||||||
|
->get()
|
||||||
|
->map(function (EmailForwarder $forwarder): array {
|
||||||
|
return [
|
||||||
|
'source' => $forwarder->email,
|
||||||
|
'destinations' => $forwarder->destinations ?? [],
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$catchAll = EmailDomain::query()
|
||||||
|
->where('catch_all_enabled', true)
|
||||||
|
->with('domain')
|
||||||
|
->get()
|
||||||
|
->map(function (EmailDomain $domain): array {
|
||||||
|
return [
|
||||||
|
'source' => '@'.$domain->domain->domain,
|
||||||
|
'destinations' => $domain->catch_all_address ? [$domain->catch_all_address] : [],
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter(fn (array $entry) => ! empty($entry['destinations']))
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$aliases = array_merge($aliases, $catchAll);
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->emailSyncMaps($domains, $mailboxes, $aliases);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/System/ResourceLimitService.php
Normal file
40
app/Services/System/ResourceLimitService.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\System;
|
||||||
|
|
||||||
|
use App\Models\UserResourceLimit;
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ResourceLimitService
|
||||||
|
{
|
||||||
|
public function apply(UserResourceLimit $limit): void
|
||||||
|
{
|
||||||
|
$user = $limit->user;
|
||||||
|
if (! $user) {
|
||||||
|
throw new RuntimeException('User not found for resource limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->cgroupApplyUserLimits(
|
||||||
|
$user->username,
|
||||||
|
$limit->cpu_limit_percent ? (int) $limit->cpu_limit_percent : null,
|
||||||
|
$limit->memory_limit_mb ? (int) $limit->memory_limit_mb : null,
|
||||||
|
$limit->io_limit_mb ? (int) $limit->io_limit_mb : null,
|
||||||
|
(bool) $limit->is_active
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(UserResourceLimit $limit): void
|
||||||
|
{
|
||||||
|
$user = $limit->user;
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = new AgentClient;
|
||||||
|
$agent->cgroupClearUserLimits($user->username);
|
||||||
|
}
|
||||||
|
}
|
||||||
1625
bin/jabali-agent
1625
bin/jabali-agent
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,77 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Component Locations
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value sets the root directories that'll be used to resolve view-based
|
||||||
|
| components like single and multi-file components. The make command will
|
||||||
|
| use the first directory in this array to add new component files to.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'component_locations' => [
|
||||||
|
resource_path('views/components'),
|
||||||
|
resource_path('views/livewire'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Component Namespaces
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value sets default namespaces that will be used to resolve view-based
|
||||||
|
| components like single-file and multi-file components. These folders'll
|
||||||
|
| also be referenced when creating new components via the make command.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'component_namespaces' => [
|
||||||
|
'layouts' => resource_path('views/layouts'),
|
||||||
|
'pages' => resource_path('views/pages'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Page Layout
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| The view that will be used as the layout when rendering a single component as
|
||||||
|
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
|
||||||
|
| In this case, the content of pages::create-post will render into $slot.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'component_layout' => 'layouts::app',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Lazy Loading Placeholder
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Livewire allows you to lazy load components that would otherwise slow down
|
||||||
|
| the initial page load. Every component can have a custom placeholder or
|
||||||
|
| you can define the default placeholder view for all components below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'component_placeholder' => null, // Example: 'placeholders::skeleton'
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Make Command
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| This value determines the default configuration for the artisan make command
|
||||||
|
| You can configure the component type (sfc, mfc, class) and whether to use
|
||||||
|
| the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'make_command' => [
|
||||||
|
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
|
||||||
|
'emoji' => true, // Options: true, false
|
||||||
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|---------------------------------------------------------------------------
|
|---------------------------------------------------------------------------
|
||||||
| Class Namespace
|
| Class Namespace
|
||||||
@@ -15,6 +86,19 @@ return [
|
|||||||
|
|
||||||
'class_namespace' => 'App\\Livewire',
|
'class_namespace' => 'App\\Livewire',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Class Path
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is used to specify the path where Livewire component class files
|
||||||
|
| are created when running creation commands like `artisan make:livewire`.
|
||||||
|
| This path is customizable to match your projects directory structure.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'class_path' => app_path('Livewire'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|---------------------------------------------------------------------------
|
|---------------------------------------------------------------------------
|
||||||
| View Path
|
| View Path
|
||||||
@@ -28,30 +112,6 @@ return [
|
|||||||
|
|
||||||
'view_path' => resource_path('views/livewire'),
|
'view_path' => resource_path('views/livewire'),
|
||||||
|
|
||||||
/*
|
|
||||||
|---------------------------------------------------------------------------
|
|
||||||
| Layout
|
|
||||||
|---------------------------------------------------------------------------
|
|
||||||
| The view that will be used as the layout when rendering a single component
|
|
||||||
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
|
|
||||||
| In this case, the view returned by CreatePost will render into $slot.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'layout' => 'components.layouts.app',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|---------------------------------------------------------------------------
|
|
||||||
| Lazy Loading Placeholder
|
|
||||||
|---------------------------------------------------------------------------
|
|
||||||
| Livewire allows you to lazy load components that would otherwise slow down
|
|
||||||
| the initial page load. Every component can have a custom placeholder or
|
|
||||||
| you can define the default placeholder view for all components below.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'lazy_placeholder' => null,
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|---------------------------------------------------------------------------
|
|---------------------------------------------------------------------------
|
||||||
| Temporary File Uploads
|
| Temporary File Uploads
|
||||||
@@ -64,8 +124,8 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'temporary_file_upload' => [
|
'temporary_file_upload' => [
|
||||||
'disk' => null, // Example: 'local', 's3' | Default: 'default'
|
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
|
||||||
'rules' => ['required', 'file', 'max:524288'], // 512MB max
|
'rules' => ['required', 'file', 'max:524288'], // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
|
||||||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||||
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||||
@@ -156,7 +216,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'smart_wire_keys' => false,
|
'smart_wire_keys' => true,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|---------------------------------------------------------------------------
|
|---------------------------------------------------------------------------
|
||||||
@@ -183,4 +243,35 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'release_token' => 'a',
|
'release_token' => 'a',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| CSP Safe
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This config is used to determine if Livewire will use the CSP-safe version
|
||||||
|
| of Alpine in its bundle. This is useful for applications that are using
|
||||||
|
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'csp_safe' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Payload Guards
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These settings protect against malicious or oversized payloads that could
|
||||||
|
| cause denial of service. The default values should feel reasonable for
|
||||||
|
| most web applications. Each can be set to null to disable the limit.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'payload' => [
|
||||||
|
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
|
||||||
|
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
|
||||||
|
'max_calls' => 50, // Maximum method calls per request
|
||||||
|
'max_components' => 20, // Maximum components per batch request
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_resource_usages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('metric', 50);
|
||||||
|
$table->unsignedBigInteger('value');
|
||||||
|
$table->timestamp('captured_at')->index();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'metric', 'captured_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_resource_usages');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_settings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('key', 100);
|
||||||
|
$table->json('value')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'key']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('domain_aliases', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('alias');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['domain_id', 'alias']);
|
||||||
|
$table->index('alias');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('domain_aliases');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('git_deployments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('repo_url');
|
||||||
|
$table->string('branch')->default('main');
|
||||||
|
$table->string('deploy_path');
|
||||||
|
$table->boolean('auto_deploy')->default(false);
|
||||||
|
$table->text('deploy_script')->nullable();
|
||||||
|
$table->string('last_status')->nullable();
|
||||||
|
$table->timestamp('last_deployed_at')->nullable();
|
||||||
|
$table->text('last_error')->nullable();
|
||||||
|
$table->string('secret_token', 80)->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'domain_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('git_deployments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cloudflare_zones', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('zone_id');
|
||||||
|
$table->string('account_id')->nullable();
|
||||||
|
$table->text('api_token');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'domain_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cloudflare_zones');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('hosting_packages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->unsignedInteger('disk_quota_mb')->nullable();
|
||||||
|
$table->unsignedInteger('bandwidth_gb')->nullable();
|
||||||
|
$table->unsignedInteger('domains_limit')->nullable();
|
||||||
|
$table->unsignedInteger('databases_limit')->nullable();
|
||||||
|
$table->unsignedInteger('mailboxes_limit')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('hosting_packages');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->foreignId('hosting_package_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('hosting_packages')
|
||||||
|
->nullOnDelete()
|
||||||
|
->after('is_active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['hosting_package_id']);
|
||||||
|
$table->dropColumn('hosting_package_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhook_endpoints', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('url');
|
||||||
|
$table->json('events')->nullable();
|
||||||
|
$table->string('secret_token')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedInteger('last_response_code')->nullable();
|
||||||
|
$table->timestamp('last_triggered_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('webhook_endpoints');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_resource_limits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('cpu_limit_percent')->nullable();
|
||||||
|
$table->unsignedInteger('memory_limit_mb')->nullable();
|
||||||
|
$table->unsignedInteger('io_limit_mb')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_resource_limits');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('geo_block_rules', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('country_code', 2);
|
||||||
|
$table->enum('action', ['allow', 'block'])->default('block');
|
||||||
|
$table->string('notes')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('geo_block_rules');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('hosting_packages', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('cpu_limit_percent')->nullable()->after('mailboxes_limit');
|
||||||
|
$table->unsignedInteger('memory_limit_mb')->nullable()->after('cpu_limit_percent');
|
||||||
|
$table->unsignedInteger('io_limit_mb')->nullable()->after('memory_limit_mb');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('hosting_packages', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['cpu_limit_percent', 'memory_limit_mb', 'io_limit_mb']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@if($plainToken)
|
||||||
|
<x-filament::section icon="heroicon-o-key" icon-color="success">
|
||||||
|
<x-slot name="heading">{{ __('New Token') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Copy this token now. You will not be able to see it again.') }}</x-slot>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ __('API Token') }}</p>
|
||||||
|
<p class="mt-2 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $plainToken }}</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-filament::section icon="heroicon-o-document-text" class="mt-6">
|
||||||
|
<x-slot name="heading">{{ __('Automation API Endpoints') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Use these endpoints with a token that has the automation ability.') }}</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div><span class="font-mono">GET</span> /api/automation/users</div>
|
||||||
|
<div><span class="font-mono">POST</span> /api/automation/users</div>
|
||||||
|
<div><span class="font-mono">POST</span> /api/automation/domains</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament::section icon="heroicon-o-exclamation-triangle" icon-color="warning">
|
||||||
|
<x-slot name="heading">{{ __('Runtime Changes') }}</x-slot>
|
||||||
|
<x-slot name="description">
|
||||||
|
{{ __('Changes apply immediately but may reset after a database restart. Persisting configuration will be added in a later step.') }}
|
||||||
|
</x-slot>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
383
resources/views/filament/admin/pages/resource-usage.blade.php
Normal file
383
resources/views/filament/admin/pages/resource-usage.blade.php
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@once
|
||||||
|
@vite('resources/js/server-charts.js')
|
||||||
|
@endonce
|
||||||
|
{{ $this->usageForm }}
|
||||||
|
|
||||||
|
@php($hasRealData = !empty($chartData) && !empty($chartData['labels']))
|
||||||
|
|
||||||
|
@if(! $hasRealData)
|
||||||
|
<x-filament::section class="mt-6">
|
||||||
|
<div class="flex flex-col items-center justify-center py-10">
|
||||||
|
<div class="mb-3 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
|
||||||
|
<x-filament::icon icon="heroicon-o-chart-bar" class="h-6 w-6 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ __('No usage data yet') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ __('Usage snapshots are collected hourly. Data will appear after the first collection run.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@else
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<x-filament::section icon="heroicon-o-square-3-stack-3d" icon-color="primary">
|
||||||
|
<x-slot name="heading">{{ __('Latest Snapshot') }}</x-slot>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Disk') }}</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['disk'] ?? __('No data') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Mail') }}</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['mail'] ?? __('No data') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Databases') }}</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['database'] ?? __('No data') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Bandwidth (last hour)') }}</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['bandwidth'] ?? __('No data') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section icon="heroicon-o-chart-bar" icon-color="success">
|
||||||
|
<x-slot name="heading">{{ __('Resource Usage (Last 30 Days)') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Historical snapshots collected hourly.') }}</x-slot>
|
||||||
|
|
||||||
|
<div
|
||||||
|
wire:key="resource-usage-chart-{{ $usageFormData['user_id'] ?? 'none' }}"
|
||||||
|
x-data="{
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
let data = @js($chartData);
|
||||||
|
|
||||||
|
const boot = () => {
|
||||||
|
const element = this.$refs.chart ?? this.$el;
|
||||||
|
if (!window.echarts || !data.labels?.length || !element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = window.echarts.init(element);
|
||||||
|
this.chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: {
|
||||||
|
data: ['Disk', 'Mail', 'Databases', 'Bandwidth'],
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.labels,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value) => value.slice(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { formatter: '{value} GB' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ name: 'Disk', type: 'line', smooth: true, data: data.disk, areaStyle: {} },
|
||||||
|
{ name: 'Mail', type: 'line', smooth: true, data: data.mail, areaStyle: {} },
|
||||||
|
{ name: 'Databases', type: 'line', smooth: true, data: data.database, areaStyle: {} },
|
||||||
|
{ name: 'Bandwidth', type: 'line', smooth: true, data: data.bandwidth, areaStyle: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => this.chart?.resize());
|
||||||
|
requestAnimationFrame(() => this.chart?.resize());
|
||||||
|
setTimeout(() => this.chart?.resize(), 150);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!boot()) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (boot()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-init="init"
|
||||||
|
class="w-full"
|
||||||
|
wire:ignore
|
||||||
|
>
|
||||||
|
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if($hasPerformanceData)
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<x-filament::section icon="heroicon-o-cpu-chip" icon-color="info">
|
||||||
|
<x-slot name="heading">{{ __('CPU Usage (Last 30 Days)') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Average CPU percent per snapshot.') }}</x-slot>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Limit') }}:</span>
|
||||||
|
{{ $cpuLimitPercent ? $cpuLimitPercent . '%' : __('Unlimited') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Average') }}:</span>
|
||||||
|
{{ $cpuStats['avg'] ?? __('No data') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Peak') }}:</span>
|
||||||
|
{{ $cpuStats['max'] ?? __('No data') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="{
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
const data = @js($performanceChartData);
|
||||||
|
const limit = @js($cpuLimitPercent);
|
||||||
|
const boot = () => {
|
||||||
|
const element = this.$refs.chart ?? this.$el;
|
||||||
|
if (!window.echarts || !data.labels?.length || !element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = [
|
||||||
|
{ name: 'CPU', type: 'line', smooth: true, data: data.cpu, areaStyle: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
series.push({
|
||||||
|
name: 'Limit',
|
||||||
|
type: 'line',
|
||||||
|
data: data.labels.map(() => limit),
|
||||||
|
lineStyle: { type: 'dashed', width: 2, color: '#ef4444' },
|
||||||
|
symbol: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = window.echarts.init(element);
|
||||||
|
this.chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: series.map(item => item.name), bottom: 0 },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: 50, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.labels,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value) => value.slice(5),
|
||||||
|
margin: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => this.chart?.resize());
|
||||||
|
requestAnimationFrame(() => this.chart?.resize());
|
||||||
|
setTimeout(() => this.chart?.resize(), 150);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!boot()) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (boot()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-init="init"
|
||||||
|
class="w-full"
|
||||||
|
wire:ignore
|
||||||
|
>
|
||||||
|
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section icon="heroicon-o-circle-stack" icon-color="warning">
|
||||||
|
<x-slot name="heading">{{ __('Memory Usage (Last 30 Days)') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Resident memory per snapshot.') }}</x-slot>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Limit') }}:</span>
|
||||||
|
{{ $memoryLimitGb ? number_format($memoryLimitGb, 2) . ' GB' : __('Unlimited') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Average') }}:</span>
|
||||||
|
{{ $memoryStats['avg'] ?? __('No data') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Peak') }}:</span>
|
||||||
|
{{ $memoryStats['max'] ?? __('No data') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="{
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
const data = @js($performanceChartData);
|
||||||
|
const limit = @js($memoryLimitGb);
|
||||||
|
const boot = () => {
|
||||||
|
const element = this.$refs.chart ?? this.$el;
|
||||||
|
if (!window.echarts || !data.labels?.length || !element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = [
|
||||||
|
{ name: 'Memory', type: 'line', smooth: true, data: data.memory, areaStyle: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
series.push({
|
||||||
|
name: 'Limit',
|
||||||
|
type: 'line',
|
||||||
|
data: data.labels.map(() => limit),
|
||||||
|
lineStyle: { type: 'dashed', width: 2, color: '#ef4444' },
|
||||||
|
symbol: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = window.echarts.init(element);
|
||||||
|
this.chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: series.map(item => item.name), bottom: 0 },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: 50, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.labels,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value) => value.slice(5),
|
||||||
|
margin: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { formatter: '{value} GB' },
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => this.chart?.resize());
|
||||||
|
requestAnimationFrame(() => this.chart?.resize());
|
||||||
|
setTimeout(() => this.chart?.resize(), 150);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!boot()) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (boot()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-init="init"
|
||||||
|
class="w-full"
|
||||||
|
wire:ignore
|
||||||
|
>
|
||||||
|
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section icon="heroicon-o-arrow-path-rounded-square" icon-color="primary">
|
||||||
|
<x-slot name="heading">{{ __('Disk IO (Last 30 Days)') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Read/write MB per snapshot.') }}</x-slot>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="{
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
const data = @js($performanceChartData);
|
||||||
|
const boot = () => {
|
||||||
|
const element = this.$refs.chart ?? this.$el;
|
||||||
|
if (!window.echarts || !data.labels?.length || !element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = window.echarts.init(element);
|
||||||
|
this.chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['Disk IO'], bottom: 0 },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: 50, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.labels,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value) => value.slice(5),
|
||||||
|
margin: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { formatter: '{value} MB' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ name: 'Disk IO', type: 'line', smooth: true, data: data.disk_io, areaStyle: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => this.chart?.resize());
|
||||||
|
requestAnimationFrame(() => this.chart?.resize());
|
||||||
|
setTimeout(() => this.chart?.resize(), 150);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!boot()) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (boot()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-init="init"
|
||||||
|
class="w-full"
|
||||||
|
wire:ignore
|
||||||
|
>
|
||||||
|
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-filament::section icon="heroicon-o-cpu-chip" icon-color="info">
|
||||||
|
<x-slot name="heading">{{ __('CPU, Memory & Disk IO') }}</x-slot>
|
||||||
|
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<div class="mb-3 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
|
||||||
|
<x-filament::icon icon="heroicon-o-cpu-chip" class="h-6 w-6 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ __('No performance data yet') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ __('CPU, memory, and disk IO snapshots will appear after the next collection run.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
21
resources/views/filament/admin/pages/waf.blade.php
Normal file
21
resources/views/filament/admin/pages/waf.blade.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@if(! $wafInstalled)
|
||||||
|
<x-filament::section icon="heroicon-o-exclamation-triangle" icon-color="warning">
|
||||||
|
<x-slot name="heading">{{ __('ModSecurity not detected') }}</x-slot>
|
||||||
|
<x-slot name="description">
|
||||||
|
{{ __('Install ModSecurity on the server to enable WAF controls. Settings can be saved now and applied later.') }}
|
||||||
|
</x-slot>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $this->wafForm }}
|
||||||
|
<div class="mt-4">
|
||||||
|
<x-filament::button wire:click="saveWafSettings" icon="heroicon-o-check">
|
||||||
|
{{ __('Save WAF Settings') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
'autoresponders' => ['label' => __('Autoresponders'), 'icon' => 'heroicon-o-clock'],
|
'autoresponders' => ['label' => __('Autoresponders'), 'icon' => 'heroicon-o-clock'],
|
||||||
'catchall' => ['label' => __('Catch-All'), 'icon' => 'heroicon-o-inbox-stack'],
|
'catchall' => ['label' => __('Catch-All'), 'icon' => 'heroicon-o-inbox-stack'],
|
||||||
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
|
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
|
||||||
|
'spam' => ['label' => __('Spam Settings'), 'icon' => 'heroicon-o-shield-check'],
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -3,9 +3,21 @@
|
|||||||
|
|
||||||
{{ $this->emailForm }}
|
{{ $this->emailForm }}
|
||||||
|
|
||||||
|
@if($activeTab === 'spam')
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $this->spamForm }}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<x-filament::button wire:click="saveSpamSettings" icon="heroicon-o-check" color="primary">
|
||||||
|
{{ __('Save Spam Settings') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
<div class="-mt-4">
|
<div class="-mt-4">
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<x-filament-actions::modals />
|
<x-filament-actions::modals />
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->imageForm }}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<x-filament::button color="primary" wire:click="runOptimization" icon="heroicon-o-bolt">
|
||||||
|
{{ __('Run Optimization') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -1,10 +1,48 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@if(count($this->getDomainOptions()) > 0)
|
@php
|
||||||
{{-- Domain Selector --}}
|
$tabs = [
|
||||||
<x-filament::section
|
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
|
||||||
icon="heroicon-o-globe-alt"
|
'stats' => ['label' => __('Statistics'), 'icon' => 'heroicon-o-chart-bar'],
|
||||||
icon-color="primary"
|
'usage' => ['label' => __('Resource Usage'), 'icon' => 'heroicon-o-chart-pie'],
|
||||||
|
'activity' => ['label' => __('Activity Log'), 'icon' => 'heroicon-o-clipboard-document-list'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<nav class="fi-tabs flex max-w-full gap-x-1 overflow-x-auto mx-auto rounded-xl bg-white p-2 shadow-sm ring-1 ring-gray-950/5 dark:bg-white/5 dark:ring-white/10" role="tablist">
|
||||||
|
@foreach($tabs as $key => $tab)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}"
|
||||||
|
wire:click="setTab('{{ $key }}')"
|
||||||
|
@class([
|
||||||
|
'fi-tabs-item group flex items-center gap-x-2 rounded-lg px-3 py-2 text-sm font-medium outline-none transition duration-75',
|
||||||
|
'fi-active bg-gray-50 dark:bg-white/5' => $activeTab === $key,
|
||||||
|
'hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5' => $activeTab !== $key,
|
||||||
|
])
|
||||||
>
|
>
|
||||||
|
<x-filament::icon
|
||||||
|
:icon="$tab['icon']"
|
||||||
|
@class([
|
||||||
|
'fi-tabs-item-icon h-5 w-5 shrink-0 transition duration-75',
|
||||||
|
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
|
||||||
|
'text-gray-400 group-hover:text-gray-500 group-focus-visible:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400 dark:group-focus-visible:text-gray-400' => $activeTab !== $key,
|
||||||
|
])
|
||||||
|
/>
|
||||||
|
<span @class([
|
||||||
|
'fi-tabs-item-label transition duration-75',
|
||||||
|
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
|
||||||
|
'text-gray-500 group-hover:text-gray-700 group-focus-visible:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 dark:group-focus-visible:text-gray-200' => $activeTab !== $key,
|
||||||
|
])>
|
||||||
|
{{ $tab['label'] }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if(in_array($activeTab, ['logs', 'stats'], true))
|
||||||
|
@if(count($this->getDomainOptions()) > 0)
|
||||||
|
<x-filament::section icon="heroicon-o-globe-alt" icon-color="primary" class="mt-4">
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
{{ __('Select Domain') }}
|
{{ __('Select Domain') }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@@ -24,12 +62,9 @@
|
|||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if($selectedDomain)
|
@if($selectedDomain)
|
||||||
{{-- Statistics Banner (shown when generated) --}}
|
@if($activeTab === 'stats')
|
||||||
@if($statsGenerated)
|
@if($statsGenerated)
|
||||||
<x-filament::section
|
<x-filament::section icon="heroicon-o-check-circle" icon-color="success" class="mt-4">
|
||||||
icon="heroicon-o-check-circle"
|
|
||||||
icon-color="success"
|
|
||||||
>
|
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
{{ __('Statistics Report Ready') }}
|
{{ __('Statistics Report Ready') }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@@ -47,11 +82,10 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Log Viewer --}}
|
@if($activeTab === 'logs')
|
||||||
<x-filament::section
|
<x-filament::section icon="heroicon-o-document-text" class="mt-4">
|
||||||
icon="heroicon-o-document-text"
|
|
||||||
>
|
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
{{ __('Log Viewer') }}
|
{{ __('Log Viewer') }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@@ -62,7 +96,6 @@
|
|||||||
@endif
|
@endif
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
{{-- Log Type Buttons --}}
|
|
||||||
<div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4">
|
<div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4">
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
wire:click="setLogType('access')"
|
wire:click="setLogType('access')"
|
||||||
@@ -85,7 +118,6 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Log Content --}}
|
|
||||||
@if($logContent)
|
@if($logContent)
|
||||||
<div class="fi-input-wrp rounded-lg shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 overflow-hidden">
|
<div class="fi-input-wrp rounded-lg shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 overflow-hidden">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -109,15 +141,12 @@
|
|||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
@else
|
@else
|
||||||
{{-- No Domains Empty State --}}
|
<x-filament::section class="mt-4">
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
<div class="flex flex-col items-center justify-center py-12">
|
||||||
<div class="mb-4 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
|
<div class="mb-4 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
|
||||||
<x-filament::icon
|
<x-filament::icon icon="heroicon-o-globe-alt" class="h-6 w-6 text-gray-500 dark:text-gray-400" />
|
||||||
icon="heroicon-o-globe-alt"
|
|
||||||
class="h-6 w-6 text-gray-500 dark:text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
{{ __('No Domains Yet') }}
|
{{ __('No Domains Yet') }}
|
||||||
@@ -126,16 +155,124 @@
|
|||||||
{{ __('Add a domain first to view logs and statistics.') }}
|
{{ __('Add a domain first to view logs and statistics.') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<x-filament::button
|
<x-filament::button href="{{ route('filament.jabali.pages.domains') }}" tag="a">
|
||||||
href="{{ route('filament.jabali.pages.domains') }}"
|
|
||||||
tag="a"
|
|
||||||
>
|
|
||||||
{{ __('Add Domain') }}
|
{{ __('Add Domain') }}
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($activeTab === 'usage')
|
||||||
|
@php($usageData = $this->getUsageChartData())
|
||||||
|
<x-filament::section class="mt-4" icon="heroicon-o-chart-pie">
|
||||||
|
<x-slot name="heading">{{ __('Resource Usage (Last 30 Days)') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Historical usage snapshots collected hourly.') }}</x-slot>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="{
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
const data = @js($usageData);
|
||||||
|
const isDemo = Boolean(data.demo);
|
||||||
|
const boot = () => {
|
||||||
|
const element = this.$refs.chart ?? this.$el;
|
||||||
|
if (!window.echarts || !element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = window.echarts.init(element);
|
||||||
|
this.chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: data.series.map(s => s.name) },
|
||||||
|
grid: { left: '3%', right: '3%', bottom: 50, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.labels,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value) => value.slice(5),
|
||||||
|
margin: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { formatter: '{value} GB' },
|
||||||
|
},
|
||||||
|
series: data.series.map((series) => ({
|
||||||
|
name: series.name,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
areaStyle: {},
|
||||||
|
data: series.data,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => this.chart?.resize());
|
||||||
|
requestAnimationFrame(() => this.chart?.resize());
|
||||||
|
setTimeout(() => this.chart?.resize(), 150);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!boot()) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (boot()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
x-init="init"
|
||||||
|
class="w-full"
|
||||||
|
wire:ignore
|
||||||
|
>
|
||||||
|
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($activeTab === 'activity')
|
||||||
|
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
|
||||||
|
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Time') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Category') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Action') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Description') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('IP') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
@forelse($this->getActivityLogs() as $log)
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->created_at?->format('Y-m-d H:i') }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->category }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->action }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->description }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->ip_address }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">
|
||||||
|
{{ __('No activity recorded yet.') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
<x-filament-actions::modals />
|
<x-filament-actions::modals />
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">{{ __('Mailing Lists') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Connect a mailing list provider or store settings for your administrator to configure.') }}</x-slot>
|
||||||
|
|
||||||
|
{{ $this->mailingForm }}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<x-filament::button wire:click="saveSettings" icon="heroicon-o-check" color="primary">
|
||||||
|
{{ __('Save Settings') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
46
resources/views/filament/jabali/pages/postgresql.blade.php
Normal file
46
resources/views/filament/jabali/pages/postgresql.blade.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@php
|
||||||
|
$tabs = [
|
||||||
|
'databases' => ['label' => __('Databases'), 'icon' => 'heroicon-o-circle-stack'],
|
||||||
|
'users' => ['label' => __('Users'), 'icon' => 'heroicon-o-users'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<nav class="fi-tabs flex max-w-full gap-x-1 overflow-x-auto mx-auto rounded-xl bg-white p-2 shadow-sm ring-1 ring-gray-950/5 dark:bg-white/5 dark:ring-white/10" role="tablist">
|
||||||
|
@foreach($tabs as $key => $tab)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}"
|
||||||
|
wire:click="$set('activeTab', '{{ $key }}')"
|
||||||
|
@class([
|
||||||
|
'fi-tabs-item group flex items-center gap-x-2 rounded-lg px-3 py-2 text-sm font-medium outline-none transition duration-75',
|
||||||
|
'fi-active bg-gray-50 dark:bg-white/5' => $activeTab === $key,
|
||||||
|
'hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5' => $activeTab !== $key,
|
||||||
|
])
|
||||||
|
>
|
||||||
|
<x-filament::icon
|
||||||
|
:icon="$tab['icon']"
|
||||||
|
@class([
|
||||||
|
'fi-tabs-item-icon h-5 w-5 shrink-0 transition duration-75',
|
||||||
|
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
|
||||||
|
'text-gray-400 group-hover:text-gray-500 group-focus-visible:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400 dark:group-focus-visible:text-gray-400' => $activeTab !== $key,
|
||||||
|
])
|
||||||
|
/>
|
||||||
|
<span @class([
|
||||||
|
'fi-tabs-item-label transition duration-75',
|
||||||
|
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
|
||||||
|
'text-gray-500 group-hover:text-gray-700 group-focus-visible:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 dark:group-focus-visible:text-gray-200' => $activeTab !== $key,
|
||||||
|
])>
|
||||||
|
{{ $tab['label'] }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use App\Http\Controllers\AutomationApiController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use App\Http\Controllers\GitWebhookController;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/user', function (Request $request) {
|
Route::get('/user', function (Request $request) {
|
||||||
return $request->user();
|
return $request->user();
|
||||||
@@ -74,7 +76,7 @@ Route::post('/internal/page-cache', function (Request $request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$agent = new AgentClient();
|
$agent = new AgentClient;
|
||||||
|
|
||||||
if ($enabled) {
|
if ($enabled) {
|
||||||
$result = $agent->send('wp.page_cache_enable', [
|
$result = $agent->send('wp.page_cache_enable', [
|
||||||
@@ -143,7 +145,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$agent = new AgentClient();
|
$agent = new AgentClient;
|
||||||
|
|
||||||
if ($purgeAll || empty($paths)) {
|
if ($purgeAll || empty($paths)) {
|
||||||
// Purge entire domain cache
|
// Purge entire domain cache
|
||||||
@@ -163,3 +165,13 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
|
|||||||
return response()->json(['error' => $e->getMessage()], 500);
|
return response()->json(['error' => $e->getMessage()], 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class);
|
||||||
|
|
||||||
|
Route::middleware(['auth:sanctum', 'abilities:automation'])
|
||||||
|
->prefix('automation')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/users', [AutomationApiController::class, 'listUsers']);
|
||||||
|
Route::post('/users', [AutomationApiController::class, 'createUser']);
|
||||||
|
Route::post('/domains', [AutomationApiController::class, 'createDomain']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ Schedule::command('jabali:sync-mailbox-quotas')
|
|||||||
->runInBackground()
|
->runInBackground()
|
||||||
->appendOutputTo(storage_path('logs/mailbox-quota-sync.log'));
|
->appendOutputTo(storage_path('logs/mailbox-quota-sync.log'));
|
||||||
|
|
||||||
|
// User Resource Usage - runs hourly to capture per-user usage history
|
||||||
|
Schedule::command('jabali:collect-user-usage')
|
||||||
|
->hourly()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground()
|
||||||
|
->appendOutputTo(storage_path('logs/user-usage.log'));
|
||||||
|
|
||||||
// Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention)
|
// Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention)
|
||||||
Schedule::call(function () {
|
Schedule::call(function () {
|
||||||
$deleted = AuditLog::prune();
|
$deleted = AuditLog::prune();
|
||||||
|
|||||||
55
tests/Feature/ResourceUsageChartsTest.php
Normal file
55
tests/Feature/ResourceUsageChartsTest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserResourceUsage;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ResourceUsageChartsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_admin_resource_usage_page_renders_chart_with_data(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$metrics = ['disk_bytes', 'mail_bytes', 'database_bytes', 'bandwidth_bytes'];
|
||||||
|
foreach ($metrics as $metric) {
|
||||||
|
UserResourceUsage::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'metric' => $metric,
|
||||||
|
'value' => 1024 * 1024 * 250,
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin, 'admin')->get('/jabali-admin/resource-usage');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('Resource Usage (Last 30 Days)');
|
||||||
|
$response->assertSee('resource-usage-chart-', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_logs_usage_tab_renders_chart(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
UserResourceUsage::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'metric' => 'disk_bytes',
|
||||||
|
'value' => 1024 * 1024 * 250,
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get('/jabali-panel/logs?tab=usage');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('Resource Usage (Last 30 Days)');
|
||||||
|
$response->assertSee('x-ref="chart"', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user