Compare commits

...

12 Commits

Author SHA1 Message Date
root
cc1196a390 Update hosting features and resource usage 2026-01-27 23:38:27 +02:00
root
7c2d780b9d Merge github main 2026-01-25 03:22:35 +02:00
root
0d33c99f30 Ignore CLAUDE.md and bump build number 2026-01-25 03:19:12 +02:00
root
5208992deb Remove CLAUDE from GitHub and bump version 2026-01-24 22:09:47 +02:00
root
9d866cbac0 Regenerate screenshots and bump version 2026-01-24 21:26:31 +02:00
root
5c2da51538 Fix nginx fastcgi retry template 2026-01-24 20:20:32 +02:00
root
abee59cd43 Add README branding header 2026-01-24 20:11:24 +02:00
root
0691521ecf Allow SSH origin remote 2026-01-24 20:02:07 +02:00
root
7079cbef29 Allow local and GitHub remotes 2026-01-24 19:57:28 +02:00
root
25a645c047 Switch git remote allowlist 2026-01-24 19:56:20 +02:00
root
1d34cdf28a Update screenshots 2026-01-24 19:52:33 +02:00
root
b49e3937a5 Initial release 2026-01-24 19:36:46 +02:00
92 changed files with 7740 additions and 421 deletions

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ yarn-error.log
/.idea /.idea
/.vscode /.vscode
/.claude /.claude
CLAUDE.md

View File

@@ -75,24 +75,6 @@ VERSION=0.9-rc
|-------|--------------|--------| |-------|--------------|--------|
| `VERSION` | Every push | `0.9-rc`, `0.9-rc1`, `0.9-rc2`, ... | | `VERSION` | Every push | `0.9-rc`, `0.9-rc1`, `0.9-rc2`, ... |
## Test Server
```bash
# SSH Access
ssh test-server
# IMPORTANT: When running git or artisan commands on test server,
# always cd to /var/www/jabali first:
ssh test-server "cd /var/www/jabali && git pull"
ssh test-server "cd /var/www/jabali && php artisan optimize:clear"
```
| Panel | URL | Email | Password |
|-------|-----|-------|----------|
| Admin | https://mx.jabali-panel.com/jabali-admin/ | admin@mx.jabali-panel.com | PycpS1dUuLvxMMQs |
| User | https://mx.jabali-panel.com/jabali-panel/ | user@jabali-panel.com | PycpS1dUuLvxMMQs |
| Webmail | https://mx.jabali-panel.com/webmail/ | (use mailbox credentials) | |
## Project Structure ## Project Structure
``` ```

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. A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
Version: 0.9-rc2 (release candidate) Version: 0.9-rc8 (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0. This is a release candidate. Expect rapid iteration and breaking changes until 1.0.

View File

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

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,24 +5,36 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages; namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource; use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService; use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Str;
use Exception;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeCreate(array $data): array protected function mutateFormDataBeforeCreate(array $data): array
{ {
// Generate SFTP password (same as user password or random) // Generate SFTP password (same as user password or random)
if (!empty($data['password'])) { if (! empty($data['password'])) {
$data['sftp_password'] = $data['password']; $data['sftp_password'] = $data['password'];
} }
if (! empty($data['hosting_package_id'])) {
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
} else {
$this->selectedPackage = null;
$data['disk_quota_mb'] = null;
}
return $data; return $data;
} }
@@ -32,7 +44,7 @@ class CreateUser extends CreateRecord
if ($createLinuxUser) { if ($createLinuxUser) {
try { try {
$linuxService = new LinuxUserService(); $linuxService = new LinuxUserService;
// Get the plain password before it was hashed // Get the plain password before it was hashed
$password = $this->data['sftp_password'] ?? null; $password = $this->data['sftp_password'] ?? null;
@@ -47,6 +59,9 @@ class CreateUser extends CreateRecord
// Apply disk quota if enabled // Apply disk quota if enabled
$this->applyDiskQuota(); $this->applyDiskQuota();
// Apply resource limits from package
$this->syncResourceLimitsFromPackage($this->selectedPackage, true);
} catch (Exception $e) { } catch (Exception $e) {
Notification::make() Notification::make()
->title(__('Linux user creation failed')) ->title(__('Linux user creation failed'))
@@ -54,19 +69,30 @@ class CreateUser extends CreateRecord
->danger() ->danger()
->send(); ->send();
} }
} else {
// Store resource limits even if the Linux user was not created yet
$this->syncResourceLimitsFromPackage($this->selectedPackage, false);
}
if (! $this->record->hosting_package_id) {
Notification::make()
->title(__('No hosting package selected'))
->body(__('This user has unlimited resource limits.'))
->warning()
->send();
} }
} }
protected function applyDiskQuota(): void protected function applyDiskQuota(): void
{ {
$quotaMb = $this->record->disk_quota_mb; $quotaMb = $this->record->disk_quota_mb;
if (!$quotaMb || $quotaMb <= 0) { if (! $quotaMb || $quotaMb <= 0) {
return; return;
} }
// Always try to apply quota when set // Always try to apply quota when set
try { try {
$agent = new AgentClient(); $agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) $quotaMb); $result = $agent->quotaSet($this->record->username, (int) $quotaMb);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
@@ -87,4 +113,50 @@ class CreateUser extends CreateRecord
->send(); ->send();
} }
} }
protected function syncResourceLimitsFromPackage(?HostingPackage $package, bool $apply): void
{
if (! $this->record) {
return;
}
$cpu = $package?->cpu_limit_percent;
$memory = $package?->memory_limit_mb;
$io = $package?->io_limit_mb;
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
if (! $package || ! $hasLimits) {
if ($limit) {
$limit->fill([
'cpu_limit_percent' => null,
'memory_limit_mb' => null,
'io_limit_mb' => null,
'is_active' => false,
])->save();
if ($apply) {
app(ResourceLimitService::class)->clear($limit);
}
}
return;
}
if (! $limit) {
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
}
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
if ($apply) {
app(ResourceLimitService::class)->apply($limit);
}
}
} }

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages; namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource; use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService; use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception; use Exception;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
@@ -19,6 +22,8 @@ class EditUser extends EditRecord
protected ?int $originalQuota = null; protected ?int $originalQuota = null;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeFill(array $data): array protected function mutateFormDataBeforeFill(array $data): array
{ {
$this->originalQuota = $data['disk_quota_mb'] ?? null; $this->originalQuota = $data['disk_quota_mb'] ?? null;
@@ -26,39 +31,90 @@ class EditUser extends EditRecord
return $data; return $data;
} }
protected function mutateFormDataBeforeSave(array $data): array
{
if (! empty($data['hosting_package_id'])) {
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
} else {
$this->selectedPackage = null;
$data['disk_quota_mb'] = null;
}
return $data;
}
protected function afterSave(): void protected function afterSave(): void
{ {
$newQuota = $this->record->disk_quota_mb; $newQuota = $this->record->disk_quota_mb;
if ($newQuota === $this->originalQuota) { if ($newQuota !== $this->originalQuota) {
// Always try to apply quota when changed
try {
$agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) ($newQuota ?? 0));
if ($result['success'] ?? false) {
$message = $newQuota && $newQuota > 0
? __('Quota updated to :size GB', ['size' => number_format($newQuota / 1024, 1)])
: __('Quota removed (unlimited)');
Notification::make()
->title(__('Disk quota updated'))
->body($message)
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Unknown error'));
}
} catch (Exception $e) {
// Show warning but don't fail - quota value is saved in database
Notification::make()
->title(__('Disk quota update failed'))
->body(__('Value saved but filesystem quota not applied: :error', ['error' => $e->getMessage()]))
->warning()
->send();
}
}
$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; return;
} }
// Always try to apply quota when changed if (! $limit) {
try { $limit = new UserResourceLimit(['user_id' => $this->record->id]);
$agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) ($newQuota ?? 0));
if ($result['success'] ?? false) {
$message = $newQuota && $newQuota > 0
? __('Quota updated to :size GB', ['size' => number_format($newQuota / 1024, 1)])
: __('Quota removed (unlimited)');
Notification::make()
->title(__('Disk quota updated'))
->body($message)
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Unknown error'));
}
} catch (Exception $e) {
// Show warning but don't fail - quota value is saved in database
Notification::make()
->title(__('Disk quota update failed'))
->body(__('Value saved but filesystem quota not applied: :error', ['error' => $e->getMessage()]))
->warning()
->send();
} }
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
app(ResourceLimitService::class)->apply($limit);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array

View File

