Update hosting features and resource usage

This commit is contained in:
root
2026-01-27 23:38:27 +02:00
parent 7c2d780b9d
commit cc1196a390
91 changed files with 9758 additions and 403 deletions

2019
AGENT.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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.
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.

View File

@@ -1 +1 @@
VERSION=0.9-rc5
VERSION=0.9-rc8

View 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,
]);
}
}

View 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.'));
}
}

View 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.'));
}
}

View 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()),
]);
}
}

View 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',
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages;
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\LynisResultsTable;
use App\Filament\Admin\Widgets\Security\NiktoResultsTable;
@@ -90,6 +91,8 @@ class Security extends Page implements HasActions, HasForms, HasTable
public ?int $totalBanned = null;
public array $fail2banLogs = [];
public int $maxRetry = 5;
public int $banTime = 600;
@@ -545,6 +548,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]),
])
->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),
];
}
@@ -1012,6 +1021,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
} catch (Exception $e) {
$this->fail2banInstalled = false;
$this->fail2banRunning = false;
@@ -1019,6 +1029,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
}
}
@@ -1039,12 +1050,16 @@ class Security extends Page implements HasActions, HasForms, HasTable
$jailsResult = $this->getAgent()->send('fail2ban.list_jails');
$this->availableJails = $jailsResult['jails'] ?? [];
$logsResult = $this->getAgent()->send('fail2ban.logs');
$this->fail2banLogs = $logsResult['logs'] ?? [];
} else {
$this->fail2banRunning = false;
$this->fail2banVersion = '';
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
}
} catch (Exception $e) {
$this->fail2banInstalled = false;
@@ -1053,6 +1068,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
}
}

View 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()),
]);
}
}

View 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();
}
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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),
]);
}
}

View File

@@ -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.'));
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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),
]);
}
}

View File

@@ -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');
}
}

View File

@@ -5,17 +5,21 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Str;
use Exception;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeCreate(array $data): array
{
// Generate SFTP password (same as user password or random)
@@ -23,6 +27,14 @@ class CreateUser extends CreateRecord
$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;
}
@@ -32,7 +44,7 @@ class CreateUser extends CreateRecord
if ($createLinuxUser) {
try {
$linuxService = new LinuxUserService();
$linuxService = new LinuxUserService;
// Get the plain password before it was hashed
$password = $this->data['sftp_password'] ?? null;
@@ -47,6 +59,9 @@ class CreateUser extends CreateRecord
// Apply disk quota if enabled
$this->applyDiskQuota();
// Apply resource limits from package
$this->syncResourceLimitsFromPackage($this->selectedPackage, true);
} catch (Exception $e) {
Notification::make()
->title(__('Linux user creation failed'))
@@ -54,6 +69,17 @@ class CreateUser extends CreateRecord
->danger()
->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
try {
$agent = new AgentClient();
$agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) $quotaMb);
if ($result['success'] ?? false) {
@@ -87,4 +113,50 @@ class CreateUser extends CreateRecord
->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);
}
}
}

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
@@ -19,6 +22,8 @@ class EditUser extends EditRecord
protected ?int $originalQuota = null;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeFill(array $data): array
{
$this->originalQuota = $data['disk_quota_mb'] ?? null;
@@ -26,13 +31,23 @@ class EditUser extends EditRecord
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
{
$newQuota = $this->record->disk_quota_mb;
if ($newQuota === $this->originalQuota) {
return;
}
if ($newQuota !== $this->originalQuota) {
// Always try to apply quota when changed
try {
$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
{
return [

View File

@@ -2,18 +2,15 @@
namespace App\Filament\Admin\Resources\Users\Schemas;
use App\Models\DnsSetting;
use App\Models\HostingPackage;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
class UserForm
{
@@ -125,6 +122,24 @@ class UserForm
->helperText(__('Inactive users cannot log in'))
->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')
->label(__('Create Linux User'))
->default(true)
@@ -138,63 +153,6 @@ class UserForm
])
->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'))
->schema([
Placeholder::make('home_directory_display')

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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),
]);
}
}

View File

@@ -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.'));
}
}

View File

@@ -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'),
];
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -452,6 +452,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
->helperText(__('This name will be used for both the database and user')),
])
->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'];
$password = $this->generateSecurePassword();
@@ -513,6 +524,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
->helperText(__('Only alphanumeric characters allowed')),
])
->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'];
try {
$this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name);

View File

