From 6ed9a521131b4253f9455f2722dc319c3f09db57 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 29 Jan 2026 22:57:37 +0200 Subject: [PATCH] Use sysstat for server status charts --- AGENT.md | 4 +- README.md | 3 +- VERSION | 2 +- app/Console/Commands/CollectServerMetrics.php | 152 ------- .../Resources/Users/Schemas/UserForm.php | 9 +- .../Admin/Widgets/ServerChartsWidget.php | 394 ++++++------------ app/Filament/Jabali/Pages/Files.php | 86 +++- app/Models/ServerMetric.php | 26 -- app/Services/Agent/AgentClient.php | 10 - app/Services/SysstatMetrics.php | 307 ++++++++++++++ bin/jabali-agent | 39 -- ..._28_185724_create_server_metrics_table.php | 35 -- ...0001_add_label_to_server_metrics_table.php | 26 -- install.sh | 66 ++- install_from_gitea.sh | 66 ++- .../admin/widgets/server-charts.blade.php | 57 ++- routes/console.php | 7 - tests/Unit/ServerChartsWidgetTest.php | 2 + 18 files changed, 707 insertions(+), 584 deletions(-) delete mode 100644 app/Console/Commands/CollectServerMetrics.php delete mode 100644 app/Models/ServerMetric.php create mode 100644 app/Services/SysstatMetrics.php delete mode 100644 database/migrations/2026_01_28_185724_create_server_metrics_table.php delete mode 100644 database/migrations/2026_01_28_220001_add_label_to_server_metrics_table.php diff --git a/AGENT.md b/AGENT.md index 7c0d04a..d6430c9 100644 --- a/AGENT.md +++ b/AGENT.md @@ -323,7 +323,7 @@ dns.get_ds_records - Get DS records for registrar ### Test Credentials | Panel | URL | Email | Password | |-------|-----|-------|----------| -| Admin | `https://jabali.lan/jabali-admin` | `admin@jabali.lan` | `123123123` | +| Admin | `https://jabali.lan/jabali-admin` | `admin@jabali.lan` | `q1w2E#R$` | | User | `https://jabali.lan/jabali-panel` | `user@jabali.lan` | `wjqr9t6Z#%r&@C$4` | ## Models @@ -1385,7 +1385,7 @@ const puppeteer = require('puppeteer'); await page.goto('https://jabali.lan/jabali-admin/login', { waitUntil: 'networkidle0' }); await new Promise(r => setTimeout(r, 1000)); await page.type('input[type="email"]', 'admin@jabali.lan'); - await page.type('input[type="password"]', '123123123'); + await page.type('input[type="password"]', 'q1w2E#R$'); await page.click('button[type="submit"]'); await new Promise(r => setTimeout(r, 5000)); diff --git a/README.md b/README.md index 83cbeb8..6128b51 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-rc34 (release candidate) +Version: 0.9-rc35 (release candidate) This is a release candidate. Expect rapid iteration and breaking changes until 1.0. @@ -156,6 +156,7 @@ php artisan test --compact ## Initial Release +- 0.9-rc35: Server Status charts now read sysstat history; installer enables sysstat collection; legacy server_metrics storage removed. - 0.9-rc34: User deletion summary steps; notification re-dispatch on repeated actions; ModSecurity packages added to installer. - 0.9-rc33: Email Logs unified with Mail Queue; journald fallback; agent response reading hardened. - 0.9-rc32: Server Updates list loads reliably; admin sidebar order aligned; apt update parsing expanded. diff --git a/VERSION b/VERSION index 3a9c5c5..36fd23f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc34 +VERSION=0.9-rc35 diff --git a/app/Console/Commands/CollectServerMetrics.php b/app/Console/Commands/CollectServerMetrics.php deleted file mode 100644 index 1d1a178..0000000 --- a/app/Console/Commands/CollectServerMetrics.php +++ /dev/null @@ -1,152 +0,0 @@ -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/Resources/Users/Schemas/UserForm.php b/app/Filament/Admin/Resources/Users/Schemas/UserForm.php index f6b3e77..96a48b8 100644 --- a/app/Filament/Admin/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Admin/Resources/Users/Schemas/UserForm.php @@ -6,6 +6,7 @@ use App\Models\HostingPackage; use Filament\Actions\Action; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Section; @@ -127,16 +128,18 @@ class UserForm ->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') + Select::make('hosting_package_id') ->label(__('Hosting Package')) ->searchable() ->preload() - ->options(fn () => HostingPackage::query() + ->options(fn () => ['' => __('No package (Unlimited)')] + HostingPackage::query() ->where('is_active', true) ->orderBy('name') ->pluck('name', 'id') ->toArray()) - ->placeholder(__('Unlimited (no package)')) + ->default('') + ->afterStateHydrated(fn ($state, $set) => $set('hosting_package_id', $state ?? '')) + ->dehydrateStateUsing(fn ($state) => filled($state) ? (int) $state : null) ->helperText(__('Assign a package to set quotas.')) ->columnSpanFull(), diff --git a/app/Filament/Admin/Widgets/ServerChartsWidget.php b/app/Filament/Admin/Widgets/ServerChartsWidget.php index 22b2613..dffae96 100644 --- a/app/Filament/Admin/Widgets/ServerChartsWidget.php +++ b/app/Filament/Admin/Widgets/ServerChartsWidget.php @@ -4,21 +4,16 @@ declare(strict_types=1); namespace App\Filament\Admin\Widgets; -use App\Models\ServerMetric; -use App\Services\Agent\AgentClient; +use App\Services\SysstatMetrics; 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 ?string $pollingInterval = '10s'; + protected ?string $pollingInterval = '60s'; public int $refreshKey = 0; @@ -38,24 +33,8 @@ class ServerChartsWidget extends Widget { $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, @@ -64,7 +43,6 @@ class ServerChartsWidget extends Widget 'memory' => $data['memory']['usage'] ?? 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'], @@ -73,56 +51,51 @@ class ServerChartsWidget extends Widget public function getData(): array { + $sysstat = new SysstatMetrics; + $latest = $sysstat->latest(); + try { - $agent = new AgentClient; - $overview = $agent->metricsOverview(); $disk = $this->diskSnapshot; if (empty($disk)) { - $disk = $this->fetchDiskSnapshot($agent); + $disk = $this->fetchDiskSnapshot(); $this->diskSnapshot = $disk; } - $cpu = $overview['cpu'] ?? []; - $memory = $overview['memory'] ?? []; - $swap = $memory['swap'] ?? []; + $cpu = $this->getLocalCpuInfo(); + $memory = $this->readMeminfo(); - $memUsage = $memory['usage_percent'] ?? $memory['usage'] ?? 0; - if ($memUsage == 0 && ($memory['total'] ?? 0) > 0) { - $memUsage = (($memory['used'] ?? 0) / $memory['total']) * 100; + if ($latest !== null) { + $memory['usage'] = round((float) $latest['memory'], 1); + $memory['swap_usage'] = round((float) $latest['swap'], 1); } - $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), + 'usage' => 0, + 'iowait' => round((float) ($latest['iowait'] ?? 0), 1), 'cores' => $cpu['cores'] ?? 0, 'model' => $cpu['model'] ?? 'Unknown', ], 'memory' => [ - 'usage' => round($memUsage, 1), + 'usage' => $memory['usage'] ?? 0, 'used' => $memory['used'] ?? 0, '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), + 'swap_used' => $memory['swap_used'] ?? 0, + 'swap_total' => $memory['swap_total'] ?? 0, + 'swap_usage' => $memory['swap_usage'] ?? 0, ], 'disk' => [ 'partitions' => $disk['partitions'] ?? [], ], - 'load' => $overview['load'] ?? [], - 'uptime' => $overview['uptime']['human'] ?? 'N/A', - 'history' => $history, - 'source' => 'agent', + 'load' => [ + '1min' => $latest['load1'] ?? 0, + '5min' => $latest['load5'] ?? 0, + '15min' => $latest['load15'] ?? 0, + ], + 'uptime' => $this->getLocalUptime(), + 'source' => $latest !== null ? 'sysstat' : 'local', ]; } catch (\Exception $e) { $fallback = $this->getLocalMetrics(); @@ -130,13 +103,6 @@ class ServerChartsWidget extends Widget 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' => [ @@ -149,7 +115,6 @@ class ServerChartsWidget extends Widget 'disk' => $fallback['disk'], 'load' => $fallback['load'], 'uptime' => $fallback['uptime'] ?? 'N/A', - 'history' => $history, 'source' => 'local', 'warning' => $e->getMessage(), ]; @@ -162,7 +127,6 @@ class ServerChartsWidget extends Widget 'disk' => $this->diskSnapshot, 'load' => [], 'uptime' => 'N/A', - 'history' => $this->getHistory(0.0, 0.0, 0.0, 0.0, []), 'source' => 'error', ]; } @@ -173,7 +137,14 @@ class ServerChartsWidget extends Widget $this->range = $this->normalizeRange($range); $config = $this->rangeConfig($this->range); - $history = $this->getHistory(); + $data = $this->getData(); + $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-range-changed', [ 'history' => $history, 'history_points' => $config['points'], @@ -190,129 +161,37 @@ class ServerChartsWidget extends Widget 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']); + $sysstat = new SysstatMetrics; + $history = $sysstat->history($config['points'], $config['interval_seconds'], $config['label_format']); - $records = ServerMetric::query() - ->where('resolution', $config['resolution']) - ->where('captured_at', '>=', $since) - ->orderBy('captured_at') - ->get(); + if (! empty($history)) { + $history['disk'] = $this->seedDiskHistory($config['points'], $diskPartitions); - if ($records->isEmpty()) { - return $this->seedHistory( - $config['points'], - $config['label_format'], - $config['interval_seconds'], - $load, - $iowait, - $memory, - $swap, - $diskPartitions, - ); + return $history; } - $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, - ]; + return $this->seedHistory( + $config['points'], + $config['label_format'], + $config['interval_seconds'], + $load, + $iowait, + $memory, + $swap, + $diskPartitions, + ); } 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], + '5m' => ['minutes' => 5, 'points' => 5, 'resolution' => '1m', 'label_format' => 'H:i', 'interval_seconds' => 60], + '30m' => ['minutes' => 30, 'points' => 30, 'resolution' => '1m', 'label_format' => 'H:i', 'interval_seconds' => 60], '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], + default => ['minutes' => 30, 'points' => 30, 'resolution' => '1m', 'label_format' => 'H:i', 'interval_seconds' => 60], }; } @@ -371,74 +250,6 @@ class ServerChartsWidget extends Widget ]; } - 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(); @@ -452,16 +263,7 @@ class ServerChartsWidget extends Widget '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; + $cpuInfo = $this->getLocalCpuInfo(); $memory = $this->readMeminfo(); $disk = $this->readDiskUsage(); @@ -470,28 +272,96 @@ class ServerChartsWidget extends Widget 'cpu' => [ 'usage' => 0, 'iowait' => 0, - 'cores' => $cpuCores, - 'model' => 'Local', + 'cores' => $cpuInfo['cores'], + 'model' => $cpuInfo['model'], ], 'memory' => $memory, 'disk' => $disk, 'load' => $loadData, + 'uptime' => $this->getLocalUptime(), ]; } - private function fetchDiskSnapshot(?AgentClient $agent = null): array + private function fetchDiskSnapshot(): array { - try { - $client = $agent ?? new AgentClient; - $disk = $client->metricsDisk()['data'] ?? []; - if (! empty($disk)) { - return $disk; + return $this->readDiskUsage(); + } + + /** + * @return array{cores: int, model: string} + */ + private function getLocalCpuInfo(): array + { + $cpuCores = 0; + $cpuModel = 'Unknown'; + + if (is_readable('/proc/cpuinfo')) { + $lines = file('/proc/cpuinfo', FILE_IGNORE_NEW_LINES); + foreach ($lines as $line) { + if (str_starts_with($line, 'processor')) { + $cpuCores++; + } + if (str_starts_with($line, 'model name')) { + $cpuModel = trim(explode(':', $line, 2)[1] ?? $cpuModel); + } } - } catch (\Exception) { - // Ignore and fall back to local disk stats below. } - return $this->readDiskUsage(); + return [ + 'cores' => $cpuCores > 0 ? $cpuCores : 1, + 'model' => $cpuModel, + ]; + } + + private function getLocalUptime(): string + { + if (! is_readable('/proc/uptime')) { + return 'N/A'; + } + + $contents = trim((string) file_get_contents('/proc/uptime')); + $parts = preg_split('/\s+/', $contents); + $seconds = isset($parts[0]) ? (int) floor((float) $parts[0]) : 0; + + if ($seconds <= 0) { + return 'N/A'; + } + + $days = intdiv($seconds, 86400); + $hours = intdiv($seconds % 86400, 3600); + $minutes = intdiv($seconds % 3600, 60); + + if ($days > 0) { + return sprintf('%dd %dh %dm', $days, $hours, $minutes); + } + + if ($hours > 0) { + return sprintf('%dh %dm', $hours, $minutes); + } + + return sprintf('%dm', $minutes); + } + + /** + * @param array> $diskPartitions + * @return array> + */ + private function seedDiskHistory(int $points, array $diskPartitions): array + { + $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] = array_fill(0, $points, round($value, 2)); + } + + return $diskSeries; } private function readMeminfo(): array @@ -551,10 +421,10 @@ class ServerChartsWidget extends Widget ]; } - private function formatBytes(int $bytes): string + private function formatBytes(int|float $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $size = max(0, $bytes); + $size = max(0, (float) $bytes); $unit = 0; while ($size >= 1024 && $unit < count($units) - 1) { $size /= 1024; diff --git a/app/Filament/Jabali/Pages/Files.php b/app/Filament/Jabali/Pages/Files.php index 56725ad..3b7850b 100644 --- a/app/Filament/Jabali/Pages/Files.php +++ b/app/Filament/Jabali/Pages/Files.php @@ -28,7 +28,9 @@ use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Model; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; use Livewire\WithFileUploads; class Files extends Page implements HasActions, HasForms, HasTable @@ -281,14 +283,20 @@ class Files extends Page implements HasActions, HasForms, HasTable public function table(Table $table): Table { return $table - ->records(function () { - return collect($this->items) + ->paginated([100, 250, 500]) + ->defaultPaginationPageOption(100) + ->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) { + $records = collect($this->items) ->mapWithKeys(function (array $item, int $index): array { $key = $item['path'] ?? $item['name'] ?? (string) $index; return [$key => $item]; }) ->all(); + $records = $this->filterRecords($records, $search); + $records = $this->sortRecords($records, $sortColumn, $sortDirection); + + return $this->paginateRecords($records, $page, $recordsPerPage); }) ->columns([ TextColumn::make('name') @@ -661,6 +669,80 @@ class Files extends Page implements HasActions, HasForms, HasTable return is_array($record) ? $record['path'] : $record->getKey(); } + protected function filterRecords(array $records, ?string $search): array + { + if (! $search) { + return $records; + } + + $needle = Str::lower($search); + + return array_filter($records, function (array $record) use ($needle): bool { + $name = (string) ($record['name'] ?? ''); + + return Str::contains(Str::lower($name), $needle); + }); + } + + protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array + { + if (! $sortColumn) { + return $records; + } + + $parent = null; + $parentKey = null; + foreach ($records as $key => $record) { + if (($record['is_parent'] ?? false) === true) { + $parent = $record; + $parentKey = $key; + unset($records[$key]); + break; + } + } + + $direction = $sortDirection === 'asc' ? 'asc' : 'desc'; + + uasort($records, function (array $a, array $b) use ($sortColumn, $direction): int { + $aValue = $a[$sortColumn] ?? null; + $bValue = $b[$sortColumn] ?? null; + + if (is_numeric($aValue) && is_numeric($bValue)) { + $result = (float) $aValue <=> (float) $bValue; + } else { + $result = strcmp((string) $aValue, (string) $bValue); + } + + return $direction === 'asc' ? $result : -$result; + }); + + if ($parent !== null && $parentKey !== null) { + $records = [$parentKey => $parent] + $records; + } + + return $records; + } + + protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator + { + $page = max(1, (int) $page); + $perPage = max(1, (int) $recordsPerPage); + + $total = count($records); + $items = array_slice($records, ($page - 1) * $perPage, $perPage, true); + + return new LengthAwarePaginator( + $items, + $total, + $perPage, + $page, + [ + 'path' => request()->url(), + 'pageName' => $this->getTablePaginationPageName(), + ], + ); + } + // Drag and drop operations public function moveItem(string $sourcePath, string $destPath): void { diff --git a/app/Models/ServerMetric.php b/app/Models/ServerMetric.php deleted file mode 100644 index f587f7c..0000000 --- a/app/Models/ServerMetric.php +++ /dev/null @@ -1,26 +0,0 @@ - 'float', - 'captured_at' => 'datetime', - ]; - } -} diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index a22b098..7eb3315 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -1135,16 +1135,6 @@ class AgentClient ]); } - /** - * Get metrics history. - */ - public function metricsHistory(int $points = 60): array - { - return $this->send('metrics.history', [ - 'points' => $points, - ]); - } - // ============ DISK QUOTA OPERATIONS ============ /** diff --git a/app/Services/SysstatMetrics.php b/app/Services/SysstatMetrics.php new file mode 100644 index 0000000..8ecfb0f --- /dev/null +++ b/app/Services/SysstatMetrics.php @@ -0,0 +1,307 @@ +, load: array, iowait: array, memory: array, swap: array} + */ + public function history(int $points, int $intervalSeconds, string $labelFormat): array + { + if ($points <= 0 || $intervalSeconds <= 0) { + return []; + } + + $end = CarbonImmutable::now()->second(0); + if ($intervalSeconds >= 3600) { + $end = $end->minute(0); + } + if ($intervalSeconds >= 86400) { + $end = $end->hour(0)->minute(0); + } + $start = $end->subSeconds(($points - 1) * $intervalSeconds); + $samples = $this->readSamples($start, $end); + + if (empty($samples)) { + return []; + } + + return $this->resample($samples, $start, $points, $intervalSeconds, $labelFormat); + } + + /** + * @return array{load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}|null + */ + public function latest(): ?array + { + $end = CarbonImmutable::now(); + $start = $end->subMinutes(15); + $samples = $this->readSamples($start, $end); + + if (empty($samples)) { + return null; + } + + $last = end($samples); + if (! is_array($last)) { + return null; + } + + return [ + 'load1' => (float) ($last['load1'] ?? 0), + 'load5' => (float) ($last['load5'] ?? 0), + 'load15' => (float) ($last['load15'] ?? 0), + 'iowait' => (float) ($last['iowait'] ?? 0), + 'memory' => (float) ($last['memory'] ?? 0), + 'swap' => (float) ($last['swap'] ?? 0), + ]; + } + + /** + * @return array + */ + private function readSamples(CarbonImmutable $start, CarbonImmutable $end): array + { + $samples = []; + $current = $start->startOfDay(); + $lastDay = $end->startOfDay(); + + while ($current <= $lastDay) { + $file = sprintf('/var/log/sysstat/sa%s', $current->format('d')); + if (! is_readable($file)) { + $current = $current->addDay(); + + continue; + } + + $dayStart = $current->isSameDay($start) ? $start : $current->startOfDay(); + $dayEnd = $current->isSameDay($end) ? $end : $current->endOfDay(); + $statistics = $this->readSadf($file, $dayStart, $dayEnd); + + foreach ($statistics as $stat) { + $parsed = $this->parseSample($stat); + if ($parsed === null) { + continue; + } + $samples[] = $parsed; + } + + $current = $current->addDay(); + } + + usort($samples, static fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']); + + return $samples; + } + + /** + * @return array> + */ + private function readSadf(string $file, CarbonImmutable $start, CarbonImmutable $end): array + { + $process = new Process([ + 'sadf', + '-j', + '-f', + $file, + '--', + '-A', + '-s', + $start->format('H:i:s'), + '-e', + $end->format('H:i:s'), + ]); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + $payload = json_decode($process->getOutput(), true); + if (! is_array($payload)) { + return []; + } + + $stats = $payload['sysstat']['hosts'][0]['statistics'] ?? []; + if (! is_array($stats)) { + return []; + } + + return $stats; + } + + /** + * @param array $stat + * @return array{timestamp: int, load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}|null + */ + private function parseSample(array $stat): ?array + { + $timestamp = $this->parseTimestamp($stat['timestamp'] ?? []); + if ($timestamp === null) { + return null; + } + + $queue = $stat['queue'] ?? []; + $load1 = $this->getFloat($queue, ['ldavg-1', 'ldavg_1']) ?? 0.0; + $load5 = $this->getFloat($queue, ['ldavg-5', 'ldavg_5']) ?? 0.0; + $load15 = $this->getFloat($queue, ['ldavg-15', 'ldavg_15']) ?? 0.0; + + $cpuLoad = $stat['cpu-load'] ?? $stat['cpu-load-all'] ?? []; + $iowait = $this->extractCpuMetric($cpuLoad, 'iowait'); + + $memory = $stat['memory'] ?? []; + $memPercent = $this->getFloat($memory, ['memused-percent', 'memused_percent', 'memused']); + if ($memPercent === null) { + $memPercent = $this->percentFromTotals($memory, 'kbmemused', 'kbmemfree', 'kbmemtotal'); + } + + $swap = $stat['swap'] ?? $stat['memory'] ?? []; + $swapPercent = $this->getFloat($swap, ['swpused-percent', 'swpused_percent', 'swpused']); + if ($swapPercent === null) { + $swapPercent = $this->percentFromTotals($swap, 'kbswpused', 'kbswpfree', 'kbswptotal'); + } + + return [ + 'timestamp' => $timestamp->getTimestamp(), + 'load1' => (float) $load1, + 'load5' => (float) $load5, + 'load15' => (float) $load15, + 'iowait' => (float) ($iowait ?? 0.0), + 'memory' => (float) ($memPercent ?? 0.0), + 'swap' => (float) ($swapPercent ?? 0.0), + ]; + } + + /** + * @return array{labels: array, load: array, iowait: array, memory: array, swap: array} + */ + private function resample(array $samples, CarbonImmutable $start, int $points, int $intervalSeconds, string $labelFormat): array + { + $labels = []; + $loadSeries = []; + $ioWaitSeries = []; + $memorySeries = []; + $swapSeries = []; + + $index = 0; + $current = null; + $count = count($samples); + + for ($i = 0; $i < $points; $i++) { + $bucketTime = $start->addSeconds($i * $intervalSeconds); + while ($index < $count && $samples[$index]['timestamp'] <= $bucketTime->getTimestamp()) { + $current = $samples[$index]; + $index++; + } + + $labels[] = $bucketTime->format($labelFormat); + $loadSeries[] = $current ? round((float) $current['load1'], 3) : 0.0; + $ioWaitSeries[] = $current ? round((float) $current['iowait'], 2) : 0.0; + $memorySeries[] = $current ? round((float) $current['memory'], 1) : 0.0; + $swapSeries[] = $current ? round((float) $current['swap'], 1) : 0.0; + } + + return [ + 'labels' => $labels, + 'load' => $loadSeries, + 'iowait' => $ioWaitSeries, + 'memory' => $memorySeries, + 'swap' => $swapSeries, + ]; + } + + /** + * @param array $timestamp + */ + private function parseTimestamp(array $timestamp): ?CarbonImmutable + { + $date = $timestamp['date'] ?? null; + $time = $timestamp['time'] ?? null; + if (! is_string($date) || ! is_string($time)) { + return null; + } + + $value = CarbonImmutable::createFromFormat('Y-m-d H:i:s', $date.' '.$time, config('app.timezone')); + if ($value === false) { + return null; + } + + return $value; + } + + /** + * @param array $source + */ + private function getFloat(array $source, array $keys): ?float + { + foreach ($keys as $key) { + if (array_key_exists($key, $source) && is_numeric($source[$key])) { + return (float) $source[$key]; + } + } + + return null; + } + + /** + * @param array $source + */ + private function extractCpuMetric(mixed $source, string $metric): ?float + { + if (is_array($source) && array_key_exists($metric, $source) && is_numeric($source[$metric])) { + return (float) $source[$metric]; + } + + if (is_array($source)) { + $entries = $source; + if (array_values($entries) === $entries) { + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + $cpu = $entry['cpu'] ?? $entry['cpu-load'] ?? null; + if ($cpu === 'all' || $cpu === 0 || $cpu === '0') { + if (isset($entry[$metric]) && is_numeric($entry[$metric])) { + return (float) $entry[$metric]; + } + } + } + foreach ($entries as $entry) { + if (is_array($entry) && isset($entry[$metric]) && is_numeric($entry[$metric])) { + return (float) $entry[$metric]; + } + } + } + } + + return null; + } + + /** + * @param array $source + */ + private function percentFromTotals(array $source, string $usedKey, string $freeKey, string $totalKey): ?float + { + $used = $source[$usedKey] ?? null; + $free = $source[$freeKey] ?? null; + $total = $source[$totalKey] ?? null; + + if (is_numeric($total) && (float) $total > 0) { + $usedValue = is_numeric($used) ? (float) $used : null; + if ($usedValue === null && is_numeric($free)) { + $usedValue = (float) $total - (float) $free; + } + if ($usedValue !== null) { + return round(($usedValue / (float) $total) * 100, 2); + } + } + + return null; + } +} diff --git a/bin/jabali-agent b/bin/jabali-agent index d1a4a2e..9c90e73 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -691,7 +691,6 @@ function handleAction(array $request): array 'metrics.disk' => metricsDisk($params), 'metrics.network' => metricsNetwork($params), 'metrics.processes' => metricsProcesses($params), - 'metrics.history' => metricsHistory($params), 'system.kill_process' => systemKillProcess($params), // Disk quota operations 'quota.status' => quotaStatus($params), @@ -19368,44 +19367,6 @@ function systemKillProcess(array $params): array return ['success' => false, 'error' => "Failed to kill process: $error"]; } -function metricsHistory(array $params): array -{ - // This would read from a stored history file if we implement metric collection - // For now, return current snapshot - $points = $params['points'] ?? 60; - - $history = [ - 'cpu' => [], - 'memory' => [], - 'load' => [], - ]; - - // Generate current point - $cpu = metricsCpu([]); - $memory = metricsMemory([]); - $load = getLoadAverage(); - - $history['cpu'][] = [ - 'timestamp' => time(), - 'value' => $cpu['data']['usage'] ?? 0, - ]; - - $history['memory'][] = [ - 'timestamp' => time(), - 'value' => $memory['data']['usage_percent'] ?? 0, - ]; - - $history['load'][] = [ - 'timestamp' => time(), - 'value' => $load['1min'] ?? 0, - ]; - - return [ - 'success' => true, - 'data' => $history, - ]; -} - function getUptime(): array { $uptime = (float) trim(file_get_contents('/proc/uptime') ?? '0'); 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 deleted file mode 100644 index c35647b..0000000 --- a/database/migrations/2026_01_28_185724_create_server_metrics_table.php +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 6c21ff1..0000000 --- a/database/migrations/2026_01_28_220001_add_label_to_server_metrics_table.php +++ /dev/null @@ -1,26 +0,0 @@ -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 6c1742b..9d5dc70 100755 --- a/install.sh +++ b/install.sh @@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ -f "$SCRIPT_DIR/VERSION" ]]; then JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")" fi -JABALI_VERSION="${JABALI_VERSION:-0.9-rc34}" +JABALI_VERSION="${JABALI_VERSION:-0.9-rc35}" # Colors RED='\033[0;31m' @@ -440,6 +440,9 @@ install_packages() { # Log analysis goaccess + + # System metrics + sysstat ) # Add Mail Server packages if enabled @@ -1008,6 +1011,66 @@ configure_php() { fi } +configure_sysstat() { + if ! command -v sar >/dev/null 2>&1; then + warn "sysstat not installed, skipping sysstat configuration" + return + fi + + info "Configuring sysstat..." + + if [[ -f /etc/default/sysstat ]]; then + if grep -q '^ENABLED=' /etc/default/sysstat; then + sed -i 's/^ENABLED=.*/ENABLED=\"true\"/' /etc/default/sysstat + else + echo 'ENABLED="true"' >> /etc/default/sysstat + fi + + if grep -q '^INTERVAL=' /etc/default/sysstat; then + sed -i 's/^INTERVAL=.*/INTERVAL=1/' /etc/default/sysstat + else + echo 'INTERVAL=1' >> /etc/default/sysstat + fi + fi + + if [[ -f /etc/sysstat/sysstat ]]; then + if grep -q '^ENABLED=' /etc/sysstat/sysstat; then + sed -i 's/^ENABLED=.*/ENABLED=\"true\"/' /etc/sysstat/sysstat + else + echo 'ENABLED="true"' >> /etc/sysstat/sysstat + fi + + if grep -q '^INTERVAL=' /etc/sysstat/sysstat; then + sed -i 's/^INTERVAL=.*/INTERVAL=1/' /etc/sysstat/sysstat + else + echo 'INTERVAL=1' >> /etc/sysstat/sysstat + fi + fi + + if systemctl list-unit-files | grep -q '^sysstat-collect.timer'; then + mkdir -p /etc/systemd/system/sysstat-collect.timer.d + cat > /etc/systemd/system/sysstat-collect.timer.d/override.conf <<'EOF' +[Timer] +OnCalendar= +OnUnitActiveSec=1min +AccuracySec=1s +EOF + fi + + mkdir -p /var/log/sysstat + chmod 0755 /var/log/sysstat + + systemctl daemon-reload 2>/dev/null || true + systemctl enable --now sysstat-collect.timer sysstat-summary.timer sysstat 2>/dev/null || true + systemctl restart sysstat-collect.timer sysstat-summary.timer sysstat 2>/dev/null || true + + if [[ -x /usr/lib/sysstat/sadc ]]; then + /usr/lib/sysstat/sadc 1 1 /var/log/sysstat/sa"$(date +%d)" >/dev/null 2>&1 || true + elif command -v sadc >/dev/null 2>&1; then + sadc 1 1 /var/log/sysstat/sa"$(date +%d)" >/dev/null 2>&1 || true + fi +} + # Configure MariaDB configure_mariadb() { header "Configuring MariaDB" @@ -3428,6 +3491,7 @@ main() { add_repositories install_packages + configure_sysstat install_geoipupdate_binary install_composer clone_jabali diff --git a/install_from_gitea.sh b/install_from_gitea.sh index b590474..0559bc5 100755 --- a/install_from_gitea.sh +++ b/install_from_gitea.sh @@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ -f "$SCRIPT_DIR/VERSION" ]]; then JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")" fi -JABALI_VERSION="${JABALI_VERSION:-0.9-rc26}" +JABALI_VERSION="${JABALI_VERSION:-0.9-rc35}" # Colors RED='\033[0;31m' @@ -440,6 +440,9 @@ install_packages() { # Log analysis goaccess + + # System metrics + sysstat ) # Add Mail Server packages if enabled @@ -976,6 +979,66 @@ configure_php() { fi } +configure_sysstat() { + if ! command -v sar >/dev/null 2>&1; then + warn "sysstat not installed, skipping sysstat configuration" + return + fi + + info "Configuring sysstat..." + + if [[ -f /etc/default/sysstat ]]; then + if grep -q '^ENABLED=' /etc/default/sysstat; then + sed -i 's/^ENABLED=.*/ENABLED=\"true\"/' /etc/default/sysstat + else + echo 'ENABLED="true"' >> /etc/default/sysstat + fi + + if grep -q '^INTERVAL=' /etc/default/sysstat; then + sed -i 's/^INTERVAL=.*/INTERVAL=1/' /etc/default/sysstat + else + echo 'INTERVAL=1' >> /etc/default/sysstat + fi + fi + + if [[ -f /etc/sysstat/sysstat ]]; then + if grep -q '^ENABLED=' /etc/sysstat/sysstat; then + sed -i 's/^ENABLED=.*/ENABLED=\"true\"/' /etc/sysstat/sysstat + else + echo 'ENABLED="true"' >> /etc/sysstat/sysstat + fi + + if grep -q '^INTERVAL=' /etc/sysstat/sysstat; then + sed -i 's/^INTERVAL=.*/INTERVAL=1/' /etc/sysstat/sysstat + else + echo 'INTERVAL=1' >> /etc/sysstat/sysstat + fi + fi + + if systemctl list-unit-files | grep -q '^sysstat-collect.timer'; then + mkdir -p /etc/systemd/system/sysstat-collect.timer.d + cat > /etc/systemd/system/sysstat-collect.timer.d/override.conf <<'EOF' +[Timer] +OnCalendar= +OnUnitActiveSec=1min +AccuracySec=1s +EOF + fi + + mkdir -p /var/log/sysstat + chmod 0755 /var/log/sysstat + + systemctl daemon-reload 2>/dev/null || true + systemctl enable --now sysstat-collect.timer sysstat-summary.timer sysstat 2>/dev/null || true + systemctl restart sysstat-collect.timer sysstat-summary.timer sysstat 2>/dev/null || true + + if [[ -x /usr/lib/sysstat/sadc ]]; then + /usr/lib/sysstat/sadc 1 1 /var/log/sysstat/sa"$(date +%d)" >/dev/null 2>&1 || true + elif command -v sadc >/dev/null 2>&1; then + sadc 1 1 /var/log/sysstat/sa"$(date +%d)" >/dev/null 2>&1 || true + fi +} + # Configure MariaDB configure_mariadb() { header "Configuring MariaDB" @@ -3390,6 +3453,7 @@ main() { add_repositories install_packages + configure_sysstat install_geoipupdate_binary install_composer clone_jabali diff --git a/resources/views/filament/admin/widgets/server-charts.blade.php b/resources/views/filament/admin/widgets/server-charts.blade.php index 58c1aa8..5ea6bd7 100644 --- a/resources/views/filament/admin/widgets/server-charts.blade.php +++ b/resources/views/filament/admin/widgets/server-charts.blade.php @@ -1,4 +1,23 @@ - + @php $data = $this->getData(); $cpu = $data['cpu'] ?? []; @@ -7,36 +26,30 @@ $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, + '5m' => 5, + '30m' => 30, 'day' => 24, 'week' => 28, 'month' => 30, - default => 180, + default => 30, }; $historyIntervalSeconds = match ($range) { - '5m' => 10, - '30m' => 10, + '5m' => 60, + '30m' => 60, 'day' => 3600, 'week' => 21600, 'month' => 86400, - default => 10, + default => 60, }; $historyLabelFormat = match ($range) { - '5m' => 'H:i:s', - '30m' => 'H:i:s', + '5m' => 'H:i', + '30m' => 'H:i', 'day' => 'H:00', 'week' => 'M d H:00', 'month' => 'M d', - default => 'H:i:s', + default => 'H:i', }; $cpuCores = max(1, (int)($cpu['cores'] ?? 1)); @@ -45,6 +58,18 @@ $load15 = (float)($load['15min'] ?? 0); $ioWait = (float)($cpu['iowait'] ?? 0); $memUsage = $memory['usage'] ?? 0; + $history = $this->getHistory( + $load1, + $ioWait, + (float) $memUsage, + (float) ($memory['swap_usage'] ?? 0), + $partitions, + ); + $historyLabels = $history['labels'] ?? []; + $historyLoad = $history['load'] ?? []; + $historyIoWait = $history['iowait'] ?? []; + $historyMemory = $history['memory'] ?? []; + $historySwap = $history['swap'] ?? []; // Memory values are in MB from agent $memUsedGB = ($memory['used'] ?? 0) / 1024; $memTotalGB = ($memory['total'] ?? 0) / 1024; diff --git a/routes/console.php b/routes/console.php index 7d44b51..8c031bb 100644 --- a/routes/console.php +++ b/routes/console.php @@ -59,13 +59,6 @@ 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 index cfb75b1..79f8cd4 100644 --- a/tests/Unit/ServerChartsWidgetTest.php +++ b/tests/Unit/ServerChartsWidgetTest.php @@ -15,6 +15,7 @@ class ServerChartsWidgetTest extends TestCase public function test_history_seeding_includes_swap_and_disk_series(): void { $widget = app(ServerChartsWidget::class); + $widget->range = '5m'; $history = $widget->getHistory(1.25, 2.5, 42.5, 12.3, [ ['mount' => '/', 'used' => 1024 * 1024 * 1024], @@ -26,6 +27,7 @@ class ServerChartsWidgetTest extends TestCase $this->assertArrayHasKey('disk', $history); $this->assertArrayHasKey('/', $history['disk']); $this->assertArrayHasKey('/boot', $history['disk']); + $this->assertCount(5, $history['labels']); $this->assertCount(count($history['labels']), $history['swap']); $this->assertCount(count($history['labels']), $history['iowait']); }