Remove resource usage collection and tours

This commit is contained in:
root
2026-01-28 04:19:30 +02:00
parent 74180e8696
commit 0aec48acaf
66 changed files with 178 additions and 2718 deletions

View File

@@ -1 +1 @@
VERSION=0.9-rc9
VERSION=0.9-rc10

View File

@@ -1,173 +0,0 @@
<?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);
$cpuUsageTotal = (int) ($resourceStats['cpu_usage_usec_total'] ?? 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);
$lastCpuTotal = (int) UserSetting::getForUser($user->id, 'cpu_usage_usec_total', 0);
$lastCpuAt = (int) UserSetting::getForUser($user->id, 'cpu_usage_captured_at', 0);
$bandwidthDelta = $bandwidthTotal >= $lastBandwidthTotal
? $bandwidthTotal - $lastBandwidthTotal
: $bandwidthTotal;
$diskIoDelta = $diskIoTotal >= $lastDiskIoTotal
? $diskIoTotal - $lastDiskIoTotal
: $diskIoTotal;
if ($cpuUsageTotal > 0 && $lastCpuAt > 0) {
$elapsed = $capturedAt->timestamp - $lastCpuAt;
if ($elapsed > 0) {
$delta = $cpuUsageTotal >= $lastCpuTotal ? $cpuUsageTotal - $lastCpuTotal : $cpuUsageTotal;
$cpuPercent = (int) round(($delta / ($elapsed * 1_000_000)) * 100);
}
}
$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);
UserSetting::setForUser($user->id, 'cpu_usage_usec_total', $cpuUsageTotal);
UserSetting::setForUser($user->id, 'cpu_usage_captured_at', $capturedAt->timestamp);
$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, cpu_usage_usec_total: int, 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),
'cpu_usage_usec_total' => (int) ($result['cpu_usage_usec_total'] ?? 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,
'cpu_usage_usec_total' => 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

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Agent\AgentClient;
use Exception;
use Illuminate\Console\Command;
class JabaliSyncCgroups extends Command
{
public function __construct(private AgentClient $agent)
{
parent::__construct();
}
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'jabali:sync-cgroups {--user=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sync user processes into Jabali cgroups';
/**
* Execute the console command.
*/
public function handle(): int
{
$user = (string) ($this->option('user') ?? '');
try {
$result = $user !== ''
? $this->agent->cgroupSyncUserProcesses($user)
: $this->agent->cgroupSyncAllProcesses();
} catch (Exception $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
if (! ($result['success'] ?? false)) {
$this->error($result['error'] ?? 'Unknown error');
return self::FAILURE;
}
$moved = (int) ($result['moved'] ?? 0);
$this->info("Synced cgroups, moved {$moved} process(es).");
return self::SUCCESS;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Backup;
use App\Models\BackupDestination;
use App\Models\BackupSchedule;
@@ -40,7 +39,6 @@ use Livewire\Attributes\Url;
class Backups extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -508,7 +506,6 @@ class Backups extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('createServerBackup')
->label(__('Create Server Backup'))
->icon('heroicon-o-archive-box-arrow-down')

View File

@@ -19,7 +19,6 @@ use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Livewire\Attributes\On;
class Dashboard extends Page implements HasActions, HasForms
{
@@ -70,13 +69,6 @@ class Dashboard extends Page implements HasActions, HasForms
]);
}
#[On('tour-completed')]
public function completeTour(): void
{
DnsSetting::set('tour_completed', '1');
DnsSetting::clearCache();
}
protected function getHeaderActions(): array
{
return [
@@ -107,16 +99,6 @@ class Dashboard extends Page implements HasActions, HasForms
}
DnsSetting::set('onboarding_completed', '1');
DnsSetting::clearCache();
$this->dispatch('start-admin-tour');
}),
Action::make('takeTour')
->label(__('Take Tour'))
->icon('heroicon-o-academic-cap')
->color('gray')
->action(function (): void {
$this->dispatch('start-admin-tour');
}),
];
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\DnsPendingAddsTable;
use App\Filament\Concerns\HasPageTour;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
use App\Models\Domain;
@@ -39,7 +38,6 @@ use Livewire\Attributes\On;
class DnsZones extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -585,7 +583,6 @@ class DnsZones extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('syncAllZones')
->label(__('Sync All Zones'))
->icon('heroicon-o-arrow-path')

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
@@ -26,7 +25,6 @@ use Illuminate\Contracts\Support\Htmlable;
class PhpManager extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -288,7 +286,6 @@ class PhpManager extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
];
}
}

View File

@@ -1,257 +0,0 @@
<?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

@@ -12,7 +12,6 @@ use App\Filament\Admin\Widgets\Security\NiktoResultsTable;
use App\Filament\Admin\Widgets\Security\QuarantinedFilesTable;
use App\Filament\Admin\Widgets\Security\ThreatsTable;
use App\Filament\Admin\Widgets\Security\WpscanResultsTable;
use App\Filament\Concerns\HasPageTour;
use App\Models\AuditLog;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -48,7 +47,6 @@ use Livewire\Attributes\Url;
class Security extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -989,7 +987,6 @@ class Security extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
];
}

View File

@@ -6,11 +6,8 @@ namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\Settings\DnssecTable;
use App\Filament\Admin\Widgets\Settings\NotificationLogTable;
use App\Filament\Concerns\HasPageTour;
use App\Models\DnsSetting;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\ResourceLimitService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
@@ -41,7 +38,6 @@ use Livewire\WithFileUploads;
class ServerSettings extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use WithFileUploads;
@@ -199,7 +195,6 @@ class ServerSettings extends Page implements HasActions, HasForms
$this->quotaData = [
'quotas_enabled' => (bool) ($settings['quotas_enabled'] ?? false),
'default_quota_mb' => (int) ($settings['default_quota_mb'] ?? 5120),
'resource_limits_enabled' => (bool) ($settings['resource_limits_enabled'] ?? true),
];
$this->fileManagerData = [
@@ -422,10 +417,6 @@ class ServerSettings extends Page implements HasActions, HasForms
->numeric()
->placeholder('5120')
->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')),
Toggle::make('quotaData.resource_limits_enabled')
->label(__('Enable CPU/Memory/IO Limits'))
->helperText(__('Apply cgroup limits from hosting packages (CloudLinux-style)'))
->columnSpanFull(),
]),
Actions::make([
FormAction::make('saveQuotaSettings')
@@ -841,11 +832,9 @@ class ServerSettings extends Page implements HasActions, HasForms
{
$data = $this->quotaData;
$wasEnabled = (bool) DnsSetting::get('quotas_enabled', false);
$wasLimitsEnabled = (bool) DnsSetting::get('resource_limits_enabled', true);
DnsSetting::set('quotas_enabled', $data['quotas_enabled'] ? '1' : '0');
DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']);
DnsSetting::set('resource_limits_enabled', ! empty($data['resource_limits_enabled']) ? '1' : '0');
DnsSetting::clearCache();
if ($data['quotas_enabled'] && ! $wasEnabled) {
@@ -861,21 +850,6 @@ class ServerSettings extends Page implements HasActions, HasForms
}
}
if (! empty($data['resource_limits_enabled']) && ! $wasLimitsEnabled) {
$limits = UserResourceLimit::query()->where('is_active', true)->get();
foreach ($limits as $limit) {
app(ResourceLimitService::class)->apply($limit);
}
}
if (empty($data['resource_limits_enabled']) && $wasLimitsEnabled) {
try {
$this->getAgent()->send('cgroup.clear_all_limits', []);
} catch (Exception $e) {
Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not clear cgroup limits.'))->warning()->send();
}
}
Notification::make()->title(__('Quota settings saved'))->success()->send();
}
@@ -1158,7 +1132,6 @@ class ServerSettings extends Page implements HasActions, HasForms
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('export_config')
->label(__('Export'))
->icon('heroicon-o-arrow-down-tray')

View File

@@ -6,7 +6,6 @@ namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\ServerChartsWidget;
use App\Filament\Admin\Widgets\ServerInfoWidget;
use App\Filament\Concerns\HasPageTour;
use App\Models\ServerProcess;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -28,7 +27,6 @@ use Illuminate\Database\Eloquent\Collection;
class ServerStatus extends Page implements HasTable
{
use HasPageTour;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
@@ -76,7 +74,6 @@ class ServerStatus extends Page implements HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
ActionGroup::make([
Action::make('limit25')
->label(__('Show 25 processes'))

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\AuditLog;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -25,7 +24,6 @@ use Illuminate\Database\Eloquent\Model;
class Services extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -317,7 +315,6 @@ class Services extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
];
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\SslStatsOverview;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Models\User;
@@ -26,7 +25,6 @@ use Illuminate\Support\Facades\Artisan;
class SslManager extends Page implements HasTable
{
use HasPageTour;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
@@ -531,7 +529,6 @@ class SslManager extends Page implements HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('runAutoSsl')
->label(__('Run SSL Check'))
->icon('heroicon-o-play')

View File

@@ -61,27 +61,6 @@ class HostingPackageForm
])
->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

@@ -44,21 +44,6 @@ class HostingPackagesTable
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(),

View File

@@ -6,10 +6,8 @@ namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
@@ -59,9 +57,6 @@ class CreateUser extends CreateRecord
// Apply disk quota if enabled
$this->applyDiskQuota();
// Apply resource limits from package
$this->syncResourceLimitsFromPackage($this->selectedPackage, true);
} catch (Exception $e) {
Notification::make()
->title(__('Linux user creation failed'))
@@ -69,15 +64,12 @@ class CreateUser extends CreateRecord
->danger()
->send();
}
} else {
// Store resource limits even if the Linux user was not created yet
$this->syncResourceLimitsFromPackage($this->selectedPackage, false);
}
if (! $this->record->hosting_package_id) {
Notification::make()
->title(__('No hosting package selected'))
->body(__('This user has unlimited resource limits.'))
->body(__('This user has unlimited quotas.'))
->warning()
->send();
}
@@ -113,50 +105,4 @@ class CreateUser extends CreateRecord
->send();
}
}
protected function syncResourceLimitsFromPackage(?HostingPackage $package, bool $apply): void
{
if (! $this->record) {
return;
}
$cpu = $package?->cpu_limit_percent;
$memory = $package?->memory_limit_mb;
$io = $package?->io_limit_mb;
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
if (! $package || ! $hasLimits) {
if ($limit) {
$limit->fill([
'cpu_limit_percent' => null,
'memory_limit_mb' => null,
'io_limit_mb' => null,
'is_active' => false,
])->save();
if ($apply) {
app(ResourceLimitService::class)->clear($limit);
}
}
return;
}
if (! $limit) {
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
}
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
if ($apply) {
app(ResourceLimitService::class)->apply($limit);
}
}
}

