diff --git a/README.md b/README.md index 52f7213..af69ae6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. -Version: 0.9-rc21 (release candidate) +Version: 0.9-rc23 (release candidate) This is a release candidate. Expect rapid iteration and breaking changes until 1.0. diff --git a/VERSION b/VERSION index 3dec98f..4b4c0b3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc21 +VERSION=0.9-rc23 diff --git a/app/Console/Commands/CollectServerMetrics.php b/app/Console/Commands/CollectServerMetrics.php new file mode 100644 index 0000000..1d1a178 --- /dev/null +++ b/app/Console/Commands/CollectServerMetrics.php @@ -0,0 +1,152 @@ +metricsOverview(); + $diskData = $agent->metricsDisk()['data'] ?? []; + } catch (\Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $capturedAt = now()->second(0); + $load = (float) ($overview['load']['1min'] ?? 0); + $memory = $this->calculateMemoryUsage($overview['memory'] ?? []); + $swap = (float) ($overview['memory']['swap']['usage_percent'] ?? 0); + $partitions = $diskData['partitions'] ?? []; + + $this->storeMetric('load', '1m', $load, $capturedAt); + $this->storeMetric('memory', '1m', $memory, $capturedAt); + $this->storeMetric('swap', '1m', $swap, $capturedAt); + + foreach ($partitions as $partition) { + $mount = $partition['mount'] ?? null; + if ($mount === null) { + continue; + } + $used = (int) ($partition['used'] ?? 0); + $this->storeMetric('disk_gb', '1m', $this->bytesToGb($used), $capturedAt, $mount); + } + + if ($capturedAt->minute % 5 === 0) { + $this->storeAggregated('load', '1m', '5m', 5, $capturedAt); + $this->storeAggregated('memory', '1m', '5m', 5, $capturedAt); + $this->storeAggregated('swap', '1m', '5m', 5, $capturedAt); + $this->storeAggregatedDisk('5m', 5, $capturedAt); + } + + if ($capturedAt->minute % 30 === 0) { + $this->storeAggregated('load', '1m', '30m', 30, $capturedAt); + $this->storeAggregated('memory', '1m', '30m', 30, $capturedAt); + $this->storeAggregated('swap', '1m', '30m', 30, $capturedAt); + $this->storeAggregatedDisk('30m', 30, $capturedAt); + } + + $this->pruneResolution('1m', $capturedAt->copy()->subDay()); + $this->pruneResolution('5m', $capturedAt->copy()->subDays(7)); + $this->pruneResolution('30m', $capturedAt->copy()->subDays(31)); + + return self::SUCCESS; + } + + private function calculateMemoryUsage(array $memory): float + { + $usage = (float) ($memory['usage_percent'] ?? $memory['usage'] ?? 0); + $total = (float) ($memory['total'] ?? 0); + + if ($usage === 0.0 && $total > 0) { + $usage = ((float) ($memory['used'] ?? 0) / $total) * 100; + } + + return round($usage, 1); + } + + private function storeMetric(string $metric, string $resolution, float $value, Carbon $capturedAt, ?string $label = null): void + { + ServerMetric::create([ + 'metric' => $metric, + 'label' => $label, + 'resolution' => $resolution, + 'value' => round($value, 3), + 'captured_at' => $capturedAt, + ]); + } + + private function storeAggregated( + string $metric, + string $sourceResolution, + string $targetResolution, + int $minutes, + Carbon $capturedAt, + ?string $label = null + ): void { + $since = $capturedAt->copy()->subMinutes($minutes)->subSeconds(30); + $values = ServerMetric::query() + ->where('metric', $metric) + ->when($label !== null, fn ($query) => $query->where('label', $label)) + ->where('resolution', $sourceResolution) + ->where('captured_at', '>=', $since) + ->where('captured_at', '<=', $capturedAt) + ->orderBy('captured_at') + ->pluck('value') + ->all(); + + if ($values === []) { + return; + } + + $average = array_sum($values) / count($values); + $this->storeMetric($metric, $targetResolution, (float) $average, $capturedAt, $label); + } + + private function storeAggregatedDisk(string $targetResolution, int $minutes, Carbon $capturedAt): void + { + $labels = ServerMetric::query() + ->where('metric', 'disk_gb') + ->where('resolution', '1m') + ->distinct() + ->pluck('label') + ->filter() + ->all(); + + foreach ($labels as $label) { + $this->storeAggregated('disk_gb', '1m', $targetResolution, $minutes, $capturedAt, $label); + } + } + + private function bytesToGb(int $bytes): float + { + if ($bytes <= 0) { + return 0.0; + } + + return round($bytes / 1024 / 1024 / 1024, 2); + } + + private function pruneResolution(string $resolution, Carbon $before): void + { + ServerMetric::query() + ->where('resolution', $resolution) + ->where('captured_at', '<', $before) + ->delete(); + } +} diff --git a/app/Filament/Admin/Pages/ServerStatus.php b/app/Filament/Admin/Pages/ServerStatus.php index 07bc115..4d688a8 100644 --- a/app/Filament/Admin/Pages/ServerStatus.php +++ b/app/Filament/Admin/Pages/ServerStatus.php @@ -73,63 +73,7 @@ class ServerStatus extends Page implements HasTable protected function getHeaderActions(): array { - return [ - ActionGroup::make([ - Action::make('limit25') - ->label(__('Show 25 processes')) - ->icon(fn () => $this->processLimit === 25 ? 'heroicon-o-check' : null) - ->action(fn () => $this->setProcessLimit(25)), - Action::make('limit50') - ->label(__('Show 50 processes')) - ->icon(fn () => $this->processLimit === 50 ? 'heroicon-o-check' : null) - ->action(fn () => $this->setProcessLimit(50)), - Action::make('limit100') - ->label(__('Show 100 processes')) - ->icon(fn () => $this->processLimit === 100 ? 'heroicon-o-check' : null) - ->action(fn () => $this->setProcessLimit(100)), - Action::make('limitAll') - ->label(__('Show all processes')) - ->icon(fn () => $this->processLimit === 0 ? 'heroicon-o-check' : null) - ->action(fn () => $this->setProcessLimit(0)), - ]) - ->label(fn () => __('Process Limit: :limit', ['limit' => $this->processLimit === 0 ? __('All') : $this->processLimit])) - ->icon('heroicon-o-queue-list') - ->color('gray') - ->button(), - ActionGroup::make([ - Action::make('5s') - ->label(__('Every 5 seconds')) - ->icon(fn () => $this->refreshInterval === '5s' ? 'heroicon-o-check' : null) - ->action(fn () => $this->setRefreshInterval('5s')), - Action::make('10s') - ->label(__('Every 10 seconds')) - ->icon(fn () => $this->refreshInterval === '10s' ? 'heroicon-o-check' : null) - ->action(fn () => $this->setRefreshInterval('10s')), - Action::make('30s') - ->label(__('Every 30 seconds')) - ->icon(fn () => $this->refreshInterval === '30s' ? 'heroicon-o-check' : null) - ->action(fn () => $this->setRefreshInterval('30s')), - Action::make('60s') - ->label(__('Every 1 minute')) - ->icon(fn () => $this->refreshInterval === '60s' ? 'heroicon-o-check' : null) - ->action(fn () => $this->setRefreshInterval('60s')), - Action::make('off') - ->label(__('Off')) - ->icon(fn () => $this->refreshInterval === 'off' ? 'heroicon-o-check' : null) - ->action(fn () => $this->setRefreshInterval('off')), - ]) - ->label(fn () => $this->refreshInterval === 'off' - ? __('Auto-refresh: Off') - : __('Auto: :interval', ['interval' => $this->refreshInterval])) - ->icon('heroicon-o-clock') - ->color('gray') - ->button(), - Action::make('refresh') - ->label(fn () => $this->lastUpdated ? __('Refresh (:time)', ['time' => $this->lastUpdated]) : __('Refresh')) - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->action(fn () => $this->loadMetrics()), - ]; + return []; } public function setProcessLimit(int $limit): void @@ -291,6 +235,35 @@ class ServerStatus extends Page implements HasTable ->action(fn (Collection $records, array $data) => $this->killProcesses($records, (int) $data['signal'])) ->deselectRecordsAfterCompletion(), ]) + ->headerActions([ + ActionGroup::make([ + Action::make('limit25') + ->label(__('Show 25 processes')) + ->icon(fn () => $this->processLimit === 25 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(25)), + Action::make('limit50') + ->label(__('Show 50 processes')) + ->icon(fn () => $this->processLimit === 50 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(50)), + Action::make('limit100') + ->label(__('Show 100 processes')) + ->icon(fn () => $this->processLimit === 100 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(100)), + Action::make('limitAll') + ->label(__('Show all processes')) + ->icon(fn () => $this->processLimit === 0 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(0)), + ]) + ->label(fn () => __('Process Limit: :limit', ['limit' => $this->processLimit === 0 ? __('All') : $this->processLimit])) + ->icon('heroicon-o-queue-list') + ->color('gray') + ->button(), + Action::make('refreshProcesses') + ->label(fn () => $this->lastUpdated ? __('Refresh (:time)', ['time' => $this->lastUpdated]) : __('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(fn () => $this->loadMetrics()), + ]) ->heading(__('Process List')) ->description(__(':total total processes, :running running', ['total' => $this->processTotal, 'running' => $this->processRunning])) ->paginated([10, 25, 50, 100]) diff --git a/app/Filament/Admin/Widgets/ServerChartsWidget.php b/app/Filament/Admin/Widgets/ServerChartsWidget.php index 46abfed..22b2613 100644 --- a/app/Filament/Admin/Widgets/ServerChartsWidget.php +++ b/app/Filament/Admin/Widgets/ServerChartsWidget.php @@ -4,49 +4,105 @@ declare(strict_types=1); namespace App\Filament\Admin\Widgets; +use App\Models\ServerMetric; use App\Services\Agent\AgentClient; use Filament\Widgets\Widget; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Schema; class ServerChartsWidget extends Widget { + private const HISTORY_INTERVAL_SECONDS = 10; + protected string $view = 'filament.admin.widgets.server-charts'; - protected int | string | array $columnSpan = 'full'; + protected int|string|array $columnSpan = 'full'; protected ?string $pollingInterval = '10s'; public int $refreshKey = 0; + public string $range = '5m'; + + /** + * @var array + */ + public array $diskSnapshot = []; + + public function mount(): void + { + $this->diskSnapshot = $this->fetchDiskSnapshot(); + } + public function refreshData(): void { $this->refreshKey++; $data = $this->getData(); + + $this->storeTenSecondMetrics( + (float) ($data['load']['1min'] ?? 0), + (float) ($data['cpu']['iowait'] ?? 0), + (float) ($data['memory']['usage'] ?? 0), + (float) ($data['memory']['swap_usage'] ?? 0), + [], + ); + + $config = $this->rangeConfig($this->range); + $label = now()->format($config['label_format']); + $history = $this->getHistory( + (float) ($data['load']['1min'] ?? 0), + (float) ($data['cpu']['iowait'] ?? 0), + (float) ($data['memory']['usage'] ?? 0), + (float) ($data['memory']['swap_usage'] ?? 0), + $data['disk']['partitions'] ?? [], + ); + $this->dispatch('server-charts-updated', [ 'cpu' => $data['cpu']['usage'] ?? 0, + 'load' => $data['load']['1min'] ?? 0, + 'iowait' => $data['cpu']['iowait'] ?? 0, 'memory' => $data['memory']['usage'] ?? 0, - 'disk' => $data['disk']['partitions'][0]['usage_percent'] ?? 0, + 'swap' => $data['memory']['swap_usage'] ?? 0, + 'label' => $label, + 'history' => $history, + 'history_points' => $config['points'], + 'label_format' => $config['label_format'], + 'interval_seconds' => $config['interval_seconds'], ]); } public function getData(): array { try { - $agent = new AgentClient(); + $agent = new AgentClient; $overview = $agent->metricsOverview(); - $disk = $agent->metricsDisk()['data'] ?? []; + $disk = $this->diskSnapshot; + if (empty($disk)) { + $disk = $this->fetchDiskSnapshot($agent); + $this->diskSnapshot = $disk; + } $cpu = $overview['cpu'] ?? []; $memory = $overview['memory'] ?? []; + $swap = $memory['swap'] ?? []; - // Calculate memory usage if not provided $memUsage = $memory['usage_percent'] ?? $memory['usage'] ?? 0; if ($memUsage == 0 && ($memory['total'] ?? 0) > 0) { $memUsage = (($memory['used'] ?? 0) / $memory['total']) * 100; } + $history = $this->getHistory( + (float) ($overview['load']['1min'] ?? 0), + (float) ($cpu['iowait'] ?? 0), + (float) $memUsage, + (float) ($swap['usage_percent'] ?? 0), + $disk['partitions'] ?? [], + ); + return [ 'cpu' => [ 'usage' => round($cpu['usage'] ?? 0, 1), + 'iowait' => round($cpu['iowait'] ?? 0, 1), 'cores' => $cpu['cores'] ?? 0, 'model' => $cpu['model'] ?? 'Unknown', ], @@ -56,22 +112,464 @@ class ServerChartsWidget extends Widget 'total' => $memory['total'] ?? 0, 'free' => $memory['free'] ?? 0, 'cached' => $memory['cached'] ?? 0, + 'swap_used' => $swap['used'] ?? 0, + 'swap_total' => $swap['total'] ?? 0, + 'swap_usage' => round((float) ($swap['usage_percent'] ?? 0), 1), ], 'disk' => [ 'partitions' => $disk['partitions'] ?? [], ], 'load' => $overview['load'] ?? [], 'uptime' => $overview['uptime']['human'] ?? 'N/A', + 'history' => $history, + 'source' => 'agent', ]; } catch (\Exception $e) { + $fallback = $this->getLocalMetrics(); + if ($fallback !== null) { + if (empty($this->diskSnapshot)) { + $this->diskSnapshot = $fallback['disk'] ?? []; + } + $history = $this->getHistory( + (float) ($fallback['load']['1min'] ?? 0), + (float) ($fallback['cpu']['iowait'] ?? 0), + (float) ($fallback['memory']['usage'] ?? 0), + (float) ($fallback['memory']['swap_usage'] ?? 0), + $fallback['disk']['partitions'] ?? [], + ); + + return [ + 'cpu' => [ + 'usage' => round($fallback['cpu']['usage'] ?? 0, 1), + 'iowait' => round($fallback['cpu']['iowait'] ?? 0, 1), + 'cores' => $fallback['cpu']['cores'] ?? 0, + 'model' => $fallback['cpu']['model'] ?? 'Local', + ], + 'memory' => $fallback['memory'], + 'disk' => $fallback['disk'], + 'load' => $fallback['load'], + 'uptime' => $fallback['uptime'] ?? 'N/A', + 'history' => $history, + 'source' => 'local', + 'warning' => $e->getMessage(), + ]; + } + return [ 'error' => $e->getMessage(), - 'cpu' => ['usage' => 0, 'cores' => 0, 'model' => 'Error'], + 'cpu' => ['usage' => 0, 'iowait' => 0, 'cores' => 0, 'model' => 'Error'], 'memory' => ['usage' => 0, 'used' => 0, 'total' => 0, 'free' => 0, 'cached' => 0], - 'disk' => ['partitions' => []], + 'disk' => $this->diskSnapshot, 'load' => [], 'uptime' => 'N/A', + 'history' => $this->getHistory(0.0, 0.0, 0.0, 0.0, []), + 'source' => 'error', ]; } } + + public function setRange(string $range): void + { + $this->range = $this->normalizeRange($range); + + $config = $this->rangeConfig($this->range); + $history = $this->getHistory(); + $this->dispatch('server-charts-range-changed', [ + 'history' => $history, + 'history_points' => $config['points'], + 'label_format' => $config['label_format'], + 'interval_seconds' => $config['interval_seconds'], + 'range' => $this->range, + ]); + } + + public function getHistory( + float $load = 0.0, + float $iowait = 0.0, + float $memory = 0.0, + float $swap = 0.0, + array $diskPartitions = [] + ): array { + if (! Schema::hasTable('server_metrics')) { + $config = $this->rangeConfig($this->range); + + return $this->seedHistory( + $config['points'], + $config['label_format'], + $config['interval_seconds'], + $load, + $iowait, + $memory, + $swap, + $diskPartitions, + ); + } + + $config = $this->rangeConfig($this->range); + $since = now()->subMinutes($config['minutes']); + + $records = ServerMetric::query() + ->where('resolution', $config['resolution']) + ->where('captured_at', '>=', $since) + ->orderBy('captured_at') + ->get(); + + if ($records->isEmpty()) { + return $this->seedHistory( + $config['points'], + $config['label_format'], + $config['interval_seconds'], + $load, + $iowait, + $memory, + $swap, + $diskPartitions, + ); + } + + $grouped = $records->groupBy(fn (ServerMetric $metric) => $metric->captured_at->format('Y-m-d H:i:s')); + $diskLabels = $records + ->where('metric', 'disk_gb') + ->pluck('label') + ->filter() + ->unique() + ->values() + ->all(); + + $labels = []; + $loadSeries = []; + $ioWaitSeries = []; + $memorySeries = []; + $swapSeries = []; + $diskSeries = []; + foreach ($diskLabels as $label) { + $diskSeries[$label] = []; + } + + foreach ($grouped as $timestamp => $rows) { + $labels[] = Carbon::createFromFormat('Y-m-d H:i:s', $timestamp)->format($config['label_format']); + $loadSeries[] = (float) ($rows->firstWhere('metric', 'load')->value ?? 0); + $ioWaitSeries[] = (float) ($rows->firstWhere('metric', 'iowait')->value ?? 0); + $memorySeries[] = (float) ($rows->firstWhere('metric', 'memory')->value ?? 0); + $swapSeries[] = (float) ($rows->firstWhere('metric', 'swap')->value ?? 0); + foreach ($diskLabels as $label) { + $diskSeries[$label][] = (float) ($rows->firstWhere(fn (ServerMetric $metric) => $metric->metric === 'disk_gb' && $metric->label === $label)->value ?? 0); + } + } + + if (count($labels) < $config['points']) { + $seed = $this->seedHistory( + $config['points'], + $config['label_format'], + $config['interval_seconds'], + $load, + $iowait, + $memory, + $swap, + $diskPartitions, + ); + + $missing = $config['points'] - count($labels); + $labels = array_merge(array_slice($seed['labels'], 0, $missing), $labels); + $loadSeries = array_merge(array_fill(0, $missing, null), $loadSeries); + $ioWaitSeries = array_merge(array_fill(0, $missing, null), $ioWaitSeries); + $memorySeries = array_merge(array_fill(0, $missing, null), $memorySeries); + $swapSeries = array_merge(array_fill(0, $missing, null), $swapSeries); + + $seedDisk = $seed['disk'] ?? []; + foreach ($diskSeries as $label => $series) { + $prefix = array_fill(0, $missing, null); + $diskSeries[$label] = array_merge($prefix, $series); + } + } + + if (count($labels) > $config['points']) { + $labels = array_slice($labels, -$config['points']); + $loadSeries = array_slice($loadSeries, -$config['points']); + $ioWaitSeries = array_slice($ioWaitSeries, -$config['points']); + $memorySeries = array_slice($memorySeries, -$config['points']); + $swapSeries = array_slice($swapSeries, -$config['points']); + foreach ($diskSeries as $label => $series) { + $diskSeries[$label] = array_slice($series, -$config['points']); + } + } + + return [ + 'labels' => $labels, + 'load' => $loadSeries, + 'iowait' => $ioWaitSeries, + 'memory' => $memorySeries, + 'swap' => $swapSeries, + 'disk' => $diskSeries, + ]; + } + + private function rangeConfig(string $range): array + { + return match ($range) { + '5m' => ['minutes' => 5, 'points' => 30, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10], + '30m' => ['minutes' => 30, 'points' => 180, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10], + 'day' => ['minutes' => 1440, 'points' => 24, 'resolution' => '1h', 'label_format' => 'H:00', 'interval_seconds' => 3600], + 'week' => ['minutes' => 10080, 'points' => 28, 'resolution' => '6h', 'label_format' => 'M d H:00', 'interval_seconds' => 21600], + 'month' => ['minutes' => 43200, 'points' => 30, 'resolution' => '1d', 'label_format' => 'M d', 'interval_seconds' => 86400], + default => ['minutes' => 30, 'points' => 180, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10], + }; + } + + private function normalizeRange(string $range): string + { + return in_array($range, ['5m', '30m', 'day', 'week', 'month'], true) ? $range : '30m'; + } + + private function seedHistory( + int $points, + string $labelFormat, + int $intervalSeconds, + float $load, + float $iowait, + float $memory, + float $swap, + array $diskPartitions + ): array { + $labels = []; + $loadSeries = []; + $ioWaitSeries = []; + $memorySeries = []; + $swapSeries = []; + $diskSeries = []; + $diskSeeds = []; + foreach ($diskPartitions as $partition) { + $mount = $partition['mount'] ?? null; + $usedGb = $this->bytesToGb((int) ($partition['used'] ?? 0)); + if ($mount !== null) { + $diskSeeds[$mount] = $usedGb; + } + } + foreach ($diskSeeds as $mount => $value) { + $diskSeries[$mount] = []; + } + $now = now(); + + for ($i = $points - 1; $i >= 0; $i--) { + $labels[] = $now->copy()->subSeconds($i * $intervalSeconds)->format($labelFormat); + $loadSeries[] = round($load, 3); + $ioWaitSeries[] = round($iowait, 2); + $memorySeries[] = round($memory, 1); + $swapSeries[] = round($swap, 1); + foreach ($diskSeeds as $mount => $value) { + $diskSeries[$mount][] = round($value, 2); + } + } + + return [ + 'labels' => $labels, + 'load' => $loadSeries, + 'iowait' => $ioWaitSeries, + 'memory' => $memorySeries, + 'swap' => $swapSeries, + 'disk' => $diskSeries, + ]; + } + + private function storeTenSecondMetrics(float $load, float $iowait, float $memory, float $swap, array $partitions): void + { + if (! Schema::hasTable('server_metrics')) { + return; + } + + $capturedAt = now()->setSecond(intdiv(now()->second, self::HISTORY_INTERVAL_SECONDS) * self::HISTORY_INTERVAL_SECONDS); + + $this->storeMetric('load', '10s', $load, $capturedAt); + $this->storeMetric('iowait', '10s', $iowait, $capturedAt); + $this->storeMetric('memory', '10s', $memory, $capturedAt); + $this->storeMetric('swap', '10s', $swap, $capturedAt); + foreach ($partitions as $partition) { + $mount = $partition['mount'] ?? null; + if ($mount === null) { + continue; + } + $used = (int) ($partition['used'] ?? 0); + $this->storeMetric('disk_gb', '10s', $this->bytesToGb($used), $capturedAt, $mount); + } + + $this->pruneResolution('10s', now()->subMinutes(30)); + $this->storeAggregateMetric('load', $load); + $this->storeAggregateMetric('iowait', $iowait); + $this->storeAggregateMetric('memory', $memory); + $this->storeAggregateMetric('swap', $swap); + } + + private function storeMetric(string $metric, string $resolution, float $value, Carbon $capturedAt, ?string $label = null): void + { + ServerMetric::updateOrCreate( + [ + 'metric' => $metric, + 'label' => $label, + 'resolution' => $resolution, + 'captured_at' => $capturedAt, + ], + [ + 'value' => round($value, 3), + ], + ); + } + + private function storeAggregateMetric(string $metric, float $value): void + { + $now = now(); + $this->storeMetric($metric, '1h', $value, $this->bucketTimestamp($now, 3600)); + $this->storeMetric($metric, '6h', $value, $this->bucketTimestamp($now, 21600)); + $this->storeMetric($metric, '1d', $value, $this->bucketTimestamp($now, 86400)); + + $this->pruneResolution('1h', $now->copy()->subHours(24)); + $this->pruneResolution('6h', $now->copy()->subDays(7)); + $this->pruneResolution('1d', $now->copy()->subDays(31)); + } + + private function bucketTimestamp(Carbon $now, int $bucketSeconds): Carbon + { + return $now->copy()->setTimestamp(intdiv($now->timestamp, $bucketSeconds) * $bucketSeconds); + } + + private function pruneResolution(string $resolution, Carbon $before): void + { + ServerMetric::query() + ->where('resolution', $resolution) + ->where('captured_at', '<', $before) + ->delete(); + } + + private function getLocalMetrics(): ?array + { + $load = sys_getloadavg(); + if (! is_array($load) || count($load) < 3) { + return null; + } + + $loadData = [ + '1min' => round((float) $load[0], 2), + '5min' => round((float) $load[1], 2), + '15min' => round((float) $load[2], 2), + ]; + + $cpuCores = 0; + if (is_readable('/proc/cpuinfo')) { + $lines = file('/proc/cpuinfo', FILE_IGNORE_NEW_LINES); + foreach ($lines as $line) { + if (str_starts_with($line, 'processor')) { + $cpuCores++; + } + } + } + $cpuCores = $cpuCores > 0 ? $cpuCores : 1; + + $memory = $this->readMeminfo(); + $disk = $this->readDiskUsage(); + + return [ + 'cpu' => [ + 'usage' => 0, + 'iowait' => 0, + 'cores' => $cpuCores, + 'model' => 'Local', + ], + 'memory' => $memory, + 'disk' => $disk, + 'load' => $loadData, + ]; + } + + private function fetchDiskSnapshot(?AgentClient $agent = null): array + { + try { + $client = $agent ?? new AgentClient; + $disk = $client->metricsDisk()['data'] ?? []; + if (! empty($disk)) { + return $disk; + } + } catch (\Exception) { + // Ignore and fall back to local disk stats below. + } + + return $this->readDiskUsage(); + } + + private function readMeminfo(): array + { + $memInfo = []; + if (is_readable('/proc/meminfo')) { + $lines = file('/proc/meminfo', FILE_IGNORE_NEW_LINES); + foreach ($lines as $line) { + if (preg_match('/^(\w+):\s+(\d+)/', $line, $matches)) { + $memInfo[$matches[1]] = (int) $matches[2]; + } + } + } + + $totalKb = $memInfo['MemTotal'] ?? 0; + $freeKb = $memInfo['MemFree'] ?? 0; + $availableKb = $memInfo['MemAvailable'] ?? $freeKb; + $buffersKb = $memInfo['Buffers'] ?? 0; + $cachedKb = $memInfo['Cached'] ?? 0; + $swapTotalKb = $memInfo['SwapTotal'] ?? 0; + $swapFreeKb = $memInfo['SwapFree'] ?? 0; + $swapUsedKb = max(0, $swapTotalKb - $swapFreeKb); + + $usedKb = max(0, $totalKb - $availableKb); + + return [ + 'usage' => $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 1) : 0, + 'used' => round($usedKb / 1024, 0), + 'total' => round($totalKb / 1024, 0), + 'free' => round($freeKb / 1024, 0), + 'cached' => round($cachedKb / 1024, 0), + 'swap_used' => round($swapUsedKb / 1024, 0), + 'swap_total' => round($swapTotalKb / 1024, 0), + 'swap_usage' => $swapTotalKb > 0 ? round(($swapUsedKb / $swapTotalKb) * 100, 1) : 0, + ]; + } + + private function readDiskUsage(): array + { + $total = @disk_total_space('/') ?: 0; + $free = @disk_free_space('/') ?: 0; + $used = max(0, $total - $free); + $usagePercent = $total > 0 ? round(($used / $total) * 100, 1) : 0; + + return [ + 'partitions' => [ + [ + 'filesystem' => '/', + 'mount' => '/', + 'used' => $used, + 'total' => $total, + 'usage_percent' => $usagePercent, + 'used_human' => $this->formatBytes($used), + 'total_human' => $this->formatBytes($total), + ], + ], + ]; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $size = max(0, $bytes); + $unit = 0; + while ($size >= 1024 && $unit < count($units) - 1) { + $size /= 1024; + $unit++; + } + + return round($size, 1).' '.$units[$unit]; + } + + private function bytesToGb(int $bytes): float + { + if ($bytes <= 0) { + return 0.0; + } + + return round($bytes / 1024 / 1024 / 1024, 2); + } } diff --git a/app/Models/ServerMetric.php b/app/Models/ServerMetric.php new file mode 100644 index 0000000..f587f7c --- /dev/null +++ b/app/Models/ServerMetric.php @@ -0,0 +1,26 @@ + 'float', + 'captured_at' => 'datetime', + ]; + } +} diff --git a/bin/jabali-agent b/bin/jabali-agent index dc87341..425eebe 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -18735,8 +18735,10 @@ function metricsCpu(array $params): array $stat1 = file_get_contents('/proc/stat'); usleep(100000); // 100ms $stat2 = file_get_contents('/proc/stat'); - - $cpuUsage = calculateCpuUsage($stat1, $stat2); + + $cpuStats = calculateCpuStats($stat1, $stat2); + $cpuUsage = $cpuStats['usage']; + $ioWait = $cpuStats['iowait']; // Per-core usage $coreUsages = []; @@ -18757,6 +18759,7 @@ function metricsCpu(array $params): array 'model' => $cpuModel, 'cores' => $cpuCores, 'usage' => $cpuUsage, + 'iowait' => $ioWait, 'core_usage' => $coreUsages, 'load' => $load, 'frequency' => getCpuFrequency(), @@ -18764,29 +18767,39 @@ function metricsCpu(array $params): array ]; } -function calculateCpuUsage(string $stat1, string $stat2): float +function calculateCpuStats(string $stat1, string $stat2): array { - preg_match('/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m', $stat1, $m1); - preg_match('/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m', $stat2, $m2); - - if (empty($m1) || empty($m2)) { - return 0; + preg_match('/^cpu\s+(.+)$/m', $stat1, $m1); + preg_match('/^cpu\s+(.+)$/m', $stat2, $m2); + + if (empty($m1[1]) || empty($m2[1])) { + return ['usage' => 0.0, 'iowait' => 0.0]; } - - $idle1 = $m1[4] + $m1[5]; - $idle2 = $m2[4] + $m2[5]; - - $total1 = array_sum(array_slice($m1, 1)); - $total2 = array_sum(array_slice($m2, 1)); - + + $v1 = array_map('intval', preg_split('/\s+/', trim($m1[1]))); + $v2 = array_map('intval', preg_split('/\s+/', trim($m2[1]))); + + $idle1 = ($v1[3] ?? 0) + ($v1[4] ?? 0); + $idle2 = ($v2[3] ?? 0) + ($v2[4] ?? 0); + + $total1 = array_sum($v1); + $total2 = array_sum($v2); + $totalDiff = $total2 - $total1; $idleDiff = $idle2 - $idle1; - + $iowaitDiff = ($v2[4] ?? 0) - ($v1[4] ?? 0); + if ($totalDiff == 0) { - return 0; + return ['usage' => 0.0, 'iowait' => 0.0]; } - - return round((($totalDiff - $idleDiff) / $totalDiff) * 100, 1); + + $usage = (($totalDiff - $idleDiff) / $totalDiff) * 100; + $iowait = ($iowaitDiff / $totalDiff) * 100; + + return [ + 'usage' => round($usage, 1), + 'iowait' => round(max(0, $iowait), 1), + ]; } function getCpuFrequency(): array diff --git a/database/migrations/2026_01_28_185724_create_server_metrics_table.php b/database/migrations/2026_01_28_185724_create_server_metrics_table.php new file mode 100644 index 0000000..c35647b --- /dev/null +++ b/database/migrations/2026_01_28_185724_create_server_metrics_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('metric', 20); + $table->string('resolution', 10); + $table->decimal('value', 10, 3); + $table->timestamp('captured_at'); + $table->timestamps(); + + $table->index(['metric', 'resolution', 'captured_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('server_metrics'); + } +}; diff --git a/database/migrations/2026_01_28_220001_add_label_to_server_metrics_table.php b/database/migrations/2026_01_28_220001_add_label_to_server_metrics_table.php new file mode 100644 index 0000000..6c21ff1 --- /dev/null +++ b/database/migrations/2026_01_28_220001_add_label_to_server_metrics_table.php @@ -0,0 +1,26 @@ +string('label')->nullable()->after('metric'); + $table->index(['metric', 'label', 'resolution', 'captured_at'], 'server_metrics_metric_label_resolution_captured'); + }); + } + + public function down(): void + { + Schema::table('server_metrics', function (Blueprint $table) { + $table->dropIndex('server_metrics_metric_label_resolution_captured'); + $table->dropColumn('label'); + }); + } +}; diff --git a/install.sh b/install.sh index 2948c3b..651c5cb 100755 --- a/install.sh +++ b/install.sh @@ -418,6 +418,7 @@ install_packages() { socat sshpass pigz + locales # Security (always installed) fail2ban @@ -518,6 +519,15 @@ install_packages() { done } + if command -v locale-gen >/dev/null 2>&1; then + info "Configuring locales..." + if ! grep -q '^en_US.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + fi + locale-gen >/dev/null 2>&1 || warn "Locale generation failed" + update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || warn "Failed to set default locale" + fi + # Unhold packages in case user wants to install them manually later apt-mark unhold apache2 libapache2-mod-php libapache2-mod-php8.4 2>/dev/null || true diff --git a/resources/views/filament/admin/widgets/server-charts.blade.php b/resources/views/filament/admin/widgets/server-charts.blade.php index c62b10f..58c1aa8 100644 --- a/resources/views/filament/admin/widgets/server-charts.blade.php +++ b/resources/views/filament/admin/widgets/server-charts.blade.php @@ -7,18 +7,82 @@ $partitions = $disk['partitions'] ?? []; $load = $data['load'] ?? []; $uptime = $data['uptime'] ?? 'N/A'; + $history = $data['history'] ?? $this->getHistory(); + $historyLabels = $history['labels'] ?? []; + $historyLoad = $history['load'] ?? []; + $historyIoWait = $history['iowait'] ?? []; + $historyMemory = $history['memory'] ?? []; + $historySwap = $history['swap'] ?? []; + $range = $this->range; + $historyPoints = match ($range) { + '5m' => 30, + '30m' => 180, + 'day' => 24, + 'week' => 28, + 'month' => 30, + default => 180, + }; + $historyIntervalSeconds = match ($range) { + '5m' => 10, + '30m' => 10, + 'day' => 3600, + 'week' => 21600, + 'month' => 86400, + default => 10, + }; + $historyLabelFormat = match ($range) { + '5m' => 'H:i:s', + '30m' => 'H:i:s', + 'day' => 'H:00', + 'week' => 'M d H:00', + 'month' => 'M d', + default => 'H:i:s', + }; - $cpuUsage = $cpu['usage'] ?? 0; + $cpuCores = max(1, (int)($cpu['cores'] ?? 1)); + $load1 = (float)($load['1min'] ?? 0); + $load5 = (float)($load['5min'] ?? 0); + $load15 = (float)($load['15min'] ?? 0); + $ioWait = (float)($cpu['iowait'] ?? 0); $memUsage = $memory['usage'] ?? 0; // Memory values are in MB from agent $memUsedGB = ($memory['used'] ?? 0) / 1024; $memTotalGB = ($memory['total'] ?? 0) / 1024; + $swapUsedGB = ($memory['swap_used'] ?? 0) / 1024; + $swapTotalGB = ($memory['swap_total'] ?? 0) / 1024; + $hasSwap = $swapTotalGB > 0; - $partition = $partitions[0] ?? null; - $diskUsage = $partition ? round($partition['usage_percent'] ?? 0, 1) : 0; - $mountPoint = $partition['mount'] ?? '/'; - $usedHuman = $partition['used_human'] ?? '0 B'; - $totalHuman = $partition['total_human'] ?? '0 B'; + $diskMounts = collect($partitions) + ->map(fn (array $partition) => $partition['mount'] ?? $partition['filesystem'] ?? null) + ->filter() + ->unique() + ->values() + ->all(); + $diskTotals = []; + $diskUsedTotal = 0; + $diskTotal = 0; + foreach ($partitions as $partition) { + $mount = $partition['mount'] ?? $partition['filesystem'] ?? null; + if ($mount === null) { + continue; + } + $used = (float) ($partition['used'] ?? 0); + $total = (float) ($partition['total'] ?? 0); + $diskTotals[$mount] = $total > 0 ? round($total / 1024 / 1024 / 1024, 2) : 0; + $diskUsedTotal += $used; + $diskTotal += $total; + } + $diskUsedTotalGB = $diskUsedTotal > 0 ? $diskUsedTotal / 1024 / 1024 / 1024 : 0; + $diskTotalGB = $diskTotal > 0 ? $diskTotal / 1024 / 1024 / 1024 : 0; + $diskUsedSeries = []; + $diskFreeSeries = []; + foreach ($diskMounts as $mount) { + $partition = collect($partitions)->first(fn (array $row) => ($row['mount'] ?? $row['filesystem'] ?? null) === $mount); + $used = $partition ? ((float) ($partition['used'] ?? 0)) / 1024 / 1024 / 1024 : 0; + $total = $partition ? ((float) ($partition['total'] ?? 0)) / 1024 / 1024 / 1024 : 0; + $diskUsedSeries[] = round($used, 2); + $diskFreeSeries[] = round(max(0, $total - $used), 2); + } @endphp +
+ + {{ __('Last 5 minutes') }} + + + {{ __('Last 30 minutes') }} + + + {{ __('Last day') }} + + + {{ __('Last week') }} + + + {{ __('Last month') }} + +
+ @if(!empty($data['warning'])) +
+ {{ __('Agent unavailable, showing local metrics.') }} +
+ @endif + {{-- Main Charts Grid (3 columns) --}}
{{-- CPU Usage Chart --}} @@ -87,118 +226,282 @@
- - {{ __('CPU Usage') }} + + {{ __('Load') }}
-
{{ $cpu['cores'] ?? 0 }} {{ __('cores') }} · {{ __('Load') }}: {{ $load['1min'] ?? 0 }} / {{ $load['5min'] ?? 0 }} / {{ $load['15min'] ?? 0 }}
+
{{ $cpuCores }} {{ __('cores') }} · {{ __('Load') }}: {{ $load1 }} / {{ $load5 }} / {{ $load15 }}
{{-- Memory Usage Chart --}} @@ -221,118 +524,267 @@
{{ __('Memory') }}
-
{{ number_format($memUsedGB, 1) }} GB {{ __('used') }} / {{ number_format($memTotalGB, 1) }} GB {{ __('total') }}
+
+ {{ number_format($memUsedGB, 1) }} GB {{ __('used') }} / {{ number_format($memTotalGB, 1) }} GB {{ __('total') }} + @if($hasSwap) + · {{ __('Swap') }} {{ number_format($swapUsedGB, 1) }} GB / {{ number_format($swapTotalGB, 1) }} GB + @endif +
{{-- Disk Usage Chart --}} - @if($partition) + @if(!empty($diskMounts))
- {{ __('Disk') }} {{ $mountPoint }} + {{ __('Disk Usage') }}
-
{{ $usedHuman }} {{ __('used') }} / {{ $totalHuman }} {{ __('total') }}
+
{{ number_format($diskUsedTotalGB, 1) }} GB {{ __('used') }} / {{ number_format($diskTotalGB, 1) }} GB {{ __('total') }}
@endif diff --git a/routes/console.php b/routes/console.php index 8c031bb..7d44b51 100644 --- a/routes/console.php +++ b/routes/console.php @@ -59,6 +59,13 @@ Schedule::command('jabali:run-cron-jobs') ->runInBackground() ->appendOutputTo(storage_path('logs/user-cron-jobs.log')); +// Server Metrics - runs every minute to build historical charts +Schedule::command('server-metrics:collect') + ->everyMinute() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/server-metrics.log')); + // Mailbox Quota Sync - runs every 15 minutes to update mailbox usage from disk Schedule::command('jabali:sync-mailbox-quotas') ->everyFifteenMinutes() diff --git a/tests/Unit/ServerChartsWidgetTest.php b/tests/Unit/ServerChartsWidgetTest.php new file mode 100644 index 0000000..cfb75b1 --- /dev/null +++ b/tests/Unit/ServerChartsWidgetTest.php @@ -0,0 +1,32 @@ +getHistory(1.25, 2.5, 42.5, 12.3, [ + ['mount' => '/', 'used' => 1024 * 1024 * 1024], + ['mount' => '/boot', 'used' => 512 * 1024 * 1024], + ]); + + $this->assertArrayHasKey('swap', $history); + $this->assertArrayHasKey('iowait', $history); + $this->assertArrayHasKey('disk', $history); + $this->assertArrayHasKey('/', $history['disk']); + $this->assertArrayHasKey('/boot', $history['disk']); + $this->assertCount(count($history['labels']), $history['swap']); + $this->assertCount(count($history['labels']), $history['iowait']); + } +}