@@ -2,18 +2,15 @@
namespace App\Filament\Admin\Resources\Users\Schemas; namespace App\Filament\Admin\Resources\Users\Schemas;
use App\Models\DnsSetting; use App\Models\HostingPackage;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Group;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
class UserForm class UserForm
{ {
@@ -29,12 +26,12 @@ class UserForm
// Ensure at least one of each type // Ensure at least one of each type
$password = $uppercase[random_int(0, strlen($uppercase) - 1)] $password = $uppercase[random_int(0, strlen($uppercase) - 1)]
. $lowercase[random_int(0, strlen($lowercase) - 1)] .$lowercase[random_int(0, strlen($lowercase) - 1)]
. $numbers[random_int(0, strlen($numbers) - 1)] .$numbers[random_int(0, strlen($numbers) - 1)]
. $special[random_int(0, strlen($special) - 1)]; .$special[random_int(0, strlen($special) - 1)];
// Fill rest with random characters from all pools // Fill rest with random characters from all pools
$allChars = $uppercase . $lowercase . $numbers . $special; $allChars = $uppercase.$lowercase.$numbers.$special;
for ($i = 4; $i < $length; $i++) { for ($i = 4; $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)]; $password .= $allChars[random_int(0, strlen($allChars) - 1)];
} }
@@ -125,6 +122,24 @@ class UserForm
->helperText(__('Inactive users cannot log in')) ->helperText(__('Inactive users cannot log in'))
->inline(false), ->inline(false),
Placeholder::make('package_notice')
->label(__('Hosting Package'))
->content(__('No hosting package selected. This user will have unlimited resources.'))
->visible(fn ($get) => blank($get('hosting_package_id'))),
\Filament\Forms\Components\Select::make('hosting_package_id')
->label(__('Hosting Package'))
->searchable()
->preload()
->options(fn () => HostingPackage::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->toArray())
->placeholder(__('Unlimited (no package)'))
->helperText(__('Assign a package to set resource limits.'))
->columnSpanFull(),
Toggle::make('create_linux_user') Toggle::make('create_linux_user')
->label(__('Create Linux User')) ->label(__('Create Linux User'))
->default(true) ->default(true)
@@ -138,68 +153,11 @@ class UserForm
]) ])
->columns(4), ->columns(4),
Section::make(__('Disk Quota'))
->schema([
Placeholder::make('current_usage')
->label(__('Current Usage'))
->content(function ($record) {
if (!$record) {
return __('N/A');
}
$used = $record->disk_usage_formatted;
$quotaMb = $record->disk_quota_mb;
if (!$quotaMb || $quotaMb <= 0) {
return "{$used} (" . __('Unlimited') . ")";
}
$quota = $quotaMb >= 1024
? number_format($quotaMb / 1024, 1) . ' GB'
: $quotaMb . ' MB';
$percent = $record->disk_usage_percent;
return "{$used} / {$quota} ({$percent}%)";
})
->visibleOn('edit'),
Toggle::make('unlimited_quota')
->label(__('Unlimited Quota'))
->helperText(__('No disk space limit'))
->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120) === 0)
->live()
->afterStateHydrated(function (Toggle $component, $state, $record) {
if ($record) {
$component->state(!$record->disk_quota_mb || $record->disk_quota_mb <= 0);
}
})
->afterStateUpdated(function ($state, callable $set) {
if ($state) {
$set('disk_quota_mb', 0);
} else {
$set('disk_quota_mb', (int) DnsSetting::get('default_quota_mb', 5120));
}
})
->inline(false),
TextInput::make('disk_quota_mb')
->label(__('Disk Quota'))
->numeric()
->minValue(1)
->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120))
->helperText(fn ($state) => $state && $state > 0 ? number_format($state / 1024, 1) . ' GB' : null)
->suffix('MB')
->visible(fn ($get) => !$get('unlimited_quota'))
->required(fn ($get) => !$get('unlimited_quota')),
])
->description(fn () => !(bool) DnsSetting::get('quotas_enabled', false) ? __('Note: Quotas are currently disabled in Server Settings.') : null)
->columns(3),
Section::make(__('System Information')) Section::make(__('System Information'))
->schema([ ->schema([
Placeholder::make('home_directory_display') Placeholder::make('home_directory_display')
->label(__('Home Directory')) ->label(__('Home Directory'))
->content(fn ($record) => $record?->home_directory ?? '/home/' . __('username')), ->content(fn ($record) => $record?->home_directory ?? '/home/'.__('username')),
Placeholder::make('created_at_display') Placeholder::make('created_at_display')
->label(__('Created')) ->label(__('Created'))

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')), ->helperText(__('This name will be used for both the database and user')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
$limit = Auth::user()?->hostingPackage?->databases_limit;
if ($limit && count($this->databases) >= $limit) {
Notification::make()
->title(__('Database limit reached'))
->body(__('Your hosting package allows up to :limit databases.', ['limit' => $limit]))
->warning()
->send();
return;
}
$name = $this->getUsername().'_'.$data['name']; $name = $this->getUsername().'_'.$data['name'];
$password = $this->generateSecurePassword(); $password = $this->generateSecurePassword();
@@ -513,6 +524,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
->helperText(__('Only alphanumeric characters allowed')), ->helperText(__('Only alphanumeric characters allowed')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
$limit = Auth::user()?->hostingPackage?->databases_limit;
if ($limit && count($this->databases) >= $limit) {
Notification::make()
->title(__('Database limit reached'))
->body(__('Your hosting package allows up to :limit databases.', ['limit' => $limit]))
->warning()
->send();
return;
}
$name = $this->getUsername().'_'.$data['name']; $name = $this->getUsername().'_'.$data['name'];
try { try {
$this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name); $this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name);

View File

@@ -6,6 +6,7 @@ namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour; use App\Filament\Concerns\HasPageTour;
use App\Models\Domain; use App\Models\Domain;
use App\Models\DomainAlias;
use App\Models\DomainHotlinkSetting; use App\Models\DomainHotlinkSetting;
use App\Models\DomainRedirect; use App\Models\DomainRedirect;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
@@ -35,12 +36,12 @@ use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class Domains extends Page implements HasForms, HasActions, HasTable class Domains extends Page implements HasActions, HasForms, HasTable
{ {
use InteractsWithForms;
use InteractsWithActions;
use InteractsWithTable;
use HasPageTour; use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
@@ -63,8 +64,9 @@ class Domains extends Page implements HasForms, HasActions, HasTable
public function getAgent(): AgentClient public function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
$this->agent = new AgentClient(); $this->agent = new AgentClient;
} }
return $this->agent; return $this->agent;
} }
@@ -83,7 +85,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->icon('heroicon-o-globe-alt') ->icon('heroicon-o-globe-alt')
->iconColor('primary') ->iconColor('primary')
->description(fn (Domain $record) => $record->document_root) ->description(fn (Domain $record) => $record->document_root)
->url(fn (Domain $record) => 'http://' . $record->domain, shouldOpenInNewTab: true) ->url(fn (Domain $record) => 'http://'.$record->domain, shouldOpenInNewTab: true)
->searchable() ->searchable()
->sortable(), ->sortable(),
IconColumn::make('is_active') IconColumn::make('is_active')
@@ -136,6 +138,28 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->form(fn (Domain $record) => $this->getRedirectsForm($record)) ->form(fn (Domain $record) => $this->getRedirectsForm($record))
->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record)) ->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record))
->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)), ->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)),
Action::make('aliases')
->label(__('Aliases'))
->icon('heroicon-o-link')
->color('info')
->modalHeading(fn (Domain $record) => __('Aliases for :domain', ['domain' => $record->domain]))
->modalDescription(__('Point additional domains to the same website content.'))
->modalWidth(Width::Large)
->modalSubmitActionLabel(__('Save Aliases'))
->form(fn (Domain $record) => $this->getAliasesForm($record))
->fillForm(fn (Domain $record) => $this->getAliasesFormData($record))
->action(fn (Domain $record, array $data) => $this->saveAliases($record, $data)),
Action::make('errorPages')
->label(__('Error Pages'))
->icon('heroicon-o-document-text')
->color('gray')
->modalHeading(fn (Domain $record) => __('Custom Error Pages for :domain', ['domain' => $record->domain]))
->modalDescription(__('Customize the 404, 500 and 503 pages shown to visitors.'))
->modalWidth(Width::TwoExtraLarge)
->modalSubmitActionLabel(__('Save Error Pages'))
->form(fn (Domain $record) => $this->getErrorPagesForm())
->fillForm(fn (Domain $record) => $this->getErrorPagesFormData($record))
->action(fn (Domain $record, array $data) => $this->saveErrorPages($record, $data)),
Action::make('hotlink') Action::make('hotlink')
->label(__('Hotlink Protection')) ->label(__('Hotlink Protection'))
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
@@ -172,7 +196,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(__('Delete Domain')) ->modalHeading(__('Delete Domain'))
->modalDescription(fn (Domain $record) => __('Are you sure you want to delete') . " '{$record->domain}'? " . __('This will also delete the following associated data:')) ->modalDescription(fn (Domain $record) => __('Are you sure you want to delete')." '{$record->domain}'? ".__('This will also delete the following associated data:'))
->modalIcon('heroicon-o-trash') ->modalIcon('heroicon-o-trash')
->modalIconColor('danger') ->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete Domain')) ->modalSubmitActionLabel(__('Delete Domain'))
@@ -183,12 +207,12 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->helperText(__('Permanently delete all files in the domain folder')) ->helperText(__('Permanently delete all files in the domain folder'))
->default(true), ->default(true),
Toggle::make('delete_dns') Toggle::make('delete_dns')
->label(__('Delete DNS records') . ' (' . $record->dnsRecords()->count() . ')') ->label(__('Delete DNS records').' ('.$record->dnsRecords()->count().')')
->helperText(__('Remove all DNS records for this domain')) ->helperText(__('Remove all DNS records for this domain'))
->default(true) ->default(true)
->visible(fn () => $record->dnsRecords()->exists()), ->visible(fn () => $record->dnsRecords()->exists()),
Toggle::make('delete_email') Toggle::make('delete_email')
->label(__('Delete email accounts') . ' (' . ($record->emailDomain?->mailboxes()->count() ?? 0) . ')') ->label(__('Delete email accounts').' ('.($record->emailDomain?->mailboxes()->count() ?? 0).')')
->helperText(__('Remove all mailboxes and email configuration')) ->helperText(__('Remove all mailboxes and email configuration'))
->default(true) ->default(true)
->visible(fn () => $record->emailDomain()->exists()), ->visible(fn () => $record->emailDomain()->exists()),
@@ -287,13 +311,13 @@ class Domains extends Page implements HasForms, HasActions, HasTable
]) ])
->columns(['default' => 2, 'sm' => 3]), ->columns(['default' => 2, 'sm' => 3]),
]) ])
->itemLabel(fn (array $state): ?string => ($state['source_path'] ?? '') . ' → ' . ($state['redirect_type'] ?? '301')) ->itemLabel(fn (array $state): ?string => ($state['source_path'] ?? '').' → '.($state['redirect_type'] ?? '301'))
->collapsible() ->collapsible()
->collapsed(fn () => $record->redirects()->count() > 3) ->collapsed(fn () => $record->redirects()->count() > 3)
->addActionLabel(__('Add Page Redirect')) ->addActionLabel(__('Add Page Redirect'))
->reorderable() ->reorderable()
->defaultItems(0) ->defaultItems(0)
->visible(fn ($get) => !$get('domain_redirect_enabled')), ->visible(fn ($get) => ! $get('domain_redirect_enabled')),
]; ];
} }
@@ -370,7 +394,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
protected function getHotlinkFormData(Domain $record): array protected function getHotlinkFormData(Domain $record): array
{ {
$setting = $record->hotlinkSetting; $setting = $record->hotlinkSetting;
if (!$setting) { if (! $setting) {
return [ return [
'is_enabled' => false, 'is_enabled' => false,
'allowed_domains' => '', 'allowed_domains' => '',
@@ -379,6 +403,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
'redirect_url' => '', 'redirect_url' => '',
]; ];
} }
return [ return [
'is_enabled' => $setting->is_enabled, 'is_enabled' => $setting->is_enabled,
'allowed_domains' => $setting->allowed_domains, 'allowed_domains' => $setting->allowed_domains,
@@ -435,13 +460,25 @@ class Domains extends Page implements HasForms, HasActions, HasTable
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
try { try {
$user = Auth::user();
$limit = $user?->hostingPackage?->domains_limit;
if ($limit && Domain::where('user_id', $user->id)->count() >= $limit) {
Notification::make()
->title(__('Domain limit reached'))
->body(__('Your hosting package allows up to :limit domains.', ['limit' => $limit]))
->warning()
->send();
return;
}
$result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']); $result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
Domain::create([ Domain::create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'domain' => $data['domain'], 'domain' => $data['domain'],
'document_root' => '/home/' . $this->getUsername() . '/domains/' . $data['domain'] . '/public_html', 'document_root' => '/home/'.$this->getUsername().'/domains/'.$data['domain'].'/public_html',
'is_active' => true, 'is_active' => true,
'ssl_enabled' => false, 'ssl_enabled' => false,
'directory_index' => 'index.php index.html', 'directory_index' => 'index.php index.html',
@@ -495,7 +532,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$redirectsData = $data['redirects'] ?? []; $redirectsData = $data['redirects'] ?? [];
foreach ($redirectsData as $redirectData) { foreach ($redirectsData as $redirectData) {
if (!empty($redirectData['id'])) { if (! empty($redirectData['id'])) {
$redirect = DomainRedirect::find($redirectData['id']); $redirect = DomainRedirect::find($redirectData['id']);
if ($redirect && $redirect->domain_id === $domain->id) { if ($redirect && $redirect->domain_id === $domain->id) {
$redirect->update([ $redirect->update([
@@ -520,7 +557,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
} }
// Delete removed page redirects (but not domain-wide ones which we already handled) // Delete removed page redirects (but not domain-wide ones which we already handled)
if (!empty($existingIds)) { if (! empty($existingIds)) {
$domain->redirects() $domain->redirects()
->whereNotIn('id', $existingIds) ->whereNotIn('id', $existingIds)
->whereNotIn('source_path', ['/*', '*', '/']) ->whereNotIn('source_path', ['/*', '*', '/'])
@@ -565,7 +602,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
{ {
try { try {
$setting = $domain->hotlinkSetting; $setting = $domain->hotlinkSetting;
if (!$setting) { if (! $setting) {
$setting = new DomainHotlinkSetting(['domain_id' => $domain->id]); $setting = new DomainHotlinkSetting(['domain_id' => $domain->id]);
} }
@@ -632,7 +669,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
public function toggleDomain(Domain $domain): void public function toggleDomain(Domain $domain): void
{ {
try { try {
$newStatus = !$domain->is_active; $newStatus = ! $domain->is_active;
$result = $this->getAgent()->domainToggle($this->getUsername(), $domain->domain, $newStatus); $result = $this->getAgent()->domainToggle($this->getUsername(), $domain->domain, $newStatus);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
@@ -640,7 +677,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$status = $newStatus ? __('Enabled') : __('Disabled'); $status = $newStatus ? __('Enabled') : __('Disabled');
Notification::make() Notification::make()
->title(__('Domain') . " {$status}") ->title(__('Domain')." {$status}")
->success() ->success()
->send(); ->send();
} else { } else {
@@ -680,7 +717,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
} }
} }
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('WordPress: ') . $e->getMessage(); $errors[] = __('WordPress: ').$e->getMessage();
} }
} }
@@ -695,7 +732,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$domain->sslCertificate->delete(); $domain->sslCertificate->delete();
$deletedItems[] = __('SSL certificate'); $deletedItems[] = __('SSL certificate');
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('SSL: ') . $e->getMessage(); $errors[] = __('SSL: ').$e->getMessage();
} }
} }
} }
@@ -722,7 +759,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$domain->emailDomain->delete(); $domain->emailDomain->delete();
$deletedItems[] = __(':count email account(s)', ['count' => $mailboxCount]); $deletedItems[] = __(':count email account(s)', ['count' => $mailboxCount]);
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('Email: ') . $e->getMessage(); $errors[] = __('Email: ').$e->getMessage();
} }
} }
} }
@@ -739,7 +776,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$deletedItems[] = __(':count DNS record(s)', ['count' => $dnsCount]); $deletedItems[] = __(':count DNS record(s)', ['count' => $dnsCount]);
} }
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('DNS: ') . $e->getMessage(); $errors[] = __('DNS: ').$e->getMessage();
} }
} }
@@ -758,8 +795,8 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$domain->delete(); $domain->delete();
$message = __('Domain deleted successfully.'); $message = __('Domain deleted successfully.');
if (!empty($deletedItems)) { if (! empty($deletedItems)) {
$message .= ' ' . __('Also deleted: ') . implode(', ', $deletedItems); $message .= ' '.__('Also deleted: ').implode(', ', $deletedItems);
} }
Notification::make() Notification::make()
@@ -768,7 +805,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->success() ->success()
->send(); ->send();
if (!empty($errors)) { if (! empty($errors)) {
Notification::make() Notification::make()
->title(__('Some items had warnings')) ->title(__('Some items had warnings'))
->body(implode("\n", $errors)) ->body(implode("\n", $errors))
@@ -789,7 +826,181 @@ class Domains extends Page implements HasForms, HasActions, HasTable
public function openFileManager(Domain $domain): void public function openFileManager(Domain $domain): void
{ {
$path = str_replace('/home/' . $this->getUsername() . '/', '', $domain->document_root); $path = str_replace('/home/'.$this->getUsername().'/', '', $domain->document_root);
$this->redirect(route('filament.jabali.pages.files', ['path' => $path])); $this->redirect(route('filament.jabali.pages.files', ['path' => $path]));
} }
protected function getAliasesForm(Domain $record): array
{
return [
Repeater::make('aliases')
->label(__('Domain Aliases'))
->schema([
TextInput::make('alias')
->label(__('Alias Domain'))
->placeholder('alias-example.com')
->required()
->rule('regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\\.[a-z]{2,}$/')
->helperText(__('Enter a full domain name.')),
])
->addActionLabel(__('Add Alias'))
->columns(1)
->defaultItems(0),
];
}
protected function getAliasesFormData(Domain $record): array
{
return [
'aliases' => $record->aliases()
->orderBy('alias')
->get()
->map(fn (DomainAlias $alias) => ['alias' => $alias->alias])
->toArray(),
];
}
protected function saveAliases(Domain $record, array $data): void
{
$aliases = collect($data['aliases'] ?? [])
->pluck('alias')
->map(fn ($alias) => strtolower(trim((string) $alias)))
->filter()
->unique()
->values();
$existing = $record->aliases()->pluck('alias')->map(fn ($alias) => strtolower($alias));
$toAdd = $aliases->diff($existing);
$toRemove = $existing->diff($aliases);
$errors = [];
foreach ($toAdd as $alias) {
try {
$result = $this->getAgent()->domainAliasAdd($this->getUsername(), $record->domain, $alias);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Failed to add alias');
}
DomainAlias::firstOrCreate([
'domain_id' => $record->id,
'alias' => $alias,
]);
} catch (Exception $e) {
$errors[] = $alias.': '.$e->getMessage();
}
}
foreach ($toRemove as $alias) {
try {
$result = $this->getAgent()->domainAliasRemove($this->getUsername(), $record->domain, $alias);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Failed to remove alias');
}
$record->aliases()->where('alias', $alias)->delete();
} catch (Exception $e) {
$errors[] = $alias.': '.$e->getMessage();
}
}
if (! empty($errors)) {
Notification::make()
->title(__('Some aliases failed'))
->body(implode("\n", $errors))
->warning()
->send();
return;
}
Notification::make()
->title(__('Aliases updated'))
->success()
->send();
}
protected function getErrorPagesForm(): array
{
return [
Textarea::make('page_404')
->label(__('404 Page (Not Found)'))
->rows(6)
->placeholder(__('HTML content for your 404 page')),
Textarea::make('page_500')
->label(__('500 Page (Server Error)'))
->rows(6)
->placeholder(__('HTML content for your 500 page')),
Textarea::make('page_503')
->label(__('503 Page (Maintenance)'))
->rows(6)
->placeholder(__('HTML content for your 503 page')),
];
}
protected function getErrorPagesFormData(Domain $record): array
{
return [
'page_404' => $this->readErrorPageContent($record, '404.html'),
'page_500' => $this->readErrorPageContent($record, '500.html'),
'page_503' => $this->readErrorPageContent($record, '503.html'),
];
}
protected function saveErrorPages(Domain $record, array $data): void
{
try {
$result = $this->getAgent()->domainEnsureErrorPages($this->getUsername(), $record->domain);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Failed to enable error pages');
}
foreach (['404' => 'page_404', '500' => 'page_500', '503' => 'page_503'] as $code => $key) {
$content = trim((string) ($data[$key] ?? ''));
$path = $this->getErrorPagePath($record, "{$code}.html");
if ($content === '') {
try {
$this->getAgent()->fileDelete($this->getUsername(), $path);
} catch (Exception) {
// Ignore missing file
}
continue;
}
$this->getAgent()->fileWrite($this->getUsername(), $path, $content);
}
Notification::make()
->title(__('Error pages updated'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Failed to update error pages'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function readErrorPageContent(Domain $record, string $filename): string
{
$path = $this->getErrorPagePath($record, $filename);
try {
$result = $this->getAgent()->fileRead($this->getUsername(), $path);
if ($result['success'] ?? false) {
return (string) base64_decode($result['content'] ?? '');
}
} catch (Exception) {
// ignore
}
return '';
}
protected function getErrorPagePath(Domain $record, string $filename): string
{
return "domains/{$record->domain}/public_html/{$filename}";
}
} }

View File

@@ -11,7 +11,9 @@ use App\Models\Domain;
use App\Models\EmailDomain; use App\Models\EmailDomain;
use App\Models\EmailForwarder; use App\Models\EmailForwarder;
use App\Models\Mailbox; use App\Models\Mailbox;
use App\Models\UserSetting;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\MailRoutingSyncService;
use BackedEnum; use BackedEnum;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -65,6 +67,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
public string $credPassword = ''; public string $credPassword = '';
public array $spamFormData = [];
protected ?AgentClient $agent = null; protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable public function getTitle(): string|Htmlable
@@ -76,11 +80,18 @@ class Email extends Page implements HasActions, HasForms, HasTable
{ {
// Normalize the tab value from URL // Normalize the tab value from URL
$this->activeTab = $this->normalizeTabName($this->activeTab); $this->activeTab = $this->normalizeTabName($this->activeTab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
} }
public function updatedActiveTab(): void public function updatedActiveTab(): void
{ {
$this->activeTab = $this->normalizeTabName($this->activeTab); $this->activeTab = $this->normalizeTabName($this->activeTab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
$this->resetTable(); $this->resetTable();
} }
@@ -99,6 +110,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders', 'Autoresponders' => 'autoresponders', 'autoresponders', 'Autoresponders' => 'autoresponders',
'catchall', 'catch-all', 'Catch-All' => 'catchall', 'catchall', 'catch-all', 'Catch-All' => 'catchall',
'logs', 'Logs' => 'logs', 'logs', 'Logs' => 'logs',
'spam', 'Spam' => 'spam',
default => 'mailboxes', default => 'mailboxes',
}; };
} }
@@ -111,13 +123,14 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders' => 3, 'autoresponders' => 3,
'catchall' => 4, 'catchall' => 4,
'logs' => 5, 'logs' => 5,
'spam' => 6,
default => 1, default => 1,
}; };
} }
protected function getForms(): array protected function getForms(): array
{ {
return ['emailForm']; return ['emailForm', 'spamForm'];
} }
public function emailForm(Schema $schema): Schema public function emailForm(Schema $schema): Schema
@@ -127,9 +140,37 @@ class Email extends Page implements HasActions, HasForms, HasTable
]); ]);
} }
public function spamForm(Schema $schema): Schema
{
return $schema
->statePath('spamFormData')
->schema([
Section::make(__('Spam Settings'))
->schema([
Textarea::make('whitelist')
->label(__('Whitelist (one per line)'))
->rows(6)
->placeholder("friend@example.com\ntrusted.com"),
Textarea::make('blacklist')
->label(__('Blacklist (one per line)'))
->rows(6)
->placeholder("spam@example.com\nbad-domain.com"),
TextInput::make('score')
->label(__('Spam Score Threshold'))
->numeric()
->default(6.0)
->helperText(__('Lower values are stricter, higher values are more permissive.')),
])
->columns(2),
]);
}
public function setTab(string $tab): void public function setTab(string $tab): void
{ {
$this->activeTab = $this->normalizeTabName($tab); $this->activeTab = $this->normalizeTabName($tab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
$this->resetTable(); $this->resetTable();
} }
@@ -170,6 +211,60 @@ class Email extends Page implements HasActions, HasForms, HasTable
return str_shuffle($password); return str_shuffle($password);
} }
protected function loadSpamSettings(): void
{
$settings = UserSetting::getForUser(Auth::id(), 'spam_settings', [
'whitelist' => [],
'blacklist' => [],
'score' => 6.0,
]);
$this->spamFormData = [
'whitelist' => implode("\n", $settings['whitelist'] ?? []),
'blacklist' => implode("\n", $settings['blacklist'] ?? []),
'score' => $settings['score'] ?? 6.0,
];
}
public function saveSpamSettings(): void
{
$data = $this->spamForm->getState();
$whitelist = $this->linesToArray($data['whitelist'] ?? '');
$blacklist = $this->linesToArray($data['blacklist'] ?? '');
$score = isset($data['score']) && $data['score'] !== '' ? (float) $data['score'] : null;
UserSetting::setForUser(Auth::id(), 'spam_settings', [
'whitelist' => $whitelist,
'blacklist' => $blacklist,
'score' => $score,
]);
$result = $this->getAgent()->rspamdUserSettings($this->getUsername(), $whitelist, $blacklist, $score);
if (! ($result['success'] ?? false)) {
Notification::make()
->title(__('Failed to update spam settings'))
->body($result['error'] ?? '')
->danger()
->send();
return;
}
Notification::make()
->title(__('Spam settings updated'))
->success()
->send();
}
protected function linesToArray(string $value): array
{
return collect(preg_split('/\\r\\n|\\r|\\n/', $value))
->map(fn ($line) => trim((string) $line))
->filter()
->values()
->toArray();
}
public function table(Table $table): Table public function table(Table $table): Table
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
@@ -178,6 +273,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders' => $this->autorespondersTable($table), 'autoresponders' => $this->autorespondersTable($table),
'catchall' => $this->catchAllTable($table), 'catchall' => $this->catchAllTable($table),
'logs' => $this->emailLogsTable($table), 'logs' => $this->emailLogsTable($table),
'spam' => $this->mailboxesTable($table),
default => $this->mailboxesTable($table), default => $this->mailboxesTable($table),
}; };
} }
@@ -778,6 +874,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true, 'is_active' => true,
]); ]);
$this->syncMailRouting();
// Generate DKIM // Generate DKIM
try { try {
$dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain); $dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain);
@@ -830,6 +928,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
return $emailDomain; return $emailDomain;
} }
protected function syncMailRouting(): void
{
try {
app(MailRoutingSyncService::class)->sync();
} catch (Exception $e) {
Notification::make()
->title(__('Mail routing sync failed'))
->body($e->getMessage())
->warning()
->send();
}
}
protected function regenerateDnsZone(Domain $domain): void protected function regenerateDnsZone(Domain $domain): void
{ {
try { try {
@@ -924,6 +1035,17 @@ class Email extends Page implements HasActions, HasForms, HasTable
->helperText(__('Storage limit in megabytes')), ->helperText(__('Storage limit in megabytes')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
$limit = Auth::user()?->hostingPackage?->mailboxes_limit;
if ($limit && Mailbox::where('user_id', Auth::id())->count() >= $limit) {
Notification::make()
->title(__('Mailbox limit reached'))
->body(__('Your hosting package allows up to :limit mailboxes.', ['limit' => $limit]))
->warning()
->send();
return;
}
$domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']);
if (! $domain) { if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send(); Notification::make()->title(__('Domain not found'))->danger()->send();
@@ -965,6 +1087,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true, 'is_active' => true,
]); ]);
$this->syncMailRouting();
$this->credEmail = $email; $this->credEmail = $email;
$this->credPassword = $data['password']; $this->credPassword = $data['password'];
@@ -1016,6 +1140,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus); $this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus);
$mailbox->update(['is_active' => $newStatus]); $mailbox->update(['is_active' => $newStatus]);
$this->syncMailRouting();
Notification::make() Notification::make()
->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled')) ->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled'))
->success() ->success()
@@ -1037,6 +1163,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$mailbox->delete(); $mailbox->delete();
$this->syncMailRouting();
Notification::make()->title(__('Mailbox deleted'))->success()->send(); Notification::make()->title(__('Mailbox deleted'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1122,6 +1250,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true, 'is_active' => true,
]); ]);
$this->syncMailRouting();
Notification::make()->title(__('Forwarder created'))->success()->send(); Notification::make()->title(__('Forwarder created'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send();
@@ -1149,6 +1279,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$forwarder->update(['destinations' => $destinations]); $forwarder->update(['destinations' => $destinations]);
$this->syncMailRouting();
Notification::make()->title(__('Forwarder updated'))->success()->send(); Notification::make()->title(__('Forwarder updated'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1173,6 +1305,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
]); ]);
$forwarder->update(['is_active' => $newStatus]); $forwarder->update(['is_active' => $newStatus]);
$this->syncMailRouting();
Notification::make() Notification::make()
->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled')) ->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled'))
->success() ->success()
@@ -1192,6 +1326,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$forwarder->delete(); $forwarder->delete();
$this->syncMailRouting();
Notification::make()->title(__('Forwarder deleted'))->success()->send(); Notification::make()->title(__('Forwarder deleted'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1404,6 +1540,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'catch_all_address' => $enabled ? $address : null, 'catch_all_address' => $enabled ? $address : null,
]); ]);
$this->syncMailRouting();
Notification::make() Notification::make()
->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled')) ->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled'))
->success() ->success()

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; namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour; use App\Filament\Concerns\HasPageTour;
use App\Models\AuditLog;
use App\Models\UserResourceUsage;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -42,6 +44,9 @@ class Logs extends Page implements HasActions, HasForms
#[Url] #[Url]
public ?string $selectedDomain = null; public ?string $selectedDomain = null;
#[Url(as: 'tab')]
public string $activeTab = 'logs';
public string $logType = 'access'; public string $logType = 'access';
public int $logLines = 100; public int $logLines = 100;
@@ -64,6 +69,7 @@ class Logs extends Page implements HasActions, HasForms
public function mount(): void public function mount(): void
{ {
$this->loadDomains(); $this->loadDomains();
$this->activeTab = $this->normalizeTab($this->activeTab);
if (! empty($this->domains) && ! $this->selectedDomain) { if (! empty($this->domains) && ! $this->selectedDomain) {
$this->selectedDomain = $this->domains[0]['domain'] ?? null; $this->selectedDomain = $this->domains[0]['domain'] ?? null;
@@ -74,6 +80,27 @@ class Logs extends Page implements HasActions, HasForms
} }
} }
public function updatedActiveTab(): void
{
$this->activeTab = $this->normalizeTab($this->activeTab);
if ($this->activeTab === 'logs' && $this->selectedDomain) {
$this->loadLogs();
}
}
public function setTab(string $tab): void
{
$this->activeTab = $this->normalizeTab($tab);
}
protected function normalizeTab(?string $tab): string
{
return match ($tab) {
'logs', 'usage', 'activity', 'stats' => (string) $tab,
default => 'logs',
};
}
protected function getAgent(): AgentClient protected function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
@@ -90,11 +117,15 @@ class Logs extends Page implements HasActions, HasForms
protected function loadDomains(): void protected function loadDomains(): void
{ {
$result = $this->getAgent()->send('domain.list', [ try {
'username' => $this->getUsername(), $result = $this->getAgent()->send('domain.list', [
]); 'username' => $this->getUsername(),
]);
$this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : []; $this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : [];
} catch (\Throwable $exception) {
$this->domains = [];
}
} }
public function getDomainOptions(): array public function getDomainOptions(): array
@@ -164,6 +195,66 @@ class Logs extends Page implements HasActions, HasForms
->send(); ->send();
} }
public function getUsageChartData(): array
{
$start = now()->subDays(29)->startOfDay();
$end = now()->endOfDay();
$records = UserResourceUsage::query()
->where('user_id', Auth::id())
->whereBetween('captured_at', [$start, $end])
->get();
$labels = [];
for ($i = 0; $i < 30; $i++) {
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
}
$index = array_flip($labels);
$metrics = ['disk_bytes', 'database_bytes', 'mail_bytes', 'bandwidth_bytes'];
$values = [];
foreach ($metrics as $metric) {
$values[$metric] = array_fill(0, count($labels), 0);
}
foreach ($records as $record) {
$date = $record->captured_at?->format('Y-m-d');
if (! $date || ! isset($index[$date])) {
continue;
}
$idx = $index[$date];
$metric = $record->metric;
if (! isset($values[$metric])) {
continue;
}
if ($metric === 'bandwidth_bytes') {
$values[$metric][$idx] += (int) $record->value;
} else {
$values[$metric][$idx] = max($values[$metric][$idx], (int) $record->value);
}
}
$toGb = fn (int $bytes) => round($bytes / 1024 / 1024 / 1024, 2);
return [
'labels' => $labels,
'series' => [
['name' => __('Disk'), 'data' => array_map($toGb, $values['disk_bytes'])],
['name' => __('Databases'), 'data' => array_map($toGb, $values['database_bytes'])],
['name' => __('Mail'), 'data' => array_map($toGb, $values['mail_bytes'])],
['name' => __('Bandwidth'), 'data' => array_map($toGb, $values['bandwidth_bytes'])],
],
];
}
public function getActivityLogs()
{
return AuditLog::query()
->where('user_id', Auth::id())
->latest()
->limit(50)
->get();
}
public function generateStats(): void public function generateStats(): void
{ {
if (! $this->selectedDomain) { if (! $this->selectedDomain) {
@@ -216,14 +307,14 @@ class Logs extends Page implements HasActions, HasForms
->label(__('Generate Statistics')) ->label(__('Generate Statistics'))
->icon('heroicon-o-chart-bar') ->icon('heroicon-o-chart-bar')
->color('primary') ->color('primary')
->visible(fn () => $this->selectedDomain !== null) ->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'stats')
->action(fn () => $this->generateStats()), ->action(fn () => $this->generateStats()),
Action::make('refreshLogs') Action::make('refreshLogs')
->label(__('Refresh')) ->label(__('Refresh'))
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('gray') ->color('gray')
->visible(fn () => $this->selectedDomain !== null) ->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'logs')
->action(fn () => $this->refreshLogs()), ->action(fn () => $this->refreshLogs()),
]; ];
} }

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; namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain; use App\Models\Domain;
use App\Models\MysqlCredential; use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions; use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn; use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Concerns\InteractsWithTable;
@@ -28,20 +31,18 @@ use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use App\Filament\Concerns\HasPageTour;
use BackedEnum;
use Exception;
class WordPress extends Page implements HasForms, HasActions, HasTable class WordPress extends Page implements HasActions, HasForms, HasTable
{ {
protected static ?string $slug = 'wordpress'; protected static ?string $slug = 'wordpress';
use InteractsWithForms;
use InteractsWithActions;
use InteractsWithTable;
use HasPageTour;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-pencil-square'; use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
protected static ?int $navigationSort = 4; protected static ?int $navigationSort = 4;
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
@@ -52,28 +53,37 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
protected string $view = 'filament.jabali.pages.wordpress'; protected string $view = 'filament.jabali.pages.wordpress';
public array $sites = []; public array $sites = [];
public array $domains = []; public array $domains = [];
public ?string $selectedSiteId = null; public ?string $selectedSiteId = null;
// Credentials modal // Credentials modal
public bool $showCredentials = false; public bool $showCredentials = false;
public array $credentials = []; public array $credentials = [];
// Scan modal // Scan modal
public bool $showScanModal = false; public bool $showScanModal = false;
public array $scannedSites = []; public array $scannedSites = [];
public bool $isScanning = false; public bool $isScanning = false;
// Security scan // Security scan
public bool $showSecurityScanModal = false; public bool $showSecurityScanModal = false;
public array $securityScanResults = []; public array $securityScanResults = [];
public bool $isSecurityScanning = false; public bool $isSecurityScanning = false;
public ?string $scanningSiteId = null; public ?string $scanningSiteId = null;
public ?string $scanningSiteUrl = null; public ?string $scanningSiteUrl = null;
protected ?AgentClient $agent = null; protected ?AgentClient $agent = null;
public function getTitle(): string | Htmlable public function getTitle(): string|Htmlable
{ {
return __('WordPress Manager'); return __('WordPress Manager');
} }
@@ -81,8 +91,9 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function getAgent(): AgentClient public function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
$this->agent = new AgentClient(); $this->agent = new AgentClient;
} }
return $this->agent; return $this->agent;
} }
@@ -199,12 +210,23 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
TextInput::make('staging_subdomain') TextInput::make('staging_subdomain')
->label(__('Staging Subdomain')) ->label(__('Staging Subdomain'))
->prefix('staging-') ->prefix('staging-')
->suffix(fn (array $record): string => '.' . ($record['domain'] ?? '')) ->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
->default('test') ->default('test')
->required() ->required()
->alphaNum(), ->alphaNum(),
]) ])
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])), ->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
Action::make('pushStaging')
->label(__('Push to Production'))
->icon('heroicon-o-arrow-up-tray')
->color('warning')
->requiresConfirmation()
->visible(fn (array $record): bool => (bool) ($record['is_staging'] ?? false))
->modalHeading(__('Push Staging to Production'))
->modalDescription(__('This will replace the live site files and database with the staging version.'))
->modalIcon('heroicon-o-arrow-up-tray')
->modalIconColor('warning')
->action(fn (array $record) => $this->pushStaging($record['id'])),
Action::make('security') Action::make('security')
->label(__('Security Scan')) ->label(__('Security Scan'))
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
@@ -239,7 +261,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
// Delete screenshot if exists // Delete screenshot if exists
$screenshotPath = storage_path('app/public/screenshots/wp-' . $record['id'] . '.png'); $screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
if (file_exists($screenshotPath)) { if (file_exists($screenshotPath)) {
@unlink($screenshotPath); @unlink($screenshotPath);
} }
@@ -285,12 +307,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
// Ensure at least one of each required type // Ensure at least one of each required type
$password = $lowercase[random_int(0, strlen($lowercase) - 1)] $password = $lowercase[random_int(0, strlen($lowercase) - 1)]
. $uppercase[random_int(0, strlen($uppercase) - 1)] .$uppercase[random_int(0, strlen($uppercase) - 1)]
. $numbers[random_int(0, strlen($numbers) - 1)] .$numbers[random_int(0, strlen($numbers) - 1)]
. $special[random_int(0, strlen($special) - 1)]; .$special[random_int(0, strlen($special) - 1)];
// Fill the rest with random characters from all types // Fill the rest with random characters from all types
$allChars = $lowercase . $uppercase . $numbers . $special; $allChars = $lowercase.$uppercase.$numbers.$special;
for ($i = strlen($password); $i < $length; $i++) { for ($i = strlen($password); $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)]; $password .= $allChars[random_int(0, strlen($allChars) - 1)];
} }
@@ -340,7 +362,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
$options = []; $options = [];
foreach ($this->scannedSites as $site) { foreach ($this->scannedSites as $site) {
$label = ($site['site_url'] ?? $site['path']) . ' (v' . ($site['version'] ?? '?') . ')'; $label = ($site['site_url'] ?? $site['path']).' (v'.($site['version'] ?? '?').')';
$options[$site['path']] = $label; $options[$site['path']] = $label;
} }
@@ -365,6 +387,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->body(__('Please select at least one site to import.')) ->body(__('Please select at least one site to import.'))
->warning() ->warning()
->send(); ->send();
return; return;
} }
@@ -575,7 +598,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Store MySQL credentials for phpMyAdmin SSO // Store MySQL credentials for phpMyAdmin SSO
if (!empty($result['db_user']) && !empty($result['db_password'])) { if (! empty($result['db_user']) && ! empty($result['db_password'])) {
MysqlCredential::updateOrCreate( MysqlCredential::updateOrCreate(
[ [
'user_id' => Auth::id(), 'user_id' => Auth::id(),
@@ -689,7 +712,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
try { try {
// Get site info to get domain // Get site info to get domain
$site = collect($this->sites)->firstWhere('id', $siteId); $site = collect($this->sites)->firstWhere('id', $siteId);
if (!$site) { if (! $site) {
throw new Exception(__('Site not found')); throw new Exception(__('Site not found'));
} }
$siteDomain = $site['domain'] ?? ''; $siteDomain = $site['domain'] ?? '';
@@ -741,7 +764,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->send(); ->send();
} else { } else {
// Check if there are conflicting plugins // Check if there are conflicting plugins
if (isset($result['conflicts']) && !empty($result['conflicts'])) { if (isset($result['conflicts']) && ! empty($result['conflicts'])) {
$conflictNames = array_column($result['conflicts'], 'name'); $conflictNames = array_column($result['conflicts'], 'name');
Notification::make() Notification::make()
->title(__('Conflicting Plugins Detected')) ->title(__('Conflicting Plugins Detected'))
@@ -875,7 +898,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
$result = $this->getAgent()->send('wp.create_staging', [ $result = $this->getAgent()->send('wp.create_staging', [
'username' => $this->getUsername(), 'username' => $this->getUsername(),
'site_id' => $siteId, 'site_id' => $siteId,
'subdomain' => 'staging-' . $subdomain, 'subdomain' => 'staging-'.$subdomain,
]); ]);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
@@ -899,6 +922,36 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
} }
public function pushStaging(string $stagingSiteId): void
{
try {
Notification::make()
->title(__('Pushing staging to production...'))
->body(__('This may take several minutes.'))
->info()
->send();
$result = $this->getAgent()->wpPushStaging($this->getUsername(), $stagingSiteId);
if ($result['success'] ?? false) {
Notification::make()
->title(__('Staging pushed to production'))
->success()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Failed to push staging site'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Push Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function flushCache(string $siteId): void public function flushCache(string $siteId): void
{ {
try { try {
@@ -953,11 +1006,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
{ {
// Find the site // Find the site
$site = collect($this->sites)->firstWhere('id', $siteId); $site = collect($this->sites)->firstWhere('id', $siteId);
if (!$site) { if (! $site) {
Notification::make() Notification::make()
->title(__('Site not found')) ->title(__('Site not found'))
->danger() ->danger()
->send(); ->send();
return; return;
} }
@@ -969,6 +1023,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->body(__('Please contact your administrator to enable security scanning.')) ->body(__('Please contact your administrator to enable security scanning.'))
->warning() ->warning()
->send(); ->send();
return; return;
} }
@@ -985,12 +1040,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
// Run WPScan // Run WPScan
$url = $site['url']; $url = $site['url'];
exec("wpscan --url " . escapeshellarg($url) . " --format json --no-banner 2>&1", $scanOutput, $scanCode); exec('wpscan --url '.escapeshellarg($url).' --format json --no-banner 2>&1', $scanOutput, $scanCode);
$jsonOutput = implode("\n", $scanOutput); $jsonOutput = implode("\n", $scanOutput);
$results = json_decode($jsonOutput, true); $results = json_decode($jsonOutput, true);
if (!$results) { if (! $results) {
$this->securityScanResults = [ $this->securityScanResults = [
'error' => __('Failed to parse scan results'), 'error' => __('Failed to parse scan results'),
'raw_output' => $jsonOutput, 'raw_output' => $jsonOutput,
@@ -1042,7 +1097,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
'vulnerabilities' => [], 'vulnerabilities' => [],
]; ];
if (!empty($results['version']['vulnerabilities'])) { if (! empty($results['version']['vulnerabilities'])) {
foreach ($results['version']['vulnerabilities'] as $vuln) { foreach ($results['version']['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [ $parsed['vulnerabilities'][] = [
'type' => __('WordPress Core'), 'type' => __('WordPress Core'),
@@ -1061,7 +1116,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
'version' => $results['main_theme']['version']['number'] ?? __('Unknown'), 'version' => $results['main_theme']['version']['number'] ?? __('Unknown'),
]; ];
if (!empty($results['main_theme']['vulnerabilities'])) { if (! empty($results['main_theme']['vulnerabilities'])) {
foreach ($results['main_theme']['vulnerabilities'] as $vuln) { foreach ($results['main_theme']['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [ $parsed['vulnerabilities'][] = [
'type' => __('Theme: :name', ['name' => $results['main_theme']['slug']]), 'type' => __('Theme: :name', ['name' => $results['main_theme']['slug']]),
@@ -1074,14 +1129,14 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Plugins // Plugins
if (!empty($results['plugins'])) { if (! empty($results['plugins'])) {
foreach ($results['plugins'] as $slug => $plugin) { foreach ($results['plugins'] as $slug => $plugin) {
$parsed['plugins'][] = [ $parsed['plugins'][] = [
'name' => $slug, 'name' => $slug,
'version' => $plugin['version']['number'] ?? __('Unknown'), 'version' => $plugin['version']['number'] ?? __('Unknown'),
]; ];
if (!empty($plugin['vulnerabilities'])) { if (! empty($plugin['vulnerabilities'])) {
foreach ($plugin['vulnerabilities'] as $vuln) { foreach ($plugin['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [ $parsed['vulnerabilities'][] = [
'type' => __('Plugin: :name', ['name' => $slug]), 'type' => __('Plugin: :name', ['name' => $slug]),
@@ -1095,7 +1150,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Interesting findings // Interesting findings
if (!empty($results['interesting_findings'])) { if (! empty($results['interesting_findings'])) {
foreach ($results['interesting_findings'] as $finding) { foreach ($results['interesting_findings'] as $finding) {
$parsed['interesting_findings'][] = [ $parsed['interesting_findings'][] = [
'type' => $finding['type'] ?? 'info', 'type' => $finding['type'] ?? 'info',
@@ -1201,11 +1256,10 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->infolist([ ->infolist([
Section::make(__('Discovered Sites')) Section::make(__('Discovered Sites'))
->schema( ->schema(
collect($this->scannedSites)->map(fn ($site, $index) => collect($this->scannedSites)->map(fn ($site, $index) => TextEntry::make("site_{$index}")
TextEntry::make("site_{$index}") ->label($site['site_url'] ?? __('WordPress Site'))
->label($site['site_url'] ?? __('WordPress Site')) ->state($site['path'])
->state($site['path']) ->helperText(isset($site['version']) ? 'v'.$site['version'] : '')
->helperText(isset($site['version']) ? 'v' . $site['version'] : '')
)->toArray() )->toArray()
), ),
]); ]);
@@ -1253,7 +1307,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->label(__('Version')) ->label(__('Version'))
->state($results['wordpress_version']['number']) ->state($results['wordpress_version']['number'])
->badge() ->badge()
->color(match($results['wordpress_version']['status'] ?? '') { ->color(match ($results['wordpress_version']['status'] ?? '') {
'insecure' => 'danger', 'insecure' => 'danger',
'outdated' => 'warning', 'outdated' => 'warning',
default => 'success', default => 'success',
@@ -1271,7 +1325,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->helperText($vuln['fixed_in'] ? __('Fixed in: :version', ['version' => $vuln['fixed_in']]) : '') ->helperText($vuln['fixed_in'] ? __('Fixed in: :version', ['version' => $vuln['fixed_in']]) : '')
->color('danger'); ->color('danger');
} }
$schema[] = Section::make(__('Vulnerabilities Found') . " ({$vulnCount})") $schema[] = Section::make(__('Vulnerabilities Found')." ({$vulnCount})")
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->iconColor('danger') ->iconColor('danger')
->schema($vulnEntries); ->schema($vulnEntries);
@@ -1283,7 +1337,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Interesting findings // Interesting findings
if (!empty($results['interesting_findings'])) { if (! empty($results['interesting_findings'])) {
$findingEntries = []; $findingEntries = [];
foreach (array_slice($results['interesting_findings'], 0, 10) as $index => $finding) { foreach (array_slice($results['interesting_findings'], 0, 10) as $index => $finding) {
$findingEntries[] = TextEntry::make("finding_{$index}") $findingEntries[] = TextEntry::make("finding_{$index}")
@@ -1297,16 +1351,16 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Detected plugins // Detected plugins
if (!empty($results['plugins'])) { if (! empty($results['plugins'])) {
$pluginEntries = []; $pluginEntries = [];
foreach ($results['plugins'] as $index => $plugin) { foreach ($results['plugins'] as $index => $plugin) {
$pluginEntries[] = TextEntry::make("plugin_{$index}") $pluginEntries[] = TextEntry::make("plugin_{$index}")
->hiddenLabel() ->hiddenLabel()
->state($plugin['name'] . ' v' . $plugin['version']) ->state($plugin['name'].' v'.$plugin['version'])
->badge() ->badge()
->color('gray'); ->color('gray');
} }
$schema[] = Section::make(__('Detected Plugins') . ' (' . count($results['plugins']) . ')') $schema[] = Section::make(__('Detected Plugins').' ('.count($results['plugins']).')')
->icon('heroicon-o-puzzle-piece') ->icon('heroicon-o-puzzle-piece')
->collapsed() ->collapsed()
->schema($pluginEntries); ->schema($pluginEntries);
@@ -1319,11 +1373,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function captureScreenshot(string $siteId): void public function captureScreenshot(string $siteId): void
{ {
$site = collect($this->sites)->firstWhere('id', $siteId); $site = collect($this->sites)->firstWhere('id', $siteId);
if (!$site) { if (! $site) {
Notification::make() Notification::make()
->title(__('Site not found')) ->title(__('Site not found'))
->danger() ->danger()
->send(); ->send();
return; return;
} }
@@ -1332,22 +1387,23 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
try { try {
// Ensure screenshots directory exists // Ensure screenshots directory exists
$screenshotDir = storage_path('app/public/screenshots'); $screenshotDir = storage_path('app/public/screenshots');
if (!is_dir($screenshotDir)) { if (! is_dir($screenshotDir)) {
mkdir($screenshotDir, 0755, true); mkdir($screenshotDir, 0755, true);
} }
$filename = 'wp_' . $siteId . '.png'; $filename = 'wp_'.$siteId.'.png';
$filepath = $screenshotDir . '/' . $filename; $filepath = $screenshotDir.'/'.$filename;
// Use screenshot wrapper script that handles Chromium crashpad issues // Use screenshot wrapper script that handles Chromium crashpad issues
$screenshotBin = base_path('bin/screenshot'); $screenshotBin = base_path('bin/screenshot');
if (!file_exists($screenshotBin) || !is_executable($screenshotBin)) { if (! file_exists($screenshotBin) || ! is_executable($screenshotBin)) {
Notification::make() Notification::make()
->title(__('Screenshot failed')) ->title(__('Screenshot failed'))
->body(__('Screenshot script not found.')) ->body(__('Screenshot script not found.'))
->warning() ->warning()
->send(); ->send();
return; return;
} }
@@ -1370,7 +1426,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} else { } else {
Notification::make() Notification::make()
->title(__('Screenshot failed')) ->title(__('Screenshot failed'))
->body(__('Could not capture screenshot. Error: ') . implode("\n", $output)) ->body(__('Could not capture screenshot. Error: ').implode("\n", $output))
->warning() ->warning()
->send(); ->send();
} }
@@ -1385,12 +1441,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function getScreenshotUrl(string $siteId): ?string public function getScreenshotUrl(string $siteId): ?string
{ {
$filename = 'wp_' . $siteId . '.png'; $filename = 'wp_'.$siteId.'.png';
$filepath = storage_path('app/public/screenshots/' . $filename); $filepath = storage_path('app/public/screenshots/'.$filename);
if (file_exists($filepath)) { if (file_exists($filepath)) {
// Add timestamp to bust cache // Add timestamp to bust cache
return asset('storage/screenshots/' . $filename) . '?t=' . filemtime($filepath); return asset('storage/screenshots/'.$filename).'?t='.filemtime($filepath);
} }
return null; return null;
@@ -1398,7 +1454,8 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function hasScreenshot(string $siteId): bool public function hasScreenshot(string $siteId): bool
{ {
$filename = 'wp_' . $siteId . '.png'; $filename = 'wp_'.$siteId.'.png';
return file_exists(storage_path('app/public/screenshots/' . $filename));
return file_exists(storage_path('app/public/screenshots/'.$filename));
} }
} }

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

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

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]); return $this->send('file.list_trash', ['username' => $username]);
} }
// Git deployment
public function gitGenerateKey(string $username): array
{
return $this->send('git.generate_key', ['username' => $username]);
}
public function gitDeploy(string $username, string $repoUrl, string $branch, string $deployPath, ?string $deployScript = null): array
{
return $this->send('git.deploy', [
'username' => $username,
'repo_url' => $repoUrl,
'branch' => $branch,
'deploy_path' => $deployPath,
'deploy_script' => $deployScript,
]);
}
// Spam settings (Rspamd)
public function rspamdUserSettings(string $username, array $whitelist = [], array $blacklist = [], ?float $score = null): array
{
return $this->send('rspamd.user_settings', [
'username' => $username,
'whitelist' => $whitelist,
'blacklist' => $blacklist,
'score' => $score,
]);
}
// Image optimization
public function imageOptimize(string $username, string $path, bool $convertWebp = false, int $quality = 82): array
{
return $this->send('image.optimize', [
'username' => $username,
'path' => $path,
'convert_webp' => $convertWebp,
'quality' => $quality,
]);
}
// MySQL operations // MySQL operations
public function mysqlListDatabases(string $username): array public function mysqlListDatabases(string $username): array
{ {
@@ -293,12 +332,101 @@ class AgentClient
return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]); return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]);
} }
// PostgreSQL operations
public function postgresListDatabases(string $username): array
{
return $this->send('postgres.list_databases', ['username' => $username]);
}
public function postgresListUsers(string $username): array
{
return $this->send('postgres.list_users', ['username' => $username]);
}
public function postgresCreateDatabase(string $username, string $database, string $owner): array
{
return $this->send('postgres.create_database', [
'username' => $username,
'database' => $database,
'owner' => $owner,
]);
}
public function postgresDeleteDatabase(string $username, string $database): array
{
return $this->send('postgres.delete_database', [
'username' => $username,
'database' => $database,
]);
}
public function postgresCreateUser(string $username, string $dbUser, string $password): array
{
return $this->send('postgres.create_user', [
'username' => $username,
'db_user' => $dbUser,
'password' => $password,
]);
}
public function postgresDeleteUser(string $username, string $dbUser): array
{
return $this->send('postgres.delete_user', [
'username' => $username,
'db_user' => $dbUser,
]);
}
public function postgresChangePassword(string $username, string $dbUser, string $password): array
{
return $this->send('postgres.change_password', [
'username' => $username,
'db_user' => $dbUser,
'password' => $password,
]);
}
public function postgresGrantPrivileges(string $username, string $database, string $dbUser): array
{
return $this->send('postgres.grant_privileges', [
'username' => $username,
'database' => $database,
'db_user' => $dbUser,
]);
}
// Domain operations // Domain operations
public function domainCreate(string $username, string $domain): array public function domainCreate(string $username, string $domain): array
{ {
return $this->send('domain.create', ['username' => $username, 'domain' => $domain]); return $this->send('domain.create', ['username' => $username, 'domain' => $domain]);
} }
public function domainAliasAdd(string $username, string $domain, string $alias): array
{
return $this->send('domain.alias_add', [
'username' => $username,
'domain' => $domain,
'alias' => $alias,
]);
}
public function domainAliasRemove(string $username, string $domain, string $alias): array
{
return $this->send('domain.alias_remove', [
'username' => $username,
'domain' => $domain,
'alias' => $alias,
]);
}
public function domainEnsureErrorPages(string $username, string $domain): array
{
return $this->send('domain.ensure_error_pages', [
'username' => $username,
'domain' => $domain,
]);
}
public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array
{ {
return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]); return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]);
@@ -360,6 +488,23 @@ class AgentClient
return $this->send('wp.import', $params); return $this->send('wp.import', $params);
} }
public function wpCreateStaging(string $username, string $siteId, string $subdomain): array
{
return $this->send('wp.create_staging', [
'username' => $username,
'site_id' => $siteId,
'subdomain' => $subdomain,
]);
}
public function wpPushStaging(string $username, string $stagingSiteId): array
{
return $this->send('wp.push_staging', [
'username' => $username,
'staging_site_id' => $stagingSiteId,
]);
}
// WordPress Cache Methods // WordPress Cache Methods
public function wpCacheEnable(string $username, string $siteId): array public function wpCacheEnable(string $username, string $siteId): array
{ {
@@ -554,6 +699,15 @@ class AgentClient
return $this->send('email.sync_virtual_users', ['domain' => $domain]); return $this->send('email.sync_virtual_users', ['domain' => $domain]);
} }
public function emailSyncMaps(array $domains, array $mailboxes, array $aliases): array
{
return $this->send('email.sync_maps', [
'domains' => $domains,
'mailboxes' => $mailboxes,
'aliases' => $aliases,
]);
}
public function emailReloadServices(): array public function emailReloadServices(): array
{ {
return $this->send('email.reload_services'); return $this->send('email.reload_services');
@@ -1151,4 +1305,77 @@ class AgentClient
{ {
return $this->send('scanner.get_scan_status', ['scanner' => $scanner]); return $this->send('scanner.get_scan_status', ['scanner' => $scanner]);
} }
// Mail queue operations
public function mailQueueList(): array
{
return $this->send('mail.queue_list');
}
public function mailQueueRetry(string $id): array
{
return $this->send('mail.queue_retry', ['id' => $id]);
}
public function mailQueueDelete(string $id): array
{
return $this->send('mail.queue_delete', ['id' => $id]);
}
// Server updates
public function updatesList(): array
{
return $this->send('updates.list');
}
public function updatesRun(): array
{
return $this->send('updates.run');
}
// WAF / Geo / Resource limits
public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog): array
{
return $this->send('waf.apply', [
'enabled' => $enabled,
'paranoia' => $paranoia,
'audit_log' => $auditLog,
]);
}
public function geoApplyRules(array $rules): array
{
return $this->send('geo.apply_rules', [
'rules' => $rules,
]);
}
public function cgroupApplyUserLimits(string $username, ?int $cpuLimit, ?int $memoryLimit, ?int $ioLimit, bool $isActive = true): array
{
if (! $isActive) {
return $this->cgroupClearUserLimits($username);
}
return $this->send('cgroup.apply_user_limits', [
'username' => $username,
'cpu_limit_percent' => $cpuLimit,
'memory_limit_mb' => $memoryLimit,
'io_limit_mb' => $ioLimit,
]);
}
public function cgroupClearUserLimits(string $username): array
{
return $this->send('cgroup.clear_user_limits', [
'username' => $username,
]);
}
public function databasePersistTuning(string $name, string $value): array
{
return $this->send('database.persist_tuning', [
'name' => $name,
'value' => $value,
]);
}
} }

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 [ return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
],
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| Class Namespace | Class Namespace
@@ -15,6 +86,19 @@ return [
'class_namespace' => 'App\\Livewire', 'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| View Path | View Path
@@ -28,30 +112,6 @@ return [
'view_path' => resource_path('views/livewire'), 'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
| In this case, the view returned by CreatePost will render into $slot.
|
*/
'layout' => 'components.layouts.app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'lazy_placeholder' => null,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| Temporary File Uploads | Temporary File Uploads
@@ -64,11 +124,11 @@ return [
*/ */
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default' 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file', 'max:524288'], // 512MB max 'rules' => ['required', 'file', 'max:524288'], // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a', 'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
@@ -156,7 +216,7 @@ return [
| |
*/ */
'smart_wire_keys' => false, 'smart_wire_keys' => true,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
@@ -183,4 +243,35 @@ return [
*/ */
'release_token' => 'a', 'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 20, // Maximum components per batch request
],
]; ];

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

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 }} {{ $this->emailForm }}
<div class="-mt-4"> @if($activeTab === 'spam')
{{ $this->table }} <div class="mt-4">
</div> {{ $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-actions::modals />
</x-filament-panels::page> </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,138 +1,275 @@
<x-filament-panels::page> <x-filament-panels::page>
@if(count($this->getDomainOptions()) > 0) @php
{{-- Domain Selector --}} $tabs = [
<x-filament::section 'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
icon="heroicon-o-globe-alt" 'stats' => ['label' => __('Statistics'), 'icon' => 'heroicon-o-chart-bar'],
icon-color="primary" 'usage' => ['label' => __('Resource Usage'), 'icon' => 'heroicon-o-chart-pie'],
> 'activity' => ['label' => __('Activity Log'), 'icon' => 'heroicon-o-clipboard-document-list'],
<x-slot name="heading"> ];
{{ __('Select Domain') }} @endphp
</x-slot>
<x-slot name="description">
{{ __('Choose the domain you want to view logs for.') }}
</x-slot>
<div class="max-w-md"> <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">
<x-filament::input.wrapper> @foreach($tabs as $key => $tab)
<x-filament::input.select wire:model.live="selectedDomain"> <button
@foreach($this->getDomainOptions() as $value => $label) type="button"
<option value="{{ $value }}">{{ $label }}</option> role="tab"
@endforeach aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}"
</x-filament::input.select> wire:click="setTab('{{ $key }}')"
</x-filament::input.wrapper> @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>
<x-slot name="description">
{{ __('Choose the domain you want to view logs for.') }}
</x-slot>
<div class="max-w-md">
<x-filament::input.wrapper>
<x-filament::input.select wire:model.live="selectedDomain">
@foreach($this->getDomainOptions() as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
</div>
</x-filament::section>
@if($selectedDomain)
@if($activeTab === 'stats')
@if($statsGenerated)
<x-filament::section icon="heroicon-o-check-circle" icon-color="success" class="mt-4">
<x-slot name="heading">
{{ __('Statistics Report Ready') }}
</x-slot>
<x-slot name="description">
{{ __('Traffic analysis report has been generated for :domain', ['domain' => $selectedDomain]) }}
</x-slot>
<x-filament::button
:href="$statsUrl"
tag="a"
target="_blank"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('View Report') }}
</x-filament::button>
</x-filament::section>
@endif
@endif
@if($activeTab === 'logs')
<x-filament::section icon="heroicon-o-document-text" class="mt-4">
<x-slot name="heading">
{{ __('Log Viewer') }}
</x-slot>
<x-slot name="description">
{{ __('Viewing :type for :domain', ['type' => $logType === 'access' ? __('access log') : __('error log'), 'domain' => $selectedDomain]) }}
@if(!empty($logInfo))
({{ $logInfo['file_size'] ?? '' }}, {{ __(':lines lines', ['lines' => $logInfo['lines'] ?? 100]) }})
@endif
</x-slot>
<div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4">
<x-filament::button
wire:click="setLogType('access')"
:color="$logType === 'access' ? 'primary' : 'gray'"
:outlined="$logType !== 'access'"
size="sm"
icon="heroicon-o-arrow-right-circle"
>
{{ __('Access Log') }}
</x-filament::button>
<x-filament::button
wire:click="setLogType('error')"
:color="$logType === 'error' ? 'danger' : 'gray'"
:outlined="$logType !== 'error'"
size="sm"
icon="heroicon-o-exclamation-triangle"
>
{{ __('Error Log') }}
</x-filament::button>
</div>
@if($logContent)
<div class="fi-input-wrp rounded-lg shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 overflow-hidden">
<textarea
readonly
rows="25"
class="w-full border-0 bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 resize-none focus:ring-0"
style="font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;"
>{{ $logContent }}</textarea>
</div>
@else
<x-filament::section compact>
<x-slot name="heading">
{{ __('No Log Entries') }}
</x-slot>
<x-slot name="description">
{{ $logType === 'access'
? __('No access log entries found. Logs will appear after visitors access your site.')
: __('No error log entries found. This is good - your site has no errors.') }}
</x-slot>
</x-filament::section>
@endif
</x-filament::section>
@endif
@endif
@else
<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" />
</div>
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
{{ __('No Domains Yet') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __('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">
{{ __('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> </div>
</x-filament::section> </x-filament::section>
@if($selectedDomain) @endif
{{-- Statistics Banner (shown when generated) --}}
@if($statsGenerated)
<x-filament::section
icon="heroicon-o-check-circle"
icon-color="success"
>
<x-slot name="heading">
{{ __('Statistics Report Ready') }}
</x-slot>
<x-slot name="description">
{{ __('Traffic analysis report has been generated for :domain', ['domain' => $selectedDomain]) }}
</x-slot>
<x-filament::button @if($activeTab === 'activity')
:href="$statsUrl" <x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
tag="a" <x-slot name="heading">{{ __('Activity Log') }}</x-slot>
target="_blank" <x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('View Report') }}
</x-filament::button>
</x-filament::section>
@endif
{{-- Log Viewer --}} <div class="overflow-x-auto">
<x-filament::section <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
icon="heroicon-o-document-text" <thead class="bg-gray-50 dark:bg-gray-800">
> <tr>
<x-slot name="heading"> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Time') }}</th>
{{ __('Log Viewer') }} <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Category') }}</th>
</x-slot> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Action') }}</th>
<x-slot name="description"> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Description') }}</th>
{{ __('Viewing :type for :domain', ['type' => $logType === 'access' ? __('access log') : __('error log'), 'domain' => $selectedDomain]) }} <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('IP') }}</th>
@if(!empty($logInfo)) </tr>
({{ $logInfo['file_size'] ?? '' }}, {{ __(':lines lines', ['lines' => $logInfo['lines'] ?? 100]) }}) </thead>
@endif <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
</x-slot> @forelse($this->getActivityLogs() as $log)
<tr>
{{-- Log Type Buttons --}} <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>
<div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4"> <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->category }}</td>
<x-filament::button <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->action }}</td>
wire:click="setLogType('access')" <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->description }}</td>
:color="$logType === 'access' ? 'primary' : 'gray'" <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->ip_address }}</td>
:outlined="$logType !== 'access'" </tr>
size="sm" @empty
icon="heroicon-o-arrow-right-circle" <tr>
> <td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">
{{ __('Access Log') }} {{ __('No activity recorded yet.') }}
</x-filament::button> </td>
</tr>
<x-filament::button @endforelse
wire:click="setLogType('error')" </tbody>
:color="$logType === 'error' ? 'danger' : 'gray'" </table>
:outlined="$logType !== 'error'"
size="sm"
icon="heroicon-o-exclamation-triangle"
>
{{ __('Error Log') }}
</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
readonly
rows="25"
class="w-full border-0 bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 resize-none focus:ring-0"
style="font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;"
>{{ $logContent }}</textarea>
</div>
@else
<x-filament::section compact>
<x-slot name="heading">
{{ __('No Log Entries') }}
</x-slot>
<x-slot name="description">
{{ $logType === 'access'
? __('No access log entries found. Logs will appear after visitors access your site.')
: __('No error log entries found. This is good - your site has no errors.') }}
</x-slot>
</x-filament::section>
@endif
</x-filament::section>
@endif
@else
{{-- No Domains Empty State --}}
<x-filament::section>
<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"
/>
</div>
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
{{ __('No Domains Yet') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __('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"
>
{{ __('Add Domain') }}
</x-filament::button>
</div>
</div> </div>
</x-filament::section> </x-filament::section>
@endif @endif

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 <?php
use Illuminate\Http\Request; use App\Http\Controllers\AutomationApiController;
use Illuminate\Support\Facades\Route; use App\Http\Controllers\GitWebhookController;
use Illuminate\Support\Facades\Cache;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) { Route::get('/user', function (Request $request) {
return $request->user(); return $request->user();
@@ -13,14 +15,14 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
$token = $request->input('token'); $token = $request->input('token');
// Use Cache::get() which handles the prefix automatically // Use Cache::get() which handles the prefix automatically
$data = Cache::get('phpmyadmin_token_' . $token); $data = Cache::get('phpmyadmin_token_'.$token);
if (!$data) { if (! $data) {
return response()->json(['error' => 'Invalid token'], 401); return response()->json(['error' => 'Invalid token'], 401);
} }
// Delete token after use (single use) // Delete token after use (single use)
Cache::forget('phpmyadmin_token_' . $token); Cache::forget('phpmyadmin_token_'.$token);
return response()->json($data); return response()->json($data);
}); });
@@ -29,7 +31,7 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
Route::post('/internal/page-cache', function (Request $request) { Route::post('/internal/page-cache', function (Request $request) {
// Only allow requests from localhost // Only allow requests from localhost
$clientIp = $request->ip(); $clientIp = $request->ip();
if (!in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) { if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
return response()->json(['error' => 'Forbidden'], 403); return response()->json(['error' => 'Forbidden'], 403);
} }
@@ -52,13 +54,13 @@ Route::post('/internal/page-cache', function (Request $request) {
$query->where('domain', $domain); $query->where('domain', $domain);
})->first(); })->first();
if (!$user) { if (! $user) {
return response()->json(['error' => 'Domain not found'], 404); return response()->json(['error' => 'Domain not found'], 404);
} }
// Verify the secret by checking wp-config.php // Verify the secret by checking wp-config.php
$wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php"; $wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php";
if (!file_exists($wpConfigPath)) { if (! file_exists($wpConfigPath)) {
return response()->json(['error' => 'WordPress not found'], 404); return response()->json(['error' => 'WordPress not found'], 404);
} }
@@ -74,7 +76,7 @@ Route::post('/internal/page-cache', function (Request $request) {
} }
try { try {
$agent = new AgentClient(); $agent = new AgentClient;
if ($enabled) { if ($enabled) {
$result = $agent->send('wp.page_cache_enable', [ $result = $agent->send('wp.page_cache_enable', [
@@ -98,7 +100,7 @@ Route::post('/internal/page-cache', function (Request $request) {
Route::post('/internal/page-cache-purge', function (Request $request) { Route::post('/internal/page-cache-purge', function (Request $request) {
// Only allow requests from localhost // Only allow requests from localhost
$clientIp = $request->ip(); $clientIp = $request->ip();
if (!in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) { if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
return response()->json(['error' => 'Forbidden'], 403); return response()->json(['error' => 'Forbidden'], 403);
} }
@@ -121,13 +123,13 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
$query->where('domain', $domain); $query->where('domain', $domain);
})->first(); })->first();
if (!$user) { if (! $user) {
return response()->json(['error' => 'Domain not found'], 404); return response()->json(['error' => 'Domain not found'], 404);
} }
// Verify the secret by checking wp-config.php // Verify the secret by checking wp-config.php
$wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php"; $wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php";
if (!file_exists($wpConfigPath)) { if (! file_exists($wpConfigPath)) {
return response()->json(['error' => 'WordPress not found'], 404); return response()->json(['error' => 'WordPress not found'], 404);
} }
@@ -143,7 +145,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
} }
try { try {
$agent = new AgentClient(); $agent = new AgentClient;
if ($purgeAll || empty($paths)) { if ($purgeAll || empty($paths)) {
// Purge entire domain cache // Purge entire domain cache
@@ -163,3 +165,13 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
return response()->json(['error' => $e->getMessage()], 500); return response()->json(['error' => $e->getMessage()], 500);
} }
}); });
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class);
Route::middleware(['auth:sanctum', 'abilities:automation'])
->prefix('automation')
->group(function () {
Route::get('/users', [AutomationApiController::class, 'listUsers']);
Route::post('/users', [AutomationApiController::class, 'createUser']);
Route::post('/domains', [AutomationApiController::class, 'createDomain']);
});

View File

@@ -66,6 +66,13 @@ Schedule::command('jabali:sync-mailbox-quotas')
->runInBackground() ->runInBackground()
->appendOutputTo(storage_path('logs/mailbox-quota-sync.log')); ->appendOutputTo(storage_path('logs/mailbox-quota-sync.log'));
// User Resource Usage - runs hourly to capture per-user usage history
Schedule::command('jabali:collect-user-usage')
->hourly()
->withoutOverlapping()
->runInBackground()
->appendOutputTo(storage_path('logs/user-usage.log'));
// Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention) // Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention)
Schedule::call(function () { Schedule::call(function () {
$deleted = AuditLog::prune(); $deleted = AuditLog::prune();

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