View File

@@ -6,10 +6,8 @@ namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
@@ -76,45 +74,6 @@ class EditUser extends EditRecord
}
}
$this->syncResourceLimitsFromPackage($this->selectedPackage);
}
protected function syncResourceLimitsFromPackage(?HostingPackage $package): void
{
$cpu = $package?->cpu_limit_percent;
$memory = $package?->memory_limit_mb;
$io = $package?->io_limit_mb;
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
if (! $package || ! $hasLimits) {
if ($limit) {
$limit->fill([
'cpu_limit_percent' => null,
'memory_limit_mb' => null,
'io_limit_mb' => null,
'is_active' => false,
])->save();
app(ResourceLimitService::class)->clear($limit);
}
return;
}
if (! $limit) {
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
}
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
app(ResourceLimitService::class)->apply($limit);
}
protected function getHeaderActions(): array

View File

@@ -3,20 +3,16 @@
namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Filament\Concerns\HasPageTour;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
use HasPageTour;
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
CreateAction::make(),
];
}

View File

@@ -124,7 +124,7 @@ class UserForm
Placeholder::make('package_notice')
->label(__('Hosting Package'))
->content(__('No hosting package selected. This user will have unlimited resources.'))
->content(__('No hosting package selected. This user will have unlimited quotas.'))
->visible(fn ($get) => blank($get('hosting_package_id'))),
\Filament\Forms\Components\Select::make('hosting_package_id')
@@ -137,7 +137,7 @@ class UserForm
->pluck('name', 'id')
->toArray())
->placeholder(__('Unlimited (no package)'))
->helperText(__('Assign a package to set resource limits.'))
->helperText(__('Assign a package to set quotas.'))
->columnSpanFull(),
Toggle::make('create_linux_user')

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Filament\Concerns;
use Filament\Actions\Action;
trait HasPageTour
{
protected function getTourAction(): Action
{
return Action::make('takeTour')
->label(__('Take Tour'))
->icon('heroicon-o-academic-cap')
->color('gray')
->action(function (): void {
$this->dispatch('start-page-tour');
});
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Backup;
use App\Models\BackupDestination;
use App\Models\BackupRestore;
@@ -46,7 +45,6 @@ use Livewire\Attributes\Url;
class Backups extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -588,7 +586,6 @@ class Backups extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('createBackup')
->label(__('Create Backup'))
->icon('heroicon-o-archive-box-arrow-down')

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\CloudflareZone;
use App\Models\Domain;
use BackedEnum;
@@ -26,7 +25,6 @@ use Illuminate\Support\Facades\Http;
class CdnIntegration extends Page implements HasActions, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithTable;

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\CronJob;
use App\Models\Domain;
use App\Services\Agent\AgentClient;
@@ -32,7 +31,6 @@ use Illuminate\Support\HtmlString;
class CronJobs extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -500,7 +498,6 @@ class CronJobs extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->createCronJobAction(),
$this->setupWordPressCronAction(),
];

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -35,7 +34,6 @@ use Illuminate\Support\Facades\Crypt;
class Databases extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -382,7 +380,6 @@ class Databases extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->quickSetupAction(),
$this->createDatabaseAction(),
$this->createUserAction(),

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Filament\Jabali\Widgets\DnsPendingAddsTable;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
@@ -41,7 +40,6 @@ use Livewire\Attributes\On;
class DnsRecords extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -802,7 +800,6 @@ class DnsRecords extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->applyTemplateAction()
->visible(fn () => $this->selectedDomainId !== null),
$this->addRecordAction()

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\DomainAlias;
use App\Models\DomainHotlinkSetting;
@@ -38,7 +37,6 @@ use Illuminate\Support\Facades\Auth;
class Domains extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -434,7 +432,6 @@ class Domains extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->createDomainAction(),
];
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Autoresponder;
use App\Models\DnsRecord;
use App\Models\Domain;
@@ -44,7 +43,6 @@ use Livewire\Attributes\Url;
class Email extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -821,7 +819,6 @@ class Email extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->createMailboxAction(),
$this->createForwarderAction(),
$this->createAutoresponderAction(),

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\DnsSetting;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -34,7 +33,6 @@ use Livewire\WithFileUploads;
class Files extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -95,7 +93,6 @@ class Files extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
];
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -33,7 +32,6 @@ use Illuminate\Support\Str;
class GitDeployment extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;

View File

@@ -4,7 +4,6 @@ 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;
@@ -25,7 +24,6 @@ use Illuminate\Support\Facades\Auth;
class ImageOptimization extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;

View File

@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\AuditLog;
use App\Models\UserResourceUsage;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
@@ -22,7 +20,6 @@ use Livewire\Attributes\Url;
class Logs extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
@@ -96,7 +93,7 @@ class Logs extends Page implements HasActions, HasForms
protected function normalizeTab(?string $tab): string
{
return match ($tab) {
'logs', 'usage', 'activity', 'stats' => (string) $tab,
'logs', 'activity', 'stats' => (string) $tab,
default => 'logs',
};
}
@@ -195,57 +192,6 @@ class Logs extends Page implements HasActions, HasForms
->send();
}
public function getUsageChartData(): array
{
$start = now()->subDays(29)->startOfDay();
$end = now()->endOfDay();
$records = UserResourceUsage::query()
->where('user_id', Auth::id())
->whereBetween('captured_at', [$start, $end])
->get();
$labels = [];
for ($i = 0; $i < 30; $i++) {
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
}
$index = array_flip($labels);
$metrics = ['disk_bytes', 'database_bytes', 'mail_bytes', 'bandwidth_bytes'];
$values = [];
foreach ($metrics as $metric) {
$values[$metric] = array_fill(0, count($labels), 0);
}
foreach ($records as $record) {
$date = $record->captured_at?->format('Y-m-d');
if (! $date || ! isset($index[$date])) {
continue;
}
$idx = $index[$date];
$metric = $record->metric;
if (! isset($values[$metric])) {
continue;
}
if ($metric === 'bandwidth_bytes') {
$values[$metric][$idx] += (int) $record->value;
} else {
$values[$metric][$idx] = max($values[$metric][$idx], (int) $record->value);
}
}
$toGb = fn (int $bytes) => round($bytes / 1024 / 1024 / 1024, 2);
return [
'labels' => $labels,
'series' => [
['name' => __('Disk'), 'data' => array_map($toGb, $values['disk_bytes'])],
['name' => __('Databases'), 'data' => array_map($toGb, $values['database_bytes'])],
['name' => __('Mail'), 'data' => array_map($toGb, $values['mail_bytes'])],
['name' => __('Bandwidth'), 'data' => array_map($toGb, $values['bandwidth_bytes'])],
],
];
}
public function getActivityLogs()
{
return AuditLog::query()
@@ -301,7 +247,6 @@ class Logs extends Page implements HasActions, HasForms
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('generateStats')
->label(__('Generate Statistics'))

View File

@@ -4,7 +4,6 @@ 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;
@@ -22,7 +21,6 @@ use Illuminate\Support\Facades\Auth;
class MailingLists extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
@@ -23,7 +22,6 @@ use Illuminate\Support\Facades\Auth;
class PhpSettings extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
@@ -267,7 +265,6 @@ class PhpSettings extends Page implements HasActions, HasForms
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->saveSettingsAction(),
];
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
@@ -28,7 +27,6 @@ use Livewire\Attributes\Url;
class PostgreSQL extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
@@ -26,7 +25,6 @@ use Illuminate\Support\Facades\Auth;
class SshKeys extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -203,7 +201,6 @@ class SshKeys extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('generateKey')
->label(__('Generate SSH Key'))
->icon('heroicon-o-sparkles')

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Services\Agent\AgentClient;
@@ -29,7 +28,6 @@ use Illuminate\Support\Facades\Auth;
class Ssl extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -498,7 +496,6 @@ class Ssl extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->installCustomCertificateAction(),
];
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient;
@@ -36,7 +35,6 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
{
protected static ?string $slug = 'wordpress';
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
@@ -330,7 +328,6 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
$this->scanAction(),
$this->installAction(),
];

View File

@@ -7,10 +7,8 @@ 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;
@@ -76,29 +74,6 @@ class AutomationApiController extends Controller
}
}
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);
}

View File

@@ -20,9 +20,6 @@ class HostingPackage extends Model
'domains_limit',
'databases_limit',
'mailboxes_limit',
'cpu_limit_percent',
'memory_limit_mb',
'io_limit_mb',
'is_active',
];

View File

@@ -1,31 +0,0 @@
<?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

@@ -1,28 +0,0 @@
<?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

