Use sysstat for server status charts

This commit is contained in:
root
2026-01-29 22:57:37 +02:00
parent 12670f3546
commit 6ed9a52113
18 changed files with 707 additions and 584 deletions

View File

@@ -323,7 +323,7 @@ dns.get_ds_records - Get DS records for registrar
### Test Credentials ### Test Credentials
| Panel | URL | Email | Password | | 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` | | User | `https://jabali.lan/jabali-panel` | `user@jabali.lan` | `wjqr9t6Z#%r&@C$4` |
## Models ## Models
@@ -1385,7 +1385,7 @@ const puppeteer = require('puppeteer');
await page.goto('https://jabali.lan/jabali-admin/login', { waitUntil: 'networkidle0' }); await page.goto('https://jabali.lan/jabali-admin/login', { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
await page.type('input[type="email"]', 'admin@jabali.lan'); 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 page.click('button[type="submit"]');
await new Promise(r => setTimeout(r, 5000)); await new Promise(r => setTimeout(r, 5000));

View File

@@ -5,7 +5,7 @@
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
Version: 0.9-rc34 (release candidate) Version: 0.9-rc35 (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0. This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
@@ -156,6 +156,7 @@ php artisan test --compact
## Initial Release ## 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-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-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. - 0.9-rc32: Server Updates list loads reliably; admin sidebar order aligned; apt update parsing expanded.

View File

@@ -1 +1 @@
VERSION=0.9-rc34 VERSION=0.9-rc35

View File

@@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ServerMetric;
use App\Services\Agent\AgentClient;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class CollectServerMetrics extends Command
{
protected $signature = 'server-metrics:collect';
protected $description = 'Collect server metrics for historical charts';
public function handle(): int
{
$agent = new AgentClient;
try {
$overview = $agent->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();
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\HostingPackage;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@@ -127,16 +128,18 @@ class UserForm
->content(__('No hosting package selected. This user will have unlimited quotas.')) ->content(__('No hosting package selected. This user will have unlimited quotas.'))
->visible(fn ($get) => blank($get('hosting_package_id'))), ->visible(fn ($get) => blank($get('hosting_package_id'))),
\Filament\Forms\Components\Select::make('hosting_package_id') Select::make('hosting_package_id')
->label(__('Hosting Package')) ->label(__('Hosting Package'))
->searchable() ->searchable()
->preload() ->preload()
->options(fn () => HostingPackage::query() ->options(fn () => ['' => __('No package (Unlimited)')] + HostingPackage::query()
->where('is_active', true) ->where('is_active', true)
->orderBy('name') ->orderBy('name')
->pluck('name', 'id') ->pluck('name', 'id')
->toArray()) ->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.')) ->helperText(__('Assign a package to set quotas.'))
->columnSpanFull(), ->columnSpanFull(),

View File

@@ -4,21 +4,16 @@ declare(strict_types=1);
namespace App\Filament\Admin\Widgets; namespace App\Filament\Admin\Widgets;
use App\Models\ServerMetric; use App\Services\SysstatMetrics;
use App\Services\Agent\AgentClient;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
class ServerChartsWidget extends Widget class ServerChartsWidget extends Widget
{ {
private const HISTORY_INTERVAL_SECONDS = 10;
protected string $view = 'filament.admin.widgets.server-charts'; protected string $view = 'filament.admin.widgets.server-charts';
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';
protected ?string $pollingInterval = '10s'; protected ?string $pollingInterval = '60s';
public int $refreshKey = 0; public int $refreshKey = 0;
@@ -38,24 +33,8 @@ class ServerChartsWidget extends Widget
{ {
$this->refreshKey++; $this->refreshKey++;
$data = $this->getData(); $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); $config = $this->rangeConfig($this->range);
$label = now()->format($config['label_format']); $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', [ $this->dispatch('server-charts-updated', [
'cpu' => $data['cpu']['usage'] ?? 0, 'cpu' => $data['cpu']['usage'] ?? 0,
@@ -64,7 +43,6 @@ class ServerChartsWidget extends Widget
'memory' => $data['memory']['usage'] ?? 0, 'memory' => $data['memory']['usage'] ?? 0,
'swap' => $data['memory']['swap_usage'] ?? 0, 'swap' => $data['memory']['swap_usage'] ?? 0,
'label' => $label, 'label' => $label,
'history' => $history,
'history_points' => $config['points'], 'history_points' => $config['points'],
'label_format' => $config['label_format'], 'label_format' => $config['label_format'],
'interval_seconds' => $config['interval_seconds'], 'interval_seconds' => $config['interval_seconds'],
@@ -73,56 +51,51 @@ class ServerChartsWidget extends Widget
public function getData(): array public function getData(): array
{ {
$sysstat = new SysstatMetrics;
$latest = $sysstat->latest();
try { try {
$agent = new AgentClient;
$overview = $agent->metricsOverview();
$disk = $this->diskSnapshot; $disk = $this->diskSnapshot;
if (empty($disk)) { if (empty($disk)) {
$disk = $this->fetchDiskSnapshot($agent); $disk = $this->fetchDiskSnapshot();
$this->diskSnapshot = $disk; $this->diskSnapshot = $disk;
} }
$cpu = $overview['cpu'] ?? []; $cpu = $this->getLocalCpuInfo();
$memory = $overview['memory'] ?? []; $memory = $this->readMeminfo();
$swap = $memory['swap'] ?? [];
$memUsage = $memory['usage_percent'] ?? $memory['usage'] ?? 0; if ($latest !== null) {
if ($memUsage == 0 && ($memory['total'] ?? 0) > 0) { $memory['usage'] = round((float) $latest['memory'], 1);
$memUsage = (($memory['used'] ?? 0) / $memory['total']) * 100; $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 [ return [
'cpu' => [ 'cpu' => [
'usage' => round($cpu['usage'] ?? 0, 1), 'usage' => 0,
'iowait' => round($cpu['iowait'] ?? 0, 1), 'iowait' => round((float) ($latest['iowait'] ?? 0), 1),
'cores' => $cpu['cores'] ?? 0, 'cores' => $cpu['cores'] ?? 0,
'model' => $cpu['model'] ?? 'Unknown', 'model' => $cpu['model'] ?? 'Unknown',
], ],
'memory' => [ 'memory' => [
'usage' => round($memUsage, 1), 'usage' => $memory['usage'] ?? 0,
'used' => $memory['used'] ?? 0, 'used' => $memory['used'] ?? 0,
'total' => $memory['total'] ?? 0, 'total' => $memory['total'] ?? 0,
'free' => $memory['free'] ?? 0, 'free' => $memory['free'] ?? 0,
'cached' => $memory['cached'] ?? 0, 'cached' => $memory['cached'] ?? 0,
'swap_used' => $swap['used'] ?? 0, 'swap_used' => $memory['swap_used'] ?? 0,
'swap_total' => $swap['total'] ?? 0, 'swap_total' => $memory['swap_total'] ?? 0,
'swap_usage' => round((float) ($swap['usage_percent'] ?? 0), 1), 'swap_usage' => $memory['swap_usage'] ?? 0,
], ],
'disk' => [ 'disk' => [
'partitions' => $disk['partitions'] ?? [], 'partitions' => $disk['partitions'] ?? [],
], ],
'load' => $overview['load'] ?? [], 'load' => [
'uptime' => $overview['uptime']['human'] ?? 'N/A', '1min' => $latest['load1'] ?? 0,
'history' => $history, '5min' => $latest['load5'] ?? 0,
'source' => 'agent', '15min' => $latest['load15'] ?? 0,
],
'uptime' => $this->getLocalUptime(),
'source' => $latest !== null ? 'sysstat' : 'local',
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
$fallback = $this->getLocalMetrics(); $fallback = $this->getLocalMetrics();
@@ -130,13 +103,6 @@ class ServerChartsWidget extends Widget
if (empty($this->diskSnapshot)) { if (empty($this->diskSnapshot)) {
$this->diskSnapshot = $fallback['disk'] ?? []; $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 [ return [
'cpu' => [ 'cpu' => [
@@ -149,7 +115,6 @@ class ServerChartsWidget extends Widget
'disk' => $fallback['disk'], 'disk' => $fallback['disk'],
'load' => $fallback['load'], 'load' => $fallback['load'],
'uptime' => $fallback['uptime'] ?? 'N/A', 'uptime' => $fallback['uptime'] ?? 'N/A',
'history' => $history,
'source' => 'local', 'source' => 'local',
'warning' => $e->getMessage(), 'warning' => $e->getMessage(),
]; ];
@@ -162,7 +127,6 @@ class ServerChartsWidget extends Widget
'disk' => $this->diskSnapshot, 'disk' => $this->diskSnapshot,
'load' => [], 'load' => [],
'uptime' => 'N/A', 'uptime' => 'N/A',
'history' => $this->getHistory(0.0, 0.0, 0.0, 0.0, []),
'source' => 'error', 'source' => 'error',
]; ];
} }
@@ -173,7 +137,14 @@ class ServerChartsWidget extends Widget
$this->range = $this->normalizeRange($range); $this->range = $this->normalizeRange($range);
$config = $this->rangeConfig($this->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', [ $this->dispatch('server-charts-range-changed', [
'history' => $history, 'history' => $history,
'history_points' => $config['points'], 'history_points' => $config['points'],
@@ -190,129 +161,37 @@ class ServerChartsWidget extends Widget
float $swap = 0.0, float $swap = 0.0,
array $diskPartitions = [] array $diskPartitions = []
): array { ): 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); $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() if (! empty($history)) {
->where('resolution', $config['resolution']) $history['disk'] = $this->seedDiskHistory($config['points'], $diskPartitions);
->where('captured_at', '>=', $since)
->orderBy('captured_at')
->get();
if ($records->isEmpty()) { return $history;
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')); return $this->seedHistory(
$diskLabels = $records $config['points'],
->where('metric', 'disk_gb') $config['label_format'],
->pluck('label') $config['interval_seconds'],
->filter() $load,
->unique() $iowait,
->values() $memory,
->all(); $swap,
$diskPartitions,
$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 private function rangeConfig(string $range): array
{ {
return match ($range) { return match ($range) {
'5m' => ['minutes' => 5, 'points' => 30, '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' => 180, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10], '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], '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], '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], '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 private function getLocalMetrics(): ?array
{ {
$load = sys_getloadavg(); $load = sys_getloadavg();
@@ -452,16 +263,7 @@ class ServerChartsWidget extends Widget
'15min' => round((float) $load[2], 2), '15min' => round((float) $load[2], 2),
]; ];
$cpuCores = 0; $cpuInfo = $this->getLocalCpuInfo();
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(); $memory = $this->readMeminfo();
$disk = $this->readDiskUsage(); $disk = $this->readDiskUsage();
@@ -470,28 +272,96 @@ class ServerChartsWidget extends Widget
'cpu' => [ 'cpu' => [
'usage' => 0, 'usage' => 0,
'iowait' => 0, 'iowait' => 0,
'cores' => $cpuCores, 'cores' => $cpuInfo['cores'],
'model' => 'Local', 'model' => $cpuInfo['model'],
], ],
'memory' => $memory, 'memory' => $memory,
'disk' => $disk, 'disk' => $disk,
'load' => $loadData, 'load' => $loadData,
'uptime' => $this->getLocalUptime(),
]; ];
} }
private function fetchDiskSnapshot(?AgentClient $agent = null): array private function fetchDiskSnapshot(): array
{ {
try { return $this->readDiskUsage();
$client = $agent ?? new AgentClient; }
$disk = $client->metricsDisk()['data'] ?? [];
if (! empty($disk)) { /**
return $disk; * @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<int, array<string, mixed>> $diskPartitions
* @return array<string, array<int, float>>
*/
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 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']; $units = ['B', 'KB', 'MB', 'GB', 'TB'];
$size = max(0, $bytes); $size = max(0, (float) $bytes);
$unit = 0; $unit = 0;
while ($size >= 1024 && $unit < count($units) - 1) { while ($size >= 1024 && $unit < count($units) - 1) {
$size /= 1024; $size /= 1024;

View File

@@ -28,7 +28,9 @@ use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
class Files extends Page implements HasActions, HasForms, HasTable 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 public function table(Table $table): Table
{ {
return $table return $table
->records(function () { ->paginated([100, 250, 500])
return collect($this->items) ->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 { ->mapWithKeys(function (array $item, int $index): array {
$key = $item['path'] ?? $item['name'] ?? (string) $index; $key = $item['path'] ?? $item['name'] ?? (string) $index;
return [$key => $item]; return [$key => $item];
}) })
->all(); ->all();
$records = $this->filterRecords($records, $search);
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
return $this->paginateRecords($records, $page, $recordsPerPage);
}) })
->columns([ ->columns([
TextColumn::make('name') TextColumn::make('name')
@@ -661,6 +669,80 @@ class Files extends Page implements HasActions, HasForms, HasTable
return is_array($record) ? $record['path'] : $record->getKey(); 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 // Drag and drop operations
public function moveItem(string $sourcePath, string $destPath): void public function moveItem(string $sourcePath, string $destPath): void
{ {

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ServerMetric extends Model
{
protected $fillable = [
'metric',
'label',
'resolution',
'value',
'captured_at',
];
protected function casts(): array
{
return [
'value' => 'float',
'captured_at' => 'datetime',
];
}
}

View File

@@ -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 ============ // ============ DISK QUOTA OPERATIONS ============
/** /**

View File

@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\CarbonImmutable;
use Symfony\Component\Process\Process;
class SysstatMetrics
{
/**
* @return array{labels: array<int, string>, load: array<int, float>, iowait: array<int, float>, memory: array<int, float>, swap: array<int, float>}
*/
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<int, array{timestamp: int, load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}>
*/
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<int, array<string, mixed>>
*/
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<string, mixed> $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<int, string>, load: array<int, float>, iowait: array<int, float>, memory: array<int, float>, swap: array<int, float>}
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}

View File

@@ -691,7 +691,6 @@ function handleAction(array $request): array
'metrics.disk' => metricsDisk($params), 'metrics.disk' => metricsDisk($params),
'metrics.network' => metricsNetwork($params), 'metrics.network' => metricsNetwork($params),
'metrics.processes' => metricsProcesses($params), 'metrics.processes' => metricsProcesses($params),
'metrics.history' => metricsHistory($params),
'system.kill_process' => systemKillProcess($params), 'system.kill_process' => systemKillProcess($params),
// Disk quota operations // Disk quota operations
'quota.status' => quotaStatus($params), 'quota.status' => quotaStatus($params),
@@ -19368,44 +19367,6 @@ function systemKillProcess(array $params): array
return ['success' => false, 'error' => "Failed to kill process: $error"]; 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 function getUptime(): array
{ {
$uptime = (float) trim(file_get_contents('/proc/uptime') ?? '0'); $uptime = (float) trim(file_get_contents('/proc/uptime') ?? '0');

View File

@@ -1,35 +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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('server_metrics', function (Blueprint $table) {
$table->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');
}
};

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('server_metrics', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")" JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc34}" JABALI_VERSION="${JABALI_VERSION:-0.9-rc35}"
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
@@ -440,6 +440,9 @@ install_packages() {
# Log analysis # Log analysis
goaccess goaccess
# System metrics
sysstat
) )
# Add Mail Server packages if enabled # Add Mail Server packages if enabled
@@ -1008,6 +1011,66 @@ configure_php() {
fi 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
configure_mariadb() { configure_mariadb() {
header "Configuring MariaDB" header "Configuring MariaDB"
@@ -3428,6 +3491,7 @@ main() {
add_repositories add_repositories
install_packages install_packages
configure_sysstat
install_geoipupdate_binary install_geoipupdate_binary
install_composer install_composer
clone_jabali clone_jabali

View File

@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")" JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc26}" JABALI_VERSION="${JABALI_VERSION:-0.9-rc35}"
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
@@ -440,6 +440,9 @@ install_packages() {
# Log analysis # Log analysis
goaccess goaccess
# System metrics
sysstat
) )
# Add Mail Server packages if enabled # Add Mail Server packages if enabled
@@ -976,6 +979,66 @@ configure_php() {
fi 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
configure_mariadb() { configure_mariadb() {
header "Configuring MariaDB" header "Configuring MariaDB"
@@ -3390,6 +3453,7 @@ main() {
add_repositories add_repositories
install_packages install_packages
configure_sysstat
install_geoipupdate_binary install_geoipupdate_binary
install_composer install_composer
clone_jabali clone_jabali

View File

@@ -1,4 +1,23 @@
<x-filament-widgets::widget wire:poll.10s="refreshData"> <x-filament-widgets::widget
x-data="{
poller: null,
init() {
this.poller = setInterval(() => {
$wire.refreshData();
}, 60000);
$wire.refreshData();
},
stop() {
if (this.poller) {
clearInterval(this.poller);
this.poller = null;
}
}
}"
x-on:livewire:navigating.window="stop()"
x-on:beforeunload.window="stop()"
wire:poll.60s="refreshData"
>
@php @php
$data = $this->getData(); $data = $this->getData();
$cpu = $data['cpu'] ?? []; $cpu = $data['cpu'] ?? [];
@@ -7,36 +26,30 @@
$partitions = $disk['partitions'] ?? []; $partitions = $disk['partitions'] ?? [];
$load = $data['load'] ?? []; $load = $data['load'] ?? [];
$uptime = $data['uptime'] ?? 'N/A'; $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; $range = $this->range;
$historyPoints = match ($range) { $historyPoints = match ($range) {
'5m' => 30, '5m' => 5,
'30m' => 180, '30m' => 30,
'day' => 24, 'day' => 24,
'week' => 28, 'week' => 28,
'month' => 30, 'month' => 30,
default => 180, default => 30,
}; };
$historyIntervalSeconds = match ($range) { $historyIntervalSeconds = match ($range) {
'5m' => 10, '5m' => 60,
'30m' => 10, '30m' => 60,
'day' => 3600, 'day' => 3600,
'week' => 21600, 'week' => 21600,
'month' => 86400, 'month' => 86400,
default => 10, default => 60,
}; };
$historyLabelFormat = match ($range) { $historyLabelFormat = match ($range) {
'5m' => 'H:i:s', '5m' => 'H:i',
'30m' => 'H:i:s', '30m' => 'H:i',
'day' => 'H:00', 'day' => 'H:00',
'week' => 'M d H:00', 'week' => 'M d H:00',
'month' => 'M d', 'month' => 'M d',
default => 'H:i:s', default => 'H:i',
}; };
$cpuCores = max(1, (int)($cpu['cores'] ?? 1)); $cpuCores = max(1, (int)($cpu['cores'] ?? 1));
@@ -45,6 +58,18 @@
$load15 = (float)($load['15min'] ?? 0); $load15 = (float)($load['15min'] ?? 0);
$ioWait = (float)($cpu['iowait'] ?? 0); $ioWait = (float)($cpu['iowait'] ?? 0);
$memUsage = $memory['usage'] ?? 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 // Memory values are in MB from agent
$memUsedGB = ($memory['used'] ?? 0) / 1024; $memUsedGB = ($memory['used'] ?? 0) / 1024;
$memTotalGB = ($memory['total'] ?? 0) / 1024; $memTotalGB = ($memory['total'] ?? 0) / 1024;

View File

@@ -59,13 +59,6 @@ Schedule::command('jabali:run-cron-jobs')
->runInBackground() ->runInBackground()
->appendOutputTo(storage_path('logs/user-cron-jobs.log')); ->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 // Mailbox Quota Sync - runs every 15 minutes to update mailbox usage from disk
Schedule::command('jabali:sync-mailbox-quotas') Schedule::command('jabali:sync-mailbox-quotas')
->everyFifteenMinutes() ->everyFifteenMinutes()

View File

@@ -15,6 +15,7 @@ class ServerChartsWidgetTest extends TestCase
public function test_history_seeding_includes_swap_and_disk_series(): void public function test_history_seeding_includes_swap_and_disk_series(): void
{ {
$widget = app(ServerChartsWidget::class); $widget = app(ServerChartsWidget::class);
$widget->range = '5m';
$history = $widget->getHistory(1.25, 2.5, 42.5, 12.3, [ $history = $widget->getHistory(1.25, 2.5, 42.5, 12.3, [
['mount' => '/', 'used' => 1024 * 1024 * 1024], ['mount' => '/', 'used' => 1024 * 1024 * 1024],
@@ -26,6 +27,7 @@ class ServerChartsWidgetTest extends TestCase
$this->assertArrayHasKey('disk', $history); $this->assertArrayHasKey('disk', $history);
$this->assertArrayHasKey('/', $history['disk']); $this->assertArrayHasKey('/', $history['disk']);
$this->assertArrayHasKey('/boot', $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['swap']);
$this->assertCount(count($history['labels']), $history['iowait']); $this->assertCount(count($history['labels']), $history['iowait']);
} }