@@ -6,6 +6,7 @@ namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\DomainAlias;
use App\Models\DomainHotlinkSetting;
use App\Models\DomainRedirect;
use App\Services\Agent\AgentClient;
@@ -35,12 +36,12 @@ use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
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 InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
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
{
if ($this->agent === null) {
$this->agent = new AgentClient();
$this->agent = new AgentClient;
}
return $this->agent;
}
@@ -136,6 +138,28 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->form(fn (Domain $record) => $this->getRedirectsForm($record))
->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record))
->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')
->label(__('Hotlink Protection'))
->icon('heroicon-o-shield-check')
@@ -379,6 +403,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
'redirect_url' => '',
];
}
return [
'is_enabled' => $setting->is_enabled,
'allowed_domains' => $setting->allowed_domains,
@@ -435,6 +460,18 @@ class Domains extends Page implements HasForms, HasActions, HasTable
])
->action(function (array $data): void {
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']);
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);
$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}";
}
}

View File

@@ -11,7 +11,9 @@ use App\Models\Domain;
use App\Models\EmailDomain;
use App\Models\EmailForwarder;
use App\Models\Mailbox;
use App\Models\UserSetting;
use App\Services\Agent\AgentClient;
use App\Services\System\MailRoutingSyncService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
@@ -65,6 +67,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
public string $credPassword = '';
public array $spamFormData = [];
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
@@ -76,11 +80,18 @@ class Email extends Page implements HasActions, HasForms, HasTable
{
// Normalize the tab value from URL
$this->activeTab = $this->normalizeTabName($this->activeTab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
}
public function updatedActiveTab(): void
{
$this->activeTab = $this->normalizeTabName($this->activeTab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
$this->resetTable();
}
@@ -99,6 +110,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders', 'Autoresponders' => 'autoresponders',
'catchall', 'catch-all', 'Catch-All' => 'catchall',
'logs', 'Logs' => 'logs',
'spam', 'Spam' => 'spam',
default => 'mailboxes',
};
}
@@ -111,13 +123,14 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders' => 3,
'catchall' => 4,
'logs' => 5,
'spam' => 6,
default => 1,
};
}
protected function getForms(): array
{
return ['emailForm'];
return ['emailForm', 'spamForm'];
}
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
{
$this->activeTab = $this->normalizeTabName($tab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
$this->resetTable();
}
@@ -170,6 +211,60 @@ class Email extends Page implements HasActions, HasForms, HasTable
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
{
return match ($this->activeTab) {
@@ -178,6 +273,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders' => $this->autorespondersTable($table),
'catchall' => $this->catchAllTable($table),
'logs' => $this->emailLogsTable($table),
'spam' => $this->mailboxesTable($table),
default => $this->mailboxesTable($table),
};
}
@@ -778,6 +874,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true,
]);
$this->syncMailRouting();
// Generate DKIM
try {
$dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain);
@@ -830,6 +928,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
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
{
try {
@@ -924,6 +1035,17 @@ class Email extends Page implements HasActions, HasForms, HasTable
->helperText(__('Storage limit in megabytes')),
])
->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']);
if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send();
@@ -965,6 +1087,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true,
]);
$this->syncMailRouting();
$this->credEmail = $email;
$this->credPassword = $data['password'];
@@ -1016,6 +1140,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus);
$mailbox->update(['is_active' => $newStatus]);
$this->syncMailRouting();
Notification::make()
->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled'))
->success()
@@ -1037,6 +1163,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$mailbox->delete();
$this->syncMailRouting();
Notification::make()->title(__('Mailbox deleted'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1122,6 +1250,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true,
]);
$this->syncMailRouting();
Notification::make()->title(__('Forwarder created'))->success()->send();
} catch (Exception $e) {
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]);
$this->syncMailRouting();
Notification::make()->title(__('Forwarder updated'))->success()->send();
} catch (Exception $e) {
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]);
$this->syncMailRouting();
Notification::make()
->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled'))
->success()
@@ -1192,6 +1326,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$forwarder->delete();
$this->syncMailRouting();
Notification::make()->title(__('Forwarder deleted'))->success()->send();
} catch (Exception $e) {
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,
]);
$this->syncMailRouting();
Notification::make()
->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled'))
->success()

View 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),
];
}
}

View 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();
}
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\AuditLog;
use App\Models\UserResourceUsage;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
@@ -42,6 +44,9 @@ class Logs extends Page implements HasActions, HasForms
#[Url]
public ?string $selectedDomain = null;
#[Url(as: 'tab')]
public string $activeTab = 'logs';
public string $logType = 'access';
public int $logLines = 100;
@@ -64,6 +69,7 @@ class Logs extends Page implements HasActions, HasForms
public function mount(): void
{
$this->loadDomains();
$this->activeTab = $this->normalizeTab($this->activeTab);
if (! empty($this->domains) && ! $this->selectedDomain) {
$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
{
if ($this->agent === null) {
@@ -90,11 +117,15 @@ class Logs extends Page implements HasActions, HasForms
protected function loadDomains(): void
{
try {
$result = $this->getAgent()->send('domain.list', [
'username' => $this->getUsername(),
]);
$this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : [];
} catch (\Throwable $exception) {
$this->domains = [];
}
}
public function getDomainOptions(): array
@@ -164,6 +195,66 @@ class Logs extends Page implements HasActions, HasForms
->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
{
if (! $this->selectedDomain) {
@@ -216,14 +307,14 @@ class Logs extends Page implements HasActions, HasForms
->label(__('Generate Statistics'))
->icon('heroicon-o-chart-bar')
->color('primary')
->visible(fn () => $this->selectedDomain !== null)
->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'stats')
->action(fn () => $this->generateStats()),
Action::make('refreshLogs')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->visible(fn () => $this->selectedDomain !== null)
->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'logs')
->action(fn () => $this->refreshLogs()),
];
}

View 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();
}
}