@@ -3,6 +3,7 @@
namespace App\Providers\Filament;
use App\Filament\Admin\Pages\Auth\Login as AdminLogin;
use App\Filament\Admin\Pages\Dashboard;
use App\Filament\AvatarProviders\InitialsAvatarProvider;
use App\Http\Middleware\SetLocale;
use App\Models\DnsSetting;
@@ -10,7 +11,6 @@ use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use App\Filament\Admin\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@@ -38,13 +38,13 @@ class AdminPanelProvider extends PanelProvider
'primary' => Color::Red,
])
->darkMode()
->brandName(fn () => DnsSetting::get('panel_name', 'Jabali') . ' Admin')
->brandName(fn () => DnsSetting::get('panel_name', 'Jabali').' Admin')
->favicon(asset('favicon.ico'))
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => $this->getOpenGraphTags('Jabali Admin', 'Server administration panel for Jabali - Manage your hosting infrastructure') .
'<link rel="stylesheet" href="' . asset('css/filament-custom.css') . '">' .
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/admin-tour.js', 'resources/js/server-charts.js'])->toHtml() .
fn () => $this->getOpenGraphTags('Jabali Admin', 'Server administration panel for Jabali - Manage your hosting infrastructure').
'<link rel="stylesheet" href="'.asset('css/filament-custom.css').'">'.
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/server-charts.js'])->toHtml().
$this->getRtlScript()
)
->renderHook(
@@ -59,10 +59,6 @@ $this->getRtlScript()
PanelsRenderHook::USER_MENU_BEFORE,
fn () => view('components.language-switcher')
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => view('components.admin-tour')
)
->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources')
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages')
->pages([
@@ -141,8 +137,10 @@ $this->getRtlScript()
shuffle($shuffled);
for ($col = 0; $col < 20; $col++) {
$word = $shuffled[$col % count($shuffled)];
if ($col % 3 === 0) shuffle($shuffled); // Re-shuffle periodically
$rowContent .= $word . ' · ';
if ($col % 3 === 0) {
shuffle($shuffled);
} // Re-shuffle periodically
$rowContent .= $word.' · ';
}
$rows .= "<div class=\"pattern-row\">{$rowContent}</div>";
}

View File

@@ -4,10 +4,10 @@ namespace App\Providers\Filament;
use App\Filament\AvatarProviders\InitialsAvatarProvider;
use App\Filament\Jabali\Pages\Auth\Login;
use App\Models\DnsSetting;
use App\Models\User;
use App\Http\Middleware\RedirectAdminFromUserPanel;
use App\Http\Middleware\SetLocale;
use App\Models\DnsSetting;
use App\Models\User;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -22,7 +22,6 @@ use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Blade;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class JabaliPanelProvider extends PanelProvider
@@ -46,14 +45,14 @@ class JabaliPanelProvider extends PanelProvider
->favicon(asset('favicon.ico'))
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => $this->getOpenGraphTags('Jabali Panel', 'Web hosting control panel - Manage your domains, emails, databases and more') .
'<link rel="stylesheet" href="' . asset('css/filament-custom.css') . '?v=' . filemtime(public_path('css/filament-custom.css')) . '">' .
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/admin-tour.js', 'resources/js/server-charts.js'])->toHtml() .
fn () => $this->getOpenGraphTags('Jabali Panel', 'Web hosting control panel - Manage your domains, emails, databases and more').
'<link rel="stylesheet" href="'.asset('css/filament-custom.css').'?v='.filemtime(public_path('css/filament-custom.css')).'">'.
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/server-charts.js'])->toHtml().
$this->getRtlScript()
)
->renderHook(
PanelsRenderHook::BODY_START,
fn () => (request()->routeIs('filament.jabali.auth.login') ? $this->getLoginWordCloud() : '') . $this->renderImpersonationNotice()
fn () => (request()->routeIs('filament.jabali.auth.login') ? $this->getLoginWordCloud() : '').$this->renderImpersonationNotice()
)
->renderHook(
PanelsRenderHook::FOOTER,
@@ -63,10 +62,6 @@ $this->getRtlScript()
PanelsRenderHook::USER_MENU_BEFORE,
fn () => view('components.language-switcher')
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => view('components.user-tour')
)
->discoverResources(in: app_path('Filament/Jabali/Resources'), for: 'App\\Filament\\Jabali\\Resources')
->discoverPages(in: app_path('Filament/Jabali/Pages'), for: 'App\\Filament\\Jabali\\Pages')
->pages([])
@@ -106,7 +101,7 @@ $this->getRtlScript()
protected function renderImpersonationNotice(): string
{
if (!session()->has('impersonated_by')) {
if (! session()->has('impersonated_by')) {
return '';
}
@@ -114,7 +109,7 @@ $this->getRtlScript()
$admin = User::find($adminId);
$currentUser = auth()->user();
if (!$admin || !$currentUser) {
if (! $admin || ! $currentUser) {
return '';
}
@@ -172,8 +167,10 @@ $this->getRtlScript()
shuffle($shuffled);
for ($col = 0; $col < 20; $col++) {
$word = $shuffled[$col % count($shuffled)];
if ($col % 3 === 0) shuffle($shuffled);
$rowContent .= $word . ' · ';
if ($col % 3 === 0) {
shuffle($shuffled);
}
$rowContent .= $word.' · ';
}
$rows .= "<div class=\"pattern-row\">{$rowContent}</div>";
}

View File

@@ -1333,7 +1333,7 @@ class AgentClient
return $this->send('updates.run');
}
// WAF / Geo / Resource limits
// WAF / Geo
public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog): array
{
return $this->send('waf.apply', [
@@ -1350,44 +1350,6 @@ class AgentClient
]);
}
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 cgroupSyncUserProcesses(string $username): array
{
return $this->send('cgroup.sync_user_processes', [
'username' => $username,
]);
}
public function cgroupSyncAllProcesses(): array
{
return $this->send('cgroup.sync_all_processes');
}
public function cgroupClearAllLimits(): array
{
return $this->send('cgroup.clear_all_limits');
}
public function databasePersistTuning(string $name, string $value): array
{
return $this->send('database.persist_tuning', [

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\System;
use App\Models\DnsSetting;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use RuntimeException;
class ResourceLimitService
{
public function apply(UserResourceLimit $limit): void
{
if (! DnsSetting::get('resource_limits_enabled', true)) {
return;
}
$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);
}
}

View File

@@ -547,11 +547,6 @@ function handleAction(array $request): array
'updates.run' => updatesRun($params),
'waf.apply' => wafApplySettings($params),
'geo.apply_rules' => geoApplyRules($params),
'cgroup.apply_user_limits' => cgroupApplyUserLimits($params),
'cgroup.clear_user_limits' => cgroupClearUserLimits($params),
'cgroup.sync_user_processes' => cgroupSyncUserProcesses($params),
'cgroup.sync_all_processes' => cgroupSyncAllProcesses($params),
'cgroup.clear_all_limits' => cgroupClearAllUserLimits($params),
'database.persist_tuning' => databasePersistTuning($params),
'server.export_config' => serverExportConfig($params),
'server.import_config' => serverImportConfig($params),
@@ -818,11 +813,6 @@ function createUser(array $params): array
logger("Warning: Failed to create Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
}
$cgroupResult = ensureUserCgroup($username);
if (!($cgroupResult['success'] ?? false)) {
logger("Warning: Failed to initialize cgroup for $username: " . ($cgroupResult['error'] ?? 'Unknown error'));
}
logger("Created user $username with home directory $homeDir");
return [
@@ -1040,8 +1030,6 @@ function deleteUser(array $params): array
}
}
cgroupRemoveUser($username);
// Reload services
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null');
@@ -3040,386 +3028,6 @@ function geoApplyRules(array $params): array
return ['success' => true, 'rules' => count($ruleset)];
}
function getRootBlockDevice(): ?string
{
exec('findmnt -no SOURCE / 2>/dev/null', $output, $code);
$source = trim($output[0] ?? '');
if ($source === '') {
return null;
}
if (str_starts_with($source, '/dev/')) {
return $source;
}
exec('readlink -f ' . escapeshellarg($source) . ' 2>/dev/null', $resolved, $resolvedCode);
$resolvedPath = trim($resolved[0] ?? '');
return $resolvedPath ?: null;
}
function isCgroupV2Available(): bool
{
return is_file('/sys/fs/cgroup/cgroup.controllers');
}
function getJabaliCgroupRoot(): string
{
return '/sys/fs/cgroup/jabali.slice';
}
function enableCgroupControllers(string $path, array $controllers): void
{
$controllersFile = $path . '/cgroup.controllers';
$subtreeFile = $path . '/cgroup.subtree_control';
if (!is_readable($controllersFile) || !is_writable($subtreeFile)) {
return;
}
$available = preg_split('/\s+/', trim(file_get_contents($controllersFile)));
$toEnable = [];
foreach ($controllers as $controller) {
if (in_array($controller, $available, true)) {
$toEnable[] = '+' . $controller;
}
}
if (!empty($toEnable)) {
@file_put_contents($subtreeFile, implode(' ', $toEnable));
}
}
function ensureJabaliCgroupRoot(): array
{
if (!isCgroupV2Available()) {
return ['success' => false, 'error' => 'cgroup v2 is not available'];
}
$root = getJabaliCgroupRoot();
if (!is_dir($root)) {
exec('systemctl start jabali.slice 2>/dev/null', $output, $code);
}
if (!is_dir($root)) {
return ['success' => false, 'error' => 'Failed to initialize jabali.slice'];
}
enableCgroupControllers($root, ['cpu', 'memory', 'io']);
return ['success' => true, 'path' => $root];
}
function getUserCgroupPath(string $username): string
{
return getJabaliCgroupRoot() . '/user-' . $username;
}
function ensureUserCgroup(string $username): array
{
$rootResult = ensureJabaliCgroupRoot();
if (!($rootResult['success'] ?? false)) {
return $rootResult;
}
$path = getUserCgroupPath($username);
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
if (!is_dir($path)) {
return ['success' => false, 'error' => 'Failed to create user cgroup'];
}
return ['success' => true, 'path' => $path];
}
function writeCgroupValue(string $path, string $file, string $value): bool
{
$target = $path . '/' . $file;
if (!is_writable($target)) {
return false;
}
return file_put_contents($target, $value) !== false;
}
function getBlockDeviceId(?string $device): ?string
{
if (!$device) {
return null;
}
exec('lsblk -no MAJ:MIN ' . escapeshellarg($device) . ' 2>/dev/null', $output, $code);
$id = trim($output[0] ?? '');
return $id !== '' ? $id : null;
}
function cgroupWriteCpuMax(string $path, int $cpuPercent): void
{
$period = 100000;
if ($cpuPercent <= 0) {
writeCgroupValue($path, 'cpu.max', 'max ' . $period);
return;
}
$quota = (int) round($period * ($cpuPercent / 100));
$quota = max($quota, 1000);
writeCgroupValue($path, 'cpu.max', $quota . ' ' . $period);
}
function cgroupWriteMemoryMax(string $path, int $memoryMb): void
{
if ($memoryMb <= 0) {
writeCgroupValue($path, 'memory.max', 'max');
return;
}
$bytes = $memoryMb * 1024 * 1024;
writeCgroupValue($path, 'memory.max', (string) $bytes);
}
function cgroupWriteIoMax(string $path, int $ioMb): void
{
$device = getRootBlockDevice();
$deviceId = getBlockDeviceId($device);
if (!$deviceId) {
return;
}
if ($ioMb <= 0) {
writeCgroupValue($path, 'io.max', $deviceId . ' rbps=max wbps=max');
return;
}
$bytes = $ioMb * 1024 * 1024;
writeCgroupValue($path, 'io.max', $deviceId . ' rbps=' . $bytes . ' wbps=' . $bytes);
}
function moveUserProcessesToCgroup(int $uid, string $cgroupPath): int
{
$moved = 0;
foreach (glob('/proc/[0-9]*') as $procPath) {
$statusFile = $procPath . '/status';
if (!is_readable($statusFile)) {
continue;
}
$status = file($statusFile, FILE_IGNORE_NEW_LINES);
if (!$status) {
continue;
}
$matchesUid = false;
foreach ($status as $line) {
if (str_starts_with($line, 'Uid:')) {
$parts = preg_split('/\s+/', trim($line));
$matchesUid = isset($parts[1]) && (int) $parts[1] === $uid;
break;
}
}
if (!$matchesUid) {
continue;
}
$pid = basename($procPath);
if (!ctype_digit($pid)) {
continue;
}
if (@file_put_contents($cgroupPath . '/cgroup.procs', $pid) !== false) {
$moved++;
}
}
return $moved;
}
function cgroupSyncUserProcesses(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$cgroup = ensureUserCgroup($username);
if (!($cgroup['success'] ?? false)) {
return $cgroup;
}
$moved = moveUserProcessesToCgroup((int) $userInfo['uid'], $cgroup['path']);
return ['success' => true, 'moved' => $moved];
}
function cgroupSyncAllProcesses(array $params): array
{
$rootResult = ensureJabaliCgroupRoot();
if (!($rootResult['success'] ?? false)) {
return $rootResult;
}
$movedTotal = 0;
foreach (glob('/home/*', GLOB_ONLYDIR) as $homeDir) {
$username = basename($homeDir);
if (!validateUsername($username)) {
continue;
}
$result = cgroupSyncUserProcesses(['username' => $username]);
if ($result['success'] ?? false) {
$movedTotal += (int) ($result['moved'] ?? 0);
}
}
return ['success' => true, 'moved' => $movedTotal];
}
function cgroupClearAllUserLimits(array $params): array
{
$rootResult = ensureJabaliCgroupRoot();
if (!($rootResult['success'] ?? false)) {
return $rootResult;
}
$cleared = 0;
foreach (glob(getJabaliCgroupRoot() . '/user-*') as $cgroupPath) {
if (!is_dir($cgroupPath)) {
continue;
}
cgroupWriteCpuMax($cgroupPath, 0);
cgroupWriteMemoryMax($cgroupPath, 0);
cgroupWriteIoMax($cgroupPath, 0);
$cleared++;
}
return ['success' => true, 'cleared' => $cleared];
}
function cgroupRemoveUser(string $username): void
{
$path = getUserCgroupPath($username);
if (is_dir($path)) {
@rmdir($path);
}
}
function readCgroupStatValue(string $path, string $key): ?int
{
$file = $path . '/cpu.stat';
if (!is_readable($file)) {
return null;
}
$lines = file($file, FILE_IGNORE_NEW_LINES);
if (!$lines) {
return null;
}
foreach ($lines as $line) {
if (str_starts_with($line, $key . ' ')) {
$parts = explode(' ', trim($line));
return isset($parts[1]) ? (int) $parts[1] : null;
}
}
return null;
}
function readCgroupIoTotal(string $path): int
{
$file = $path . '/io.stat';
if (!is_readable($file)) {
return 0;
}
$total = 0;
$lines = file($file, FILE_IGNORE_NEW_LINES);
if (!$lines) {
return 0;
}
foreach ($lines as $line) {
if (!str_contains($line, 'rbytes=') && !str_contains($line, 'wbytes=')) {
continue;
}
if (preg_match('/rbytes=(\d+)/', $line, $m)) {
$total += (int) $m[1];
}
if (preg_match('/wbytes=(\d+)/', $line, $m)) {
$total += (int) $m[1];
}
}
return $total;
}
function cgroupApplyUserLimits(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
if (!posix_getpwnam($username)) {
return ['success' => false, 'error' => 'User not found'];
}
$cpu = isset($params['cpu_limit_percent']) ? (int) $params['cpu_limit_percent'] : 0;
$memory = isset($params['memory_limit_mb']) ? (int) $params['memory_limit_mb'] : 0;
$io = isset($params['io_limit_mb']) ? (int) $params['io_limit_mb'] : 0;
$cgroup = ensureUserCgroup($username);
if (!($cgroup['success'] ?? false)) {
return $cgroup;
}
$path = $cgroup['path'];
cgroupWriteCpuMax($path, $cpu);
cgroupWriteMemoryMax($path, $memory);
cgroupWriteIoMax($path, $io);
cgroupSyncUserProcesses(['username' => $username]);
return ['success' => true, 'message' => 'Resource limits applied', 'path' => $path];
}
function cgroupClearUserLimits(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
if (!posix_getpwnam($username)) {
return ['success' => false, 'error' => 'User not found'];
}
$cgroup = ensureUserCgroup($username);
if (!($cgroup['success'] ?? false)) {
return $cgroup;
}
$path = $cgroup['path'];
cgroupWriteCpuMax($path, 0);
cgroupWriteMemoryMax($path, 0);
cgroupWriteIoMax($path, 0);
return ['success' => true, 'message' => 'Resource limits cleared', 'path' => $path];
}
function databasePersistTuning(array $params): array
{
$name = $params['name'] ?? '';
@@ -6927,8 +6535,59 @@ function wpPageCacheEnable(array $params): array
$config = file_get_contents($configFile);
$hasPageCache = strpos($config, 'fastcgi_cache JABALI') !== false;
$hasHammerBypass = strpos($config, 'cache_reason "hammer"') !== false;
// If cache is already enabled, ensure hammer bypass exists
if ($hasPageCache && ! $hasHammerBypass) {
$hammerRule = <<<'HAMMER'
# Skip cache for hammer/stress test endpoints
if ($request_uri ~* "/hammer|/io-hammer|/hammer-all") {
set $skip_cache 1;
set $cache_reason "hammer";
}
HAMMER;
$updated = preg_replace(
'/\n\s*# Browser caching for static assets/',
$hammerRule . "\n\n # Browser caching for static assets",
$config,
1
);
if ($updated === null || $updated === $config) {
$updated = preg_replace(
'/(set \\$cache_reason \"\";)/',
"$1{$hammerRule}",
$config,
1
);
}
if ($updated && $updated !== $config) {
copy($configFile, $configFile . '.bak');
if (file_put_contents($configFile, $updated) === false) {
return ['success' => false, 'error' => 'Failed to update nginx config'];
}
exec('nginx -t 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
copy($configFile . '.bak', $configFile);
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode(' ', $output)];
}
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
}
return ['success' => true, 'message' => 'Page cache updated with hammer bypass'];
}
// Check if page cache is already enabled
if (strpos($config, 'fastcgi_cache JABALI') !== false) {
if ($hasPageCache) {
return ['success' => true, 'message' => 'Page cache already enabled'];
}
@@ -6973,6 +6632,12 @@ function wpPageCacheEnable(array $params): array
set $cache_reason "admin_url";
}
# Skip cache for hammer/stress test endpoints
if ($request_uri ~* "/hammer|/io-hammer|/hammer-all") {
set $skip_cache 1;
set $cache_reason "hammer";
}
# Browser caching for static assets (1 year, immutable for versioned files)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif|mp4|webm|ogg|mp3|wav|pdf|zip)$ {
expires 1y;
@@ -24047,17 +23712,8 @@ function usageUserResources(array $params): array
$cpuUsageUsec = null;
$memoryBytes = 0;
$diskIoTotal = 0;
$cgroupPath = getUserCgroupPath($username);
if (is_dir($cgroupPath)) {
$memoryCurrent = @file_get_contents($cgroupPath . '/memory.current');
if ($memoryCurrent !== false) {
$memoryBytes = (int) trim($memoryCurrent);
}
$cpuUsageUsec = readCgroupStatValue($cgroupPath, 'usage_usec');
$diskIoTotal = readCgroupIoTotal($cgroupPath);
}
$diskRead = 0;
$diskWrite = 0;
if ($cpuUsageUsec === null || $memoryBytes === 0) {
$cpuTotal = 0.0;
@@ -24082,8 +23738,6 @@ function usageUserResources(array $params): array
}
if ($diskIoTotal === 0) {
$diskRead = 0;
$diskWrite = 0;
foreach (glob('/proc/[0-9]*') as $procPath) {
$statusFile = $procPath . '/status';
@@ -24136,6 +23790,8 @@ function usageUserResources(array $params): array
'cpu_usage_usec_total' => $cpuUsageUsec,
'memory_bytes' => $memoryBytes,
'disk_io_total_bytes' => $diskIoTotal,
'disk_io_read_bytes_total' => $diskRead,
'disk_io_write_bytes_total' => $diskWrite,
];
}

