Use sysstat for server status charts
This commit is contained in:
4
AGENT.md
4
AGENT.md
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
307
app/Services/SysstatMetrics.php
Normal file
307
app/Services/SysstatMetrics.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
66
install.sh
66
install.sh
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user