View 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];
}
}

View File

@@ -4,22 +4,25 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
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\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Concerns\InteractsWithTable;
@@ -28,20 +31,18 @@ use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
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';
use InteractsWithForms;
use InteractsWithActions;
use InteractsWithTable;
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
protected static ?int $navigationSort = 4;
public static function getNavigationLabel(): string
@@ -52,23 +53,32 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
protected string $view = 'filament.jabali.pages.wordpress';
public array $sites = [];
public array $domains = [];
public ?string $selectedSiteId = null;
// Credentials modal
public bool $showCredentials = false;
public array $credentials = [];
// Scan modal
public bool $showScanModal = false;
public array $scannedSites = [];
public bool $isScanning = false;
// Security scan
public bool $showSecurityScanModal = false;
public array $securityScanResults = [];
public bool $isSecurityScanning = false;
public ?string $scanningSiteId = null;
public ?string $scanningSiteUrl = null;
protected ?AgentClient $agent = null;
@@ -81,8 +91,9 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient();
$this->agent = new AgentClient;
}
return $this->agent;
}
@@ -205,6 +216,17 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->alphaNum(),
])
->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')
->label(__('Security Scan'))
->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.'))
->warning()
->send();
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
{
try {
@@ -958,6 +1011,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->title(__('Site not found'))
->danger()
->send();
return;
}
@@ -969,6 +1023,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->body(__('Please contact your administrator to enable security scanning.'))
->warning()
->send();
return;
}
@@ -985,7 +1040,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
// Run WPScan
$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);
$results = json_decode($jsonOutput, true);
@@ -1201,8 +1256,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->infolist([
Section::make(__('Discovered Sites'))
->schema(
collect($this->scannedSites)->map(fn ($site, $index) =>
TextEntry::make("site_{$index}")
collect($this->scannedSites)->map(fn ($site, $index) => TextEntry::make("site_{$index}")
->label($site['site_url'] ?? __('WordPress Site'))
->state($site['path'])
->helperText(isset($site['version']) ? 'v'.$site['version'] : '')
@@ -1324,6 +1378,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->title(__('Site not found'))
->danger()
->send();
return;
}
@@ -1348,6 +1403,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->body(__('Screenshot script not found.'))
->warning()
->send();
return;
}
@@ -1399,6 +1455,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function hasScreenshot(string $siteId): bool
{
$filename = 'wp_'.$siteId.'.png';
return file_exists(storage_path('app/public/screenshots/'.$filename));
}
}

View 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);
}
}

View 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']);
}
}

View 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(),
]);
}
}
}

View 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);
}
}

View File

@@ -48,6 +48,9 @@ class Domain extends Model
// Delete hotlink settings
$domain->hotlinkSetting?->delete();
// Delete aliases
$domain->aliases()->delete();
// Delete email domain and related records
if ($domain->emailDomain) {
// Delete mailboxes (which will cascade to autoresponders)
@@ -103,6 +106,11 @@ class Domain extends Model
return $this->hasMany(DomainRedirect::class);
}
public function aliases(): HasMany
{
return $this->hasMany(DomainAlias::class);
}
public function hotlinkSetting(): HasOne
{
return $this->hasOne(DomainHotlinkSetting::class);

View 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);
}
}

View 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',
];
}

View 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);
}
}

View 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);
}
}

View File

@@ -11,10 +11,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser
{
use HasFactory, Notifiable, TwoFactorAuthenticatable;
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
protected $fillable = [
'name',
@@ -25,6 +26,7 @@ class User extends Authenticatable implements FilamentUser
'sftp_password',
'is_admin',
'is_active',
'hosting_package_id',
'locale',
'disk_quota_mb',
];
@@ -147,6 +149,11 @@ class User extends Authenticatable implements FilamentUser
return $this->hasMany(Domain::class);
}
public function hostingPackage(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(HostingPackage::class);
}
/**
* Get disk usage in bytes.
*/

View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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',
];
}

View File