View File

@@ -1,29 +0,0 @@
<?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

@@ -1,28 +0,0 @@
<?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

@@ -1,26 +0,0 @@
<?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,57 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('user_resource_usages')) {
Schema::drop('user_resource_usages');
}
if (Schema::hasTable('user_resource_limits')) {
Schema::drop('user_resource_limits');
}
if (Schema::hasTable('hosting_packages')) {
$columns = [];
foreach (['cpu_limit_percent', 'memory_limit_mb', 'io_limit_mb'] as $column) {
if (Schema::hasColumn('hosting_packages', $column)) {
$columns[] = $column;
}
}
if (! empty($columns)) {
Schema::table('hosting_packages', function (Blueprint $table) use ($columns) {
$table->dropColumn($columns);
});
}
}
if (Schema::hasTable('dns_settings')) {
DB::table('dns_settings')->where('key', 'resource_limits_enabled')->delete();
}
if (Schema::hasTable('user_settings')) {
DB::table('user_settings')
->whereIn('key', [
'bandwidth_total_bytes',
'disk_io_total_bytes',
'cpu_usage_usec_total',
'cpu_usage_captured_at',
])
->delete();
}
}
public function down(): void
{
// Irreversible purge of legacy resource usage artifacts.
}
};

View File

@@ -2519,33 +2519,6 @@ SERVICE
log "Jabali Agent service configured"
}
setup_cgroup_limits() {
header "Setting Up cgroup v2 Resource Limits"
if [[ ! -f /sys/fs/cgroup/cgroup.controllers ]]; then
warn "cgroup v2 not detected - resource limits will be disabled"
return
fi
cat > /etc/systemd/system/jabali.slice << 'SLICE'
[Slice]
Delegate=yes
CPUAccounting=yes
MemoryAccounting=yes
IOAccounting=yes
SLICE
systemctl daemon-reload
systemctl enable jabali.slice 2>/dev/null || true
systemctl start jabali.slice 2>/dev/null || true
if [[ -w /sys/fs/cgroup/jabali.slice/cgroup.subtree_control ]]; then
echo "+cpu +memory +io" > /sys/fs/cgroup/jabali.slice/cgroup.subtree_control || true
fi
log "cgroup v2 slice configured"
}
setup_queue_service() {
header "Setting Up Jabali Queue Worker"
@@ -2918,11 +2891,6 @@ uninstall() {
rm -f /etc/systemd/system/jabali-queue.service
rm -rf /etc/systemd/system/jabali-queue.service.d
systemctl stop jabali.slice 2>/dev/null || true
rm -f /etc/systemd/system/jabali.slice
rm -rf /sys/fs/cgroup/jabali.slice/user-* 2>/dev/null || true
rmdir /sys/fs/cgroup/jabali.slice 2>/dev/null || true
local services=(
nginx
php-fpm
@@ -3253,7 +3221,6 @@ main() {
configure_redis
setup_jabali
setup_agent_service
setup_cgroup_limits
setup_queue_service
setup_scheduler_cron
setup_logrotate

7
package-lock.json generated
View File

@@ -5,7 +5,6 @@
"packages": {
"": {
"dependencies": {
"driver.js": "^1.4.0",
"echarts": "^6.0.0"
},
"devDependencies": {
@@ -1715,12 +1714,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/driver.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -20,7 +20,6 @@
"vite": "^7.0.7"
},
"dependencies": {
"driver.js": "^1.4.0",
"echarts": "^6.0.0"
}
}

View File

@@ -1,279 +0,0 @@
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import { adminTours } from './tours/admin-tours.js';
import { userTours } from './tours/user-tours.js';
// Detect current panel and page
function getCurrentPage() {
const path = window.location.pathname;
const isAdmin = path.includes('/jabali-admin');
const isUser = path.includes('/jabali/');
let page = 'dashboard';
if (path.includes('/users')) page = 'users';
else if (path.includes('/server-status')) page = 'serverStatus';
else if (path.includes('/ssl-manager')) page = 'sslManager';
else if (path.includes('/server-settings')) page = 'serverSettings';
else if (path.includes('/email-settings')) page = 'emailSettings';
else if (path.includes('/dns-zones')) page = 'dnsZones';
else if (path.includes('/security')) page = 'security';
else if (path.includes('/services')) page = 'services';
else if (path.includes('/backups')) page = 'backups';
else if (path.includes('/audit-logs')) page = 'auditLogs';
else if (path.includes('/domains')) page = 'domains';
else if (path.includes('/email')) page = 'email';
else if (path.includes('/databases')) page = 'databases';
else if (path.includes('/files')) page = 'files';
else if (path.includes('/ssl')) page = 'ssl';
else if (path.includes('/dns-records')) page = 'dnsRecords';
else if (path.includes('/cron')) page = 'cronJobs';
else if (path.includes('/php')) page = 'phpSettings';
else if (path.includes('/ssh')) page = 'sshKeys';
else if (path.includes('/wordpress')) page = 'wordpress';
return { isAdmin, isUser, page };
}
// Setup sidebar data-tour attributes
function setupSidebarTourAttributes() {
const sidebarItems = document.querySelectorAll('.fi-sidebar-item');
const tourMappings = {
'/users': 'users',
'/server-status': 'server-status',
'/ssl-manager': 'ssl-manager',
'/server-settings': 'server-settings',
'/email-settings': 'email-settings',
'/dns-zones': 'dns-zones',
'/security': 'security',
'/services': 'services',
'/backups': 'backups',
'/audit-logs': 'audit-logs',
'/domains': 'domains',
'/email': 'email',
'/databases': 'databases',
'/files': 'files',
'/ssl': 'ssl',
'/dns-records': 'dns-records',
'/cron': 'cron-jobs',
'/php': 'php-settings',
'/ssh': 'ssh-keys',
'/wordpress': 'wordpress'
};
sidebarItems.forEach(item => {
const link = item.querySelector('a');
if (link) {
const href = link.getAttribute('href') || '';
for (const [pattern, tourId] of Object.entries(tourMappings)) {
if (href.includes(pattern)) {
item.setAttribute('data-tour', tourId);
break;
}
}
}
});
}
// Get tour configuration for current page
function getTourConfig(page, isAdmin) {
const tours = isAdmin ? adminTours : userTours;
return tours[page] || tours.dashboard;
}
// Filter steps to only include existing elements
function filterSteps(steps) {
return steps.filter(step => {
if (!step.element) return true; // Non-element steps always included
const el = document.querySelector(step.element);
return el !== null;
});
}
// Start tour for current page
function startPageTour(pageTour = null) {
const t = window.jabaliTourTranslations || {};
const { isAdmin, page } = getCurrentPage();
setupSidebarTourAttributes();
const tourConfig = pageTour ? { steps: () => pageTour } : getTourConfig(page, isAdmin);
if (!tourConfig) {
console.warn('No tour configuration found for page:', page);
return;
}
let steps = tourConfig.steps(t);
steps = filterSteps(steps);
if (steps.length === 0) {
console.warn('No valid steps for tour');
return;
}
const driverObj = driver({
showProgress: true,
animate: true,
smoothScroll: true,
allowClose: true,
overlayOpacity: 0.6,
stagePadding: 8,
nextBtnText: t.next || 'Next',
prevBtnText: t.prev || 'Previous',
doneBtnText: t.done || 'Done',
steps: steps,
onDestroyStarted: () => {
markTourCompleted();
driverObj.destroy();
}
});
driverObj.drive();
}
// Start main panel tour (dashboard overview)
function startAdminTour() {
const t = window.jabaliTourTranslations || {};
const { isAdmin } = getCurrentPage();
setupSidebarTourAttributes();
// Build comprehensive panel tour
const steps = [];
// Welcome
steps.push({
popover: {
title: t.welcome || 'Welcome to Jabali!',
description: t.welcomeDesc || "Let's take a quick tour of your control panel."
}
});
// Sidebar
const sidebar = document.querySelector('.fi-sidebar-nav');
if (sidebar) {
steps.push({
element: '.fi-sidebar-nav',
popover: {
title: t.navigation || 'Navigation Menu',
description: t.navigationDesc || 'Access all panel sections from the sidebar.',
side: 'right',
align: 'start'
}
});
}
// Add sidebar items based on what's available
const sidebarSteps = isAdmin ? [
{ tour: 'users', title: t.users || 'Users Management', desc: t.usersDesc || 'Create and manage hosting accounts.' },
{ tour: 'server-status', title: t.serverStatus || 'Server Status', desc: t.serverStatusDesc || 'Monitor server health and services.' },
{ tour: 'ssl-manager', title: t.sslManager || 'SSL Manager', desc: t.sslManagerDesc || 'Manage SSL certificates.' },
{ tour: 'server-settings', title: t.serverSettings || 'Server Settings', desc: t.serverSettingsDesc || 'Configure panel and server options.' },
{ tour: 'email-settings', title: t.emailSettings || 'Email Settings', desc: t.emailSettingsDesc || 'Configure mail server.' },
{ tour: 'dns-zones', title: t.dnsZones || 'DNS Zones', desc: t.dnsZonesDesc || 'Manage DNS zones.' },
{ tour: 'services', title: t.services || 'Services', desc: t.servicesDesc || 'Manage server services.' },
{ tour: 'backups', title: t.backups || 'Backups', desc: t.backupsDesc || 'Configure backups.' },
{ tour: 'security', title: t.security || 'Security', desc: t.securityDesc || 'Firewall, Fail2ban, antivirus, and SSH settings.' },
{ tour: 'audit-logs', title: t.auditLogs || 'Audit Logs', desc: t.auditLogsDesc || 'Track panel activities.' }
] : [
{ tour: 'domains', title: t.domains || 'Domains', desc: t.domainsDesc || 'Manage your websites.' },
{ tour: 'email', title: t.email || 'Email', desc: t.emailDesc || 'Manage email accounts.' },
{ tour: 'databases', title: t.databases || 'Databases', desc: t.databasesDesc || 'Manage MySQL databases.' },
{ tour: 'files', title: t.files || 'File Manager', desc: t.filesDesc || 'Manage your files.' },
{ tour: 'ssl', title: t.ssl || 'SSL', desc: t.sslDesc || 'Manage SSL certificates.' },
{ tour: 'backups', title: t.backups || 'Backups', desc: t.backupsDesc || 'Manage backups.' }
];
sidebarSteps.forEach(item => {
const el = document.querySelector(`[data-tour="${item.tour}"]`);
if (el) {
steps.push({
element: `[data-tour="${item.tour}"]`,
popover: {
title: item.title,
description: item.desc,
side: 'right',
align: 'start'
}
});
}
});
// Top bar
const topbar = document.querySelector('.fi-topbar');
if (topbar) {
steps.push({
element: '.fi-topbar',
popover: {
title: t.topBar || 'Top Bar',
description: t.topBarDesc || 'Access profile, language, and theme settings.',
side: 'bottom',
align: 'end'
}
});
}
// Quick Actions widget
const quickActions = document.querySelector('.fi-wi');
if (quickActions) {
steps.push({
element: '.fi-wi',
popover: {
title: t.quickActions || 'Quick Actions',
description: t.quickActionsDesc || 'Quick shortcuts to common tasks.',
side: 'top',
align: 'start'
}
});
}
// Final
steps.push({
popover: {
title: t.ready || "You're Ready!",
description: t.readyDesc || 'You can retake this tour anytime from the Dashboard.'
}
});
const driverObj = driver({
showProgress: true,
animate: true,
smoothScroll: true,
allowClose: true,
overlayOpacity: 0.6,
stagePadding: 8,
nextBtnText: t.next || 'Next',
prevBtnText: t.prev || 'Previous',
doneBtnText: t.done || 'Done',
steps: steps,
onDestroyStarted: () => {
markTourCompleted();
driverObj.destroy();
}
});
driverObj.drive();
}
function markTourCompleted() {
if (window.Livewire) {
Livewire.dispatch('tour-completed');
}
localStorage.setItem('jabali_tour_completed', 'true');
}
// Listen for Livewire events
document.addEventListener('livewire:init', () => {
Livewire.on('start-admin-tour', () => {
setTimeout(() => startAdminTour(), 300);
});
Livewire.on('start-page-tour', (data) => {
setTimeout(() => startPageTour(data?.steps), 300);
});
});
// Export for manual triggering
window.startAdminTour = startAdminTour;
window.startPageTour = startPageTour;

View File

@@ -1,114 +0,0 @@
// Admin Panel Page Tours Configuration
export const adminTours = {
// Dashboard tour (main panel tour)
dashboard: {
steps: (t) => [
{ popover: { title: t.welcome, description: t.welcomeDesc } },
{ element: '.fi-sidebar-nav', popover: { title: t.navigation, description: t.navigationDesc, side: 'right' } },
{ element: '[data-tour="users"]', popover: { title: t.users, description: t.usersDesc, side: 'right' } },
{ element: '[data-tour="server-status"]', popover: { title: t.serverStatus, description: t.serverStatusDesc, side: 'right' } },
{ element: '[data-tour="ssl-manager"]', popover: { title: t.sslManager, description: t.sslManagerDesc, side: 'right' } },
{ element: '[data-tour="server-settings"]', popover: { title: t.serverSettings, description: t.serverSettingsDesc, side: 'right' } },
{ element: '[data-tour="services"]', popover: { title: t.services, description: t.servicesDesc, side: 'right' } },
{ element: '[data-tour="backups"]', popover: { title: t.backups, description: t.backupsDesc, side: 'right' } },
{ element: '[data-tour="security"]', popover: { title: t.security || 'Security', description: t.securityDesc || 'Firewall, Fail2ban, antivirus, and SSH settings.', side: 'right' } },
{ element: '.fi-topbar', popover: { title: t.topBar, description: t.topBarDesc, side: 'bottom' } },
{ element: '.fi-wi', popover: { title: t.quickActions, description: t.quickActionsDesc, side: 'top' } },
{ popover: { title: t.ready, description: t.readyDesc } }
]
},
// Server Status page tour
serverStatus: {
steps: (t) => [
{ popover: { title: t.serverStatusTitle || 'Server Status', description: t.serverStatusIntro || 'Monitor your server health and performance metrics.' } },
{ element: '[wire\\:key*="cpu"], .fi-wi:first-child', popover: { title: t.cpuUsage || 'CPU Usage', description: t.cpuUsageDesc || 'Real-time CPU utilization percentage.', side: 'bottom' } },
{ element: '[wire\\:key*="memory"], .fi-wi:nth-child(2)', popover: { title: t.memoryUsage || 'Memory Usage', description: t.memoryUsageDesc || 'RAM usage and available memory.', side: 'bottom' } },
{ element: '[wire\\:key*="disk"], .fi-wi:nth-child(3)', popover: { title: t.diskUsage || 'Disk Usage', description: t.diskUsageDesc || 'Storage space utilization.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.servicesStatus || 'Services Status', description: t.servicesStatusDesc || 'Status of all running services. Green means running, red means stopped.', side: 'top' } }
]
},
// SSL Manager page tour
sslManager: {
steps: (t) => [
{ popover: { title: t.sslTitle || 'SSL Manager', description: t.sslIntro || 'Manage SSL certificates for all your domains.' } },
{ element: 'button[wire\\:click*="requestCertificate"], .fi-btn', popover: { title: t.requestCert || 'Request Certificate', description: t.requestCertDesc || 'Request a free Let\'s Encrypt SSL certificate for your domains.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.certList || 'Certificate List', description: t.certListDesc || 'View all certificates, their domains, and expiry dates.', side: 'top' } }
]
},
// Server Settings page tour
serverSettings: {
steps: (t) => [
{ popover: { title: t.settingsTitle || 'Server Settings', description: t.settingsIntro || 'Configure server-wide settings and preferences.' } },
{ element: '.fi-fo-field-wrp:first-child, [wire\\:model*="panel_name"]', popover: { title: t.panelBranding || 'Panel Branding', description: t.panelBrandingDesc || 'Customize the panel name and appearance.', side: 'right' } },
{ element: '[wire\\:model*="dns"], .fi-section:nth-child(2)', popover: { title: t.dnsSettings || 'DNS Settings', description: t.dnsSettingsDesc || 'Configure default nameservers for new domains.', side: 'right' } }
]
},
// Services page tour
services: {
steps: (t) => [
{ popover: { title: t.servicesTitle || 'Services Management', description: t.servicesIntro || 'Control server services from one place.' } },
{ element: '.fi-ta, table', popover: { title: t.servicesList || 'Services List', description: t.servicesListDesc || 'View all services with their current status.', side: 'top' } },
{ element: 'button[wire\\:click*="restart"], .fi-btn-action', popover: { title: t.serviceActions || 'Service Actions', description: t.serviceActionsDesc || 'Start, stop, or restart services as needed.', side: 'left' } }
]
},
// Backups page tour
backups: {
steps: (t) => [
{ popover: { title: t.backupsTitle || 'Backup Management', description: t.backupsIntro || 'Configure and manage server backups.' } },
{ element: '.fi-section:first-child, [wire\\:model*="schedule"]', popover: { title: t.backupSchedule || 'Backup Schedule', description: t.backupScheduleDesc || 'Set automatic backup schedules.', side: 'right' } },
{ element: '.fi-section:nth-child(2), [wire\\:model*="remote"]', popover: { title: t.remoteStorage || 'Remote Storage', description: t.remoteStorageDesc || 'Configure S3, FTP, or SFTP for offsite backups.', side: 'right' } },
{ element: '.fi-ta, table', popover: { title: t.backupHistory || 'Backup History', description: t.backupHistoryDesc || 'View and restore from previous backups.', side: 'top' } }
]
},
// Security page tour
security: {
steps: (t) => [
{ popover: { title: t.securityTitle || 'Security Center', description: t.securityIntro || 'Manage firewall, intrusion prevention, antivirus, and SSH settings.' } },
{ element: '.fi-tabs', popover: { title: t.securityTabs || 'Security Tabs', description: t.securityTabsDesc || 'Switch between Overview, Firewall, Fail2ban, Antivirus, and SSH settings.', side: 'bottom' } },
{ element: '.fi-section:first-child', popover: { title: t.securityOverview || 'Security Status', description: t.securityOverviewDesc || 'Quick overview of your server security status.', side: 'bottom' } },
{ element: 'button[wire\\:click*="toggleFirewall"], .fi-btn:first-child', popover: { title: t.firewallToggle || 'Firewall Controls', description: t.firewallToggleDesc || 'Enable, disable, or configure UFW firewall.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.securityRules || 'Security Rules', description: t.securityRulesDesc || 'View and manage firewall rules and banned IPs.', side: 'top' } }
]
},
// DNS Zones page tour
dnsZones: {
steps: (t) => [
{ popover: { title: t.dnsTitle || 'DNS Zones', description: t.dnsIntro || 'Manage DNS zones for all server domains.' } },
{ element: '.fi-ta, table', popover: { title: t.zonesList || 'DNS Zones List', description: t.zonesListDesc || 'All DNS zones with their record counts.', side: 'top' } }
]
},
// Email Settings page tour
emailSettings: {
steps: (t) => [
{ popover: { title: t.emailTitle || 'Email Settings', description: t.emailIntro || 'Configure mail server settings.' } },
{ element: '.fi-section:first-child', popover: { title: t.mailConfig || 'Mail Configuration', description: t.mailConfigDesc || 'Configure SMTP, DKIM, and SPF settings.', side: 'right' } }
]
},
// Audit Logs page tour
auditLogs: {
steps: (t) => [
{ popover: { title: t.auditTitle || 'Audit Logs', description: t.auditIntro || 'Track all panel activities for security.' } },
{ element: '.fi-ta, table', popover: { title: t.logsList || 'Activity Logs', description: t.logsListDesc || 'View all user actions with timestamps and details.', side: 'top' } }
]
},
// Users page tour
users: {
steps: (t) => [
{ popover: { title: t.usersTitle || 'Users Management', description: t.usersIntro || 'Create and manage hosting accounts for your server.' } },
{ element: '.fi-header-actions button, .fi-btn', popover: { title: t.createUser || 'Create User', description: t.createUserDesc || 'Click here to create a new hosting account.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.usersList || 'Users List', description: t.usersListDesc || 'All hosting accounts with their usernames, domains, and status.', side: 'top' } },
{ element: '.fi-ta-row, tr', popover: { title: t.userActions || 'User Actions', description: t.userActionsDesc || 'Click on a user to edit, or use the actions menu for impersonation, suspension, or deletion.', side: 'left' } }
]
}
};

View File

@@ -1,121 +0,0 @@
// User Panel Page Tours Configuration
export const userTours = {
// User Dashboard tour
dashboard: {
steps: (t) => [
{ popover: { title: t.welcome || 'Welcome!', description: t.welcomeDesc || 'This is your hosting control panel dashboard.' } },
{ element: '.fi-sidebar-nav', popover: { title: t.navigation || 'Navigation', description: t.navigationDesc || 'Access all features from the sidebar.', side: 'right' } },
{ element: '[data-tour="domains"]', popover: { title: t.domains || 'Domains', description: t.domainsDesc || 'Manage your websites and domain settings.', side: 'right' } },
{ element: '[data-tour="email"]', popover: { title: t.email || 'Email', description: t.emailDesc || 'Create and manage email accounts.', side: 'right' } },
{ element: '[data-tour="databases"]', popover: { title: t.databases || 'Databases', description: t.databasesDesc || 'Create MySQL databases for your applications.', side: 'right' } },
{ element: '[data-tour="files"]', popover: { title: t.files || 'File Manager', description: t.filesDesc || 'Upload and manage your website files.', side: 'right' } },
{ element: '[data-tour="ssl"]', popover: { title: t.ssl || 'SSL Certificates', description: t.sslDesc || 'Secure your websites with free SSL certificates.', side: 'right' } },
{ element: '[data-tour="backups"]', popover: { title: t.backups || 'Backups', description: t.backupsDesc || 'Create and restore website backups.', side: 'right' } },
{ element: '.fi-topbar', popover: { title: t.topBar || 'Top Bar', description: t.topBarDesc || 'Access profile and settings.', side: 'bottom' } },
{ popover: { title: t.ready || 'Ready!', description: t.readyDesc || 'Start by adding your first domain.' } }
]
},
// Domains page tour
domains: {
steps: (t) => [
{ popover: { title: t.domainsTitle || 'Domain Management', description: t.domainsIntro || 'Add and manage your websites here.' } },
{ element: 'button[wire\\:click*="add"], .fi-btn', popover: { title: t.addDomain || 'Add Domain', description: t.addDomainDesc || 'Click here to add a new domain or subdomain.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.domainsList || 'Your Domains', description: t.domainsListDesc || 'All your domains with their document roots and status.', side: 'top' } }
]
},
// Email page tour
email: {
steps: (t) => [
{ popover: { title: t.emailTitle || 'Email Management', description: t.emailIntro || 'Create and manage email accounts for your domains.' } },
{ element: 'button[wire\\:click*="create"], .fi-btn', popover: { title: t.createEmail || 'Create Email', description: t.createEmailDesc || 'Create a new email account.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.emailList || 'Email Accounts', description: t.emailListDesc || 'Your email accounts with quota usage.', side: 'top' } },
{ element: '[wire\\:click*="webmail"], a[href*="webmail"]', popover: { title: t.webmail || 'Webmail', description: t.webmailDesc || 'Access your email through the web interface.', side: 'left' } }
]
},
// Databases page tour
databases: {
steps: (t) => [
{ popover: { title: t.dbTitle || 'Database Management', description: t.dbIntro || 'Create MySQL databases for your applications.' } },
{ element: 'button[wire\\:click*="create"], .fi-btn', popover: { title: t.createDb || 'Create Database', description: t.createDbDesc || 'Create a new MySQL database.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.dbList || 'Your Databases', description: t.dbListDesc || 'All databases with size and user information.', side: 'top' } },
{ element: '[wire\\:click*="phpmyadmin"], a[href*="phpmyadmin"]', popover: { title: t.phpMyAdmin || 'phpMyAdmin', description: t.phpMyAdminDesc || 'Manage your database using phpMyAdmin.', side: 'left' } }
]
},
// Files page tour
files: {
steps: (t) => [
{ popover: { title: t.filesTitle || 'File Manager', description: t.filesIntro || 'Upload and manage your website files.' } },
{ element: '.file-browser, .fi-section', popover: { title: t.fileBrowser || 'File Browser', description: t.fileBrowserDesc || 'Navigate through your files and folders.', side: 'top' } },
{ element: 'button[wire\\:click*="upload"], .fi-btn', popover: { title: t.upload || 'Upload Files', description: t.uploadDesc || 'Upload files to your server.', side: 'bottom' } },
{ element: 'button[wire\\:click*="newFolder"], .fi-btn', popover: { title: t.newFolder || 'New Folder', description: t.newFolderDesc || 'Create new directories.', side: 'bottom' } }
]
},
// SSL page tour
ssl: {
steps: (t) => [
{ popover: { title: t.sslTitle || 'SSL Certificates', description: t.sslIntro || 'Secure your websites with SSL certificates.' } },
{ element: 'button[wire\\:click*="request"], .fi-btn', popover: { title: t.requestSsl || 'Request SSL', description: t.requestSslDesc || 'Get a free Let\'s Encrypt certificate.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.sslList || 'Your Certificates', description: t.sslListDesc || 'SSL certificates with expiry dates.', side: 'top' } }
]
},
// Backups page tour
backups: {
steps: (t) => [
{ popover: { title: t.backupsTitle || 'Backups', description: t.backupsIntro || 'Create and manage your website backups.' } },
{ element: 'button[wire\\:click*="create"], .fi-btn', popover: { title: t.createBackup || 'Create Backup', description: t.createBackupDesc || 'Create a new backup of your website.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.backupList || 'Your Backups', description: t.backupListDesc || 'Available backups that can be restored.', side: 'top' } }
]
},
// DNS Records page tour
dnsRecords: {
steps: (t) => [
{ popover: { title: t.dnsTitle || 'DNS Records', description: t.dnsIntro || 'Manage DNS records for your domain.' } },
{ element: 'button[wire\\:click*="add"], .fi-btn', popover: { title: t.addRecord || 'Add Record', description: t.addRecordDesc || 'Add A, CNAME, MX, or TXT records.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.recordsList || 'DNS Records', description: t.recordsListDesc || 'All DNS records for this domain.', side: 'top' } }
]
},
// Cron Jobs page tour
cronJobs: {
steps: (t) => [
{ popover: { title: t.cronTitle || 'Cron Jobs', description: t.cronIntro || 'Schedule automated tasks.' } },
{ element: 'button[wire\\:click*="add"], .fi-btn', popover: { title: t.addCron || 'Add Cron Job', description: t.addCronDesc || 'Schedule a new automated task.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.cronList || 'Scheduled Tasks', description: t.cronListDesc || 'Your scheduled cron jobs.', side: 'top' } }
]
},
// PHP Settings page tour
phpSettings: {
steps: (t) => [
{ popover: { title: t.phpTitle || 'PHP Settings', description: t.phpIntro || 'Configure PHP for your websites.' } },
{ element: 'select[wire\\:model*="version"], .fi-select', popover: { title: t.phpVersion || 'PHP Version', description: t.phpVersionDesc || 'Select the PHP version for your domain.', side: 'right' } },
{ element: '.fi-section, [wire\\:model*="memory"]', popover: { title: t.phpConfig || 'PHP Configuration', description: t.phpConfigDesc || 'Adjust memory limits, upload sizes, and other settings.', side: 'right' } }
]
},
// SSH Keys page tour
sshKeys: {
steps: (t) => [
{ popover: { title: t.sshTitle || 'SSH Keys', description: t.sshIntro || 'Manage SSH keys for secure server access.' } },
{ element: 'button[wire\\:click*="add"], .fi-btn', popover: { title: t.addKey || 'Add SSH Key', description: t.addKeyDesc || 'Add a public SSH key for authentication.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.keysList || 'Your SSH Keys', description: t.keysListDesc || 'Authorized SSH keys for your account.', side: 'top' } }
]
},
// WordPress page tour
wordpress: {
steps: (t) => [
{ popover: { title: t.wpTitle || 'WordPress Manager', description: t.wpIntro || 'Install and manage WordPress sites.' } },
{ element: 'button[wire\\:click*="install"], .fi-btn', popover: { title: t.installWp || 'Install WordPress', description: t.installWpDesc || 'One-click WordPress installation.', side: 'bottom' } },
{ element: '.fi-ta, table', popover: { title: t.wpList || 'WordPress Sites', description: t.wpListDesc || 'Your WordPress installations.', side: 'top' } }
]
}
};

View File

@@ -1,42 +0,0 @@
@php
$tourTranslations = [
'welcome' => __('Welcome to Jabali!'),
'welcomeDesc' => __("Let's take a quick tour of your control panel."),
'navigation' => __('Navigation Menu'),
'navigationDesc' => __('Access all panel sections from the sidebar.'),
'users' => __('Users Management'),
'usersDesc' => __('Create and manage hosting accounts with domains, email, and databases.'),
'serverStatus' => __('Server Status'),
'serverStatusDesc' => __('Monitor server health, CPU, memory, disk space, and running services.'),
'sslManager' => __('SSL Manager'),
'sslManagerDesc' => __('Manage SSL certificates, request free Let\'s Encrypt certificates, and monitor expiry dates.'),
'serverSettings' => __('Server Settings'),
'serverSettingsDesc' => __('Configure panel branding, DNS settings, disk quotas, and email notifications.'),
'emailSettings' => __('Email Settings'),
'emailSettingsDesc' => __('Configure mail server settings, DKIM, SPF records, and spam filters.'),
'dnsZones' => __('DNS Zones'),
'dnsZonesDesc' => __('Manage DNS zones and records for all domains on this server.'),
'security' => __('Security'),
'securityDesc' => __('Configure ModSecurity WAF rules and security policies.'),
'services' => __('Services'),
'servicesDesc' => __('Start, stop, and restart server services like Nginx, PHP-FPM, MySQL, and Postfix.'),
'backups' => __('Backup Management'),
'backupsDesc' => __('Set up automated backups with remote storage support (S3, FTP, SFTP).'),
'firewall' => __('Firewall'),
'firewallDesc' => __('Manage firewall rules, block IPs, and configure Fail2Ban protection.'),
'auditLogs' => __('Audit Logs'),
'auditLogsDesc' => __('Track all actions performed in the panel for security and compliance.'),
'topBar' => __('Top Bar'),
'topBarDesc' => __('Access your profile, change language, switch themes, and logout.'),
'quickActions' => __('Quick Actions'),
'quickActionsDesc' => __('Use these shortcuts to quickly add users, access backups, manage services, or configure firewall.'),
'ready' => __("You're Ready!"),
'readyDesc' => __('Start by creating your first user from the Users menu. You can always retake this tour from the Dashboard.'),
'next' => __('Next'),
'prev' => __('Previous'),
'done' => __('Done'),
];
@endphp
<script>
window.jabaliTourTranslations = {!! json_encode($tourTranslations) !!};
</script>

View File

@@ -1,41 +0,0 @@
@php
$tourTranslations = [
// Main tour
'welcome' => __('Welcome!'),
'welcomeDesc' => __('This is your hosting control panel. Let\'s take a quick tour.'),
'navigation' => __('Navigation'),
'navigationDesc' => __('Access all features from the sidebar.'),
'domains' => __('Domains'),
'domainsDesc' => __('Manage your websites and domain settings.'),
'email' => __('Email'),
'emailDesc' => __('Create and manage email accounts.'),
'databases' => __('Databases'),
'databasesDesc' => __('Create MySQL databases for your applications.'),
'files' => __('File Manager'),
'filesDesc' => __('Upload and manage your website files.'),
'ssl' => __('SSL Certificates'),
'sslDesc' => __('Secure your websites with free SSL.'),
'backups' => __('Backups'),
'backupsDesc' => __('Create and restore website backups.'),
'dnsRecords' => __('DNS Records'),
'dnsRecordsDesc' => __('Manage DNS records for your domains.'),
'cronJobs' => __('Cron Jobs'),
'cronJobsDesc' => __('Schedule automated tasks.'),
'phpSettings' => __('PHP Settings'),
'phpSettingsDesc' => __('Configure PHP for your websites.'),
'sshKeys' => __('SSH Keys'),
'sshKeysDesc' => __('Manage SSH keys for server access.'),
'wordpress' => __('WordPress'),
'wordpressDesc' => __('Install and manage WordPress sites.'),
'topBar' => __('Top Bar'),
'topBarDesc' => __('Access profile and settings.'),
'ready' => __('Ready!'),
'readyDesc' => __('Start by adding your first domain. You can retake this tour anytime.'),
'next' => __('Next'),
'prev' => __('Previous'),
'done' => __('Done'),
];
@endphp
<script>
window.jabaliTourTranslations = {!! json_encode($tourTranslations) !!};
</script>

View File

@@ -1,383 +0,0 @@
<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

@@ -3,7 +3,6 @@
$tabs = [
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
'stats' => ['label' => __('Statistics'), 'icon' => 'heroicon-o-chart-bar'],
'usage' => ['label' => __('Resource Usage'), 'icon' => 'heroicon-o-chart-pie'],
'activity' => ['label' => __('Activity Log'), 'icon' => 'heroicon-o-clipboard-document-list'],
];
@endphp
@@ -164,78 +163,6 @@
@endif
@endif
@if($activeTab === 'usage')
@php($usageData = $this->getUsageChartData())
<x-filament::section class="mt-4" icon="heroicon-o-chart-pie">
<x-slot name="heading">{{ __('Resource Usage (Last 30 Days)') }}</x-slot>
<x-slot name="description">{{ __('Historical usage snapshots collected hourly.') }}</x-slot>
<div
x-data="{
chart: null,
init() {
const data = @js($usageData);
const isDemo = Boolean(data.demo);
const boot = () => {
const element = this.$refs.chart ?? this.$el;
if (!window.echarts || !element) {
return false;
}
if (this.chart) {
this.chart.dispose();
}
this.chart = window.echarts.init(element);
this.chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: data.series.map(s => s.name) },
grid: { left: '3%', right: '3%', bottom: 50, containLabel: true },
xAxis: {
type: 'category',
data: data.labels,
axisLabel: {
formatter: (value) => value.slice(5),
margin: 12,
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '{value} GB' },
},
series: data.series.map((series) => ({
name: series.name,
type: 'line',
smooth: true,
areaStyle: {},
data: series.data,
})),
});
window.addEventListener('resize', () => this.chart?.resize());
requestAnimationFrame(() => this.chart?.resize());
setTimeout(() => this.chart?.resize(), 150);
return true;
};
if (!boot()) {
const interval = setInterval(() => {
if (boot()) {
clearInterval(interval);
}
}, 200);
}
},
}"
x-init="init"
class="w-full"
wire:ignore
>
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
</div>
</x-filament::section>
@endif
@if($activeTab === 'activity')
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>

View File

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

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Services\Agent\AgentClient;
use Tests\TestCase;
class JabaliSyncCgroupsTest extends TestCase
{
public function test_syncs_all_processes(): void
{
$this->app->instance(AgentClient::class, new FakeAgentClient([
'success' => true,
'moved' => 4,
]));
$this->artisan('jabali:sync-cgroups')
->expectsOutput('Synced cgroups, moved 4 process(es).')
->assertExitCode(0);
}
public function test_syncs_single_user(): void
{
$this->app->instance(AgentClient::class, new FakeAgentClient([
'success' => true,
'moved' => 1,
]));
$this->artisan('jabali:sync-cgroups --user=testuser')
->expectsOutput('Synced cgroups, moved 1 process(es).')
->assertExitCode(0);
}
}
class FakeAgentClient extends AgentClient
{
public function __construct(private array $result) {}
public function cgroupSyncUserProcesses(string $username): array
{
return $this->result;
}
public function cgroupSyncAllProcesses(): array
{
return $this->result;
}
}

View File

@@ -1,55 +0,0 @@
<?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);
}
}

View File

@@ -35,9 +35,16 @@ class WebmailSsoViewTest extends TestCase
$response = $this->actingAs($user)->get(route('webmail.sso', $mailbox));
$response->assertStatus(200);
$response->assertSee('Webmail Login Required');
$response->assertSee('Open Webmail Login');
if (file_exists('/etc/jabali/roundcube-sso.conf')) {
$response->assertStatus(302);
$location = $response->headers->get('Location');
$this->assertNotFalse($location);
$this->assertStringContainsString('/webmail/jabali-sso.php?token=', $location);
} else {
$response->assertStatus(200);
$response->assertSee('Webmail Login Required');
$response->assertSee('Open Webmail Login');
}
}
public function test_webmail_sso_shows_reset_required_when_password_missing(): void

View File

@@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Tests\Unit;
use App\Jobs\RunCpanelRestore;
use App\Models\User;
use App\Services\Agent\AgentClient;
use App\Services\Migration\MigrationDnsSyncService;
use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class RunCpanelRestoreTest extends TestCase
{
use RefreshDatabase;
public function test_it_marks_restore_completed_and_logs_success(): void
{
$jobId = 'cpanel-restore-success';
@@ -21,6 +26,10 @@ class RunCpanelRestoreTest extends TestCase
File::ensureDirectoryExists(dirname($logPath));
File::put($logPath, '');
User::factory()->create([
'username' => 'example',
]);
$this->app->instance(AgentClient::class, new class extends AgentClient
{
public function send(string $action, array $params = []): array
@@ -41,7 +50,10 @@ class RunCpanelRestoreTest extends TestCase
discoveredData: null,
);
$job->handle($this->app->make(AgentClient::class));
$job->handle(
$this->app->make(AgentClient::class),
$this->app->make(MigrationDnsSyncService::class),
);
$status = Cache::get('cpanel_restore_status_'.$jobId);
$this->assertSame('completed', $status['status'] ?? null);
@@ -61,6 +73,10 @@ class RunCpanelRestoreTest extends TestCase
File::ensureDirectoryExists(dirname($logPath));
File::put($logPath, '');
User::factory()->create([
'username' => 'example',
]);
$this->app->instance(AgentClient::class, new class extends AgentClient
{
public function send(string $action, array $params = []): array
@@ -81,7 +97,10 @@ class RunCpanelRestoreTest extends TestCase
discoveredData: null,
);
$job->handle($this->app->make(AgentClient::class));
$job->handle(
$this->app->make(AgentClient::class),
$this->app->make(MigrationDnsSyncService::class),
);
$status = Cache::get('cpanel_restore_status_'.$jobId);
$this->assertSame('failed', $status['status'] ?? null);

View File

@@ -14,7 +14,7 @@ class VersionFileTest extends TestCase
$content = file_get_contents($versionPath);
$this->assertNotFalse($content);
$this->assertStringContainsString('VERSION=0.9-rc2', $content);
$this->assertMatchesRegularExpression('/^VERSION=0\\.9-rc\\d*$/m', trim($content));
$this->assertStringNotContainsString('BUILD=', $content);
}
}