@@ -227,6 +227,45 @@ class AgentClient
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
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]);
}
// 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
public function domainCreate(string $username, string $domain): array
{
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
{
return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]);
@@ -360,6 +488,23 @@ class AgentClient
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
public function wpCacheEnable(string $username, string $siteId): array
{
@@ -554,6 +699,15 @@ class AgentClient
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
{
return $this->send('email.reload_services');
@@ -1151,4 +1305,77 @@ class AgentClient
{
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,
]);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,77 @@
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
@@ -15,6 +86,19 @@ return [
'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
@@ -28,30 +112,6 @@ return [
'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
@@ -64,8 +124,8 @@ return [
*/
'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file', 'max:524288'], // 512MB max
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file', 'max:524288'], // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'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',
/*
|---------------------------------------------------------------------------
| 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
],
];

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View 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>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View 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>

View File

@@ -5,6 +5,7 @@
'autoresponders' => ['label' => __('Autoresponders'), 'icon' => 'heroicon-o-clock'],
'catchall' => ['label' => __('Catch-All'), 'icon' => 'heroicon-o-inbox-stack'],
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
'spam' => ['label' => __('Spam Settings'), 'icon' => 'heroicon-o-shield-check'],
];
@endphp

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -3,9 +3,21 @@
{{ $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">
{{ $this->table }}
</div>
@endif
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -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>

View File

@@ -1,10 +1,48 @@
<x-filament-panels::page>
@if(count($this->getDomainOptions()) > 0)
{{-- Domain Selector --}}
<x-filament::section
icon="heroicon-o-globe-alt"
icon-color="primary"
@php
$tabs = [
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
'stats' => ['label' => __('Statistics'), 'icon' => 'heroicon-o-chart-bar'],
'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">
{{ __('Select Domain') }}
</x-slot>
@@ -24,12 +62,9 @@
</x-filament::section>
@if($selectedDomain)
{{-- Statistics Banner (shown when generated) --}}
@if($activeTab === 'stats')
@if($statsGenerated)
<x-filament::section
icon="heroicon-o-check-circle"
icon-color="success"
>
<x-filament::section icon="heroicon-o-check-circle" icon-color="success" class="mt-4">
<x-slot name="heading">
{{ __('Statistics Report Ready') }}
</x-slot>
@@ -47,11 +82,10 @@
</x-filament::button>
</x-filament::section>
@endif
@endif
{{-- Log Viewer --}}
<x-filament::section
icon="heroicon-o-document-text"
>
@if($activeTab === 'logs')
<x-filament::section icon="heroicon-o-document-text" class="mt-4">
<x-slot name="heading">
{{ __('Log Viewer') }}
</x-slot>
@@ -62,7 +96,6 @@
@endif
</x-slot>
{{-- Log Type Buttons --}}
<div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4">
<x-filament::button
wire:click="setLogType('access')"
@@ -85,7 +118,6 @@
</x-filament::button>
</div>
{{-- Log Content --}}
@if($logContent)
<div class="fi-input-wrp rounded-lg shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 overflow-hidden">
<textarea
@@ -109,15 +141,12 @@
@endif
</x-filament::section>
@endif
@endif
@else
{{-- No Domains Empty State --}}
<x-filament::section>
<x-filament::section class="mt-4">
<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">
<x-filament::icon
icon="heroicon-o-globe-alt"
class="h-6 w-6 text-gray-500 dark:text-gray-400"
/>
<x-filament::icon icon="heroicon-o-globe-alt" 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 Domains Yet') }}
@@ -126,16 +155,124 @@
{{ __('Add a domain first to view logs and statistics.') }}
</p>
<div class="mt-6">
<x-filament::button
href="{{ route('filament.jabali.pages.domains') }}"
tag="a"
>
<x-filament::button href="{{ route('filament.jabali.pages.domains') }}" tag="a">
{{ __('Add Domain') }}
</x-filament::button>
</div>
</div>
</x-filament::section>
@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-panels::page>

View File

@@ -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>

View 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>

View File

@@ -1,9 +1,11 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Cache;
use App\Http\Controllers\AutomationApiController;
use App\Http\Controllers\GitWebhookController;
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) {
return $request->user();
@@ -74,7 +76,7 @@ Route::post('/internal/page-cache', function (Request $request) {
}
try {
$agent = new AgentClient();
$agent = new AgentClient;
if ($enabled) {
$result = $agent->send('wp.page_cache_enable', [
@@ -143,7 +145,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
}
try {
$agent = new AgentClient();
$agent = new AgentClient;
if ($purgeAll || empty($paths)) {
// Purge entire domain cache
@@ -163,3 +165,13 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
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']);
});

View File

@@ -66,6 +66,13 @@ Schedule::command('jabali:sync-mailbox-quotas')
->runInBackground()
->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)
Schedule::call(function () {
$deleted = AuditLog::prune();

View 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);
}
}