Improve server charts and installer locales
This commit is contained in:
@@ -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-rc21 (release candidate)
|
Version: 0.9-rc23 (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.
|
||||||
|
|
||||||
|
|||||||
152
app/Console/Commands/CollectServerMetrics.php
Normal file
152
app/Console/Commands/CollectServerMetrics.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,63 +73,7 @@ class ServerStatus extends Page implements HasTable
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
ActionGroup::make([
|
|
||||||
Action::make('limit25')
|
|
||||||
->label(__('Show 25 processes'))
|
|
||||||
->icon(fn () => $this->processLimit === 25 ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setProcessLimit(25)),
|
|
||||||
Action::make('limit50')
|
|
||||||
->label(__('Show 50 processes'))
|
|
||||||
->icon(fn () => $this->processLimit === 50 ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setProcessLimit(50)),
|
|
||||||
Action::make('limit100')
|
|
||||||
->label(__('Show 100 processes'))
|
|
||||||
->icon(fn () => $this->processLimit === 100 ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setProcessLimit(100)),
|
|
||||||
Action::make('limitAll')
|
|
||||||
->label(__('Show all processes'))
|
|
||||||
->icon(fn () => $this->processLimit === 0 ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setProcessLimit(0)),
|
|
||||||
])
|
|
||||||
->label(fn () => __('Process Limit: :limit', ['limit' => $this->processLimit === 0 ? __('All') : $this->processLimit]))
|
|
||||||
->icon('heroicon-o-queue-list')
|
|
||||||
->color('gray')
|
|
||||||
->button(),
|
|
||||||
ActionGroup::make([
|
|
||||||
Action::make('5s')
|
|
||||||
->label(__('Every 5 seconds'))
|
|
||||||
->icon(fn () => $this->refreshInterval === '5s' ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setRefreshInterval('5s')),
|
|
||||||
Action::make('10s')
|
|
||||||
->label(__('Every 10 seconds'))
|
|
||||||
->icon(fn () => $this->refreshInterval === '10s' ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setRefreshInterval('10s')),
|
|
||||||
Action::make('30s')
|
|
||||||
->label(__('Every 30 seconds'))
|
|
||||||
->icon(fn () => $this->refreshInterval === '30s' ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setRefreshInterval('30s')),
|
|
||||||
Action::make('60s')
|
|
||||||
->label(__('Every 1 minute'))
|
|
||||||
->icon(fn () => $this->refreshInterval === '60s' ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setRefreshInterval('60s')),
|
|
||||||
Action::make('off')
|
|
||||||
->label(__('Off'))
|
|
||||||
->icon(fn () => $this->refreshInterval === 'off' ? 'heroicon-o-check' : null)
|
|
||||||
->action(fn () => $this->setRefreshInterval('off')),
|
|
||||||
])
|
|
||||||
->label(fn () => $this->refreshInterval === 'off'
|
|
||||||
? __('Auto-refresh: Off')
|
|
||||||
: __('Auto: :interval', ['interval' => $this->refreshInterval]))
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->color('gray')
|
|
||||||
->button(),
|
|
||||||
Action::make('refresh')
|
|
||||||
->label(fn () => $this->lastUpdated ? __('Refresh (:time)', ['time' => $this->lastUpdated]) : __('Refresh'))
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->action(fn () => $this->loadMetrics()),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setProcessLimit(int $limit): void
|
public function setProcessLimit(int $limit): void
|
||||||
@@ -291,6 +235,35 @@ class ServerStatus extends Page implements HasTable
|
|||||||
->action(fn (Collection $records, array $data) => $this->killProcesses($records, (int) $data['signal']))
|
->action(fn (Collection $records, array $data) => $this->killProcesses($records, (int) $data['signal']))
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
])
|
])
|
||||||
|
->headerActions([
|
||||||
|
ActionGroup::make([
|
||||||
|
Action::make('limit25')
|
||||||
|
->label(__('Show 25 processes'))
|
||||||
|
->icon(fn () => $this->processLimit === 25 ? 'heroicon-o-check' : null)
|
||||||
|
->action(fn () => $this->setProcessLimit(25)),
|
||||||
|
Action::make('limit50')
|
||||||
|
->label(__('Show 50 processes'))
|
||||||
|
->icon(fn () => $this->processLimit === 50 ? 'heroicon-o-check' : null)
|
||||||
|
->action(fn () => $this->setProcessLimit(50)),
|
||||||
|
Action::make('limit100')
|
||||||
|
->label(__('Show 100 processes'))
|
||||||
|
->icon(fn () => $this->processLimit === 100 ? 'heroicon-o-check' : null)
|
||||||
|
->action(fn () => $this->setProcessLimit(100)),
|
||||||
|
Action::make('limitAll')
|
||||||
|
->label(__('Show all processes'))
|
||||||
|
->icon(fn () => $this->processLimit === 0 ? 'heroicon-o-check' : null)
|
||||||
|
->action(fn () => $this->setProcessLimit(0)),
|
||||||
|
])
|
||||||
|
->label(fn () => __('Process Limit: :limit', ['limit' => $this->processLimit === 0 ? __('All') : $this->processLimit]))
|
||||||
|
->icon('heroicon-o-queue-list')
|
||||||
|
->color('gray')
|
||||||
|
->button(),
|
||||||
|
Action::make('refreshProcesses')
|
||||||
|
->label(fn () => $this->lastUpdated ? __('Refresh (:time)', ['time' => $this->lastUpdated]) : __('Refresh'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('gray')
|
||||||
|
->action(fn () => $this->loadMetrics()),
|
||||||
|
])
|
||||||
->heading(__('Process List'))
|
->heading(__('Process List'))
|
||||||
->description(__(':total total processes, :running running', ['total' => $this->processTotal, 'running' => $this->processRunning]))
|
->description(__(':total total processes, :running running', ['total' => $this->processTotal, 'running' => $this->processRunning]))
|
||||||
->paginated([10, 25, 50, 100])
|
->paginated([10, 25, 50, 100])
|
||||||
|
|||||||
@@ -4,49 +4,105 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Admin\Widgets;
|
namespace App\Filament\Admin\Widgets;
|
||||||
|
|
||||||
|
use App\Models\ServerMetric;
|
||||||
use App\Services\Agent\AgentClient;
|
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 = '10s';
|
||||||
|
|
||||||
public int $refreshKey = 0;
|
public int $refreshKey = 0;
|
||||||
|
|
||||||
|
public string $range = '5m';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $diskSnapshot = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->diskSnapshot = $this->fetchDiskSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
public function refreshData(): void
|
public function refreshData(): void
|
||||||
{
|
{
|
||||||
$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);
|
||||||
|
$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,
|
||||||
|
'load' => $data['load']['1min'] ?? 0,
|
||||||
|
'iowait' => $data['cpu']['iowait'] ?? 0,
|
||||||
'memory' => $data['memory']['usage'] ?? 0,
|
'memory' => $data['memory']['usage'] ?? 0,
|
||||||
'disk' => $data['disk']['partitions'][0]['usage_percent'] ?? 0,
|
'swap' => $data['memory']['swap_usage'] ?? 0,
|
||||||
|
'label' => $label,
|
||||||
|
'history' => $history,
|
||||||
|
'history_points' => $config['points'],
|
||||||
|
'label_format' => $config['label_format'],
|
||||||
|
'interval_seconds' => $config['interval_seconds'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getData(): array
|
public function getData(): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$agent = new AgentClient();
|
$agent = new AgentClient;
|
||||||
$overview = $agent->metricsOverview();
|
$overview = $agent->metricsOverview();
|
||||||
$disk = $agent->metricsDisk()['data'] ?? [];
|
$disk = $this->diskSnapshot;
|
||||||
|
if (empty($disk)) {
|
||||||
|
$disk = $this->fetchDiskSnapshot($agent);
|
||||||
|
$this->diskSnapshot = $disk;
|
||||||
|
}
|
||||||
|
|
||||||
$cpu = $overview['cpu'] ?? [];
|
$cpu = $overview['cpu'] ?? [];
|
||||||
$memory = $overview['memory'] ?? [];
|
$memory = $overview['memory'] ?? [];
|
||||||
|
$swap = $memory['swap'] ?? [];
|
||||||
|
|
||||||
// Calculate memory usage if not provided
|
|
||||||
$memUsage = $memory['usage_percent'] ?? $memory['usage'] ?? 0;
|
$memUsage = $memory['usage_percent'] ?? $memory['usage'] ?? 0;
|
||||||
if ($memUsage == 0 && ($memory['total'] ?? 0) > 0) {
|
if ($memUsage == 0 && ($memory['total'] ?? 0) > 0) {
|
||||||
$memUsage = (($memory['used'] ?? 0) / $memory['total']) * 100;
|
$memUsage = (($memory['used'] ?? 0) / $memory['total']) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$history = $this->getHistory(
|
||||||
|
(float) ($overview['load']['1min'] ?? 0),
|
||||||
|
(float) ($cpu['iowait'] ?? 0),
|
||||||
|
(float) $memUsage,
|
||||||
|
(float) ($swap['usage_percent'] ?? 0),
|
||||||
|
$disk['partitions'] ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'cpu' => [
|
'cpu' => [
|
||||||
'usage' => round($cpu['usage'] ?? 0, 1),
|
'usage' => round($cpu['usage'] ?? 0, 1),
|
||||||
|
'iowait' => round($cpu['iowait'] ?? 0, 1),
|
||||||
'cores' => $cpu['cores'] ?? 0,
|
'cores' => $cpu['cores'] ?? 0,
|
||||||
'model' => $cpu['model'] ?? 'Unknown',
|
'model' => $cpu['model'] ?? 'Unknown',
|
||||||
],
|
],
|
||||||
@@ -56,22 +112,464 @@ class ServerChartsWidget extends Widget
|
|||||||
'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_total' => $swap['total'] ?? 0,
|
||||||
|
'swap_usage' => round((float) ($swap['usage_percent'] ?? 0), 1),
|
||||||
],
|
],
|
||||||
'disk' => [
|
'disk' => [
|
||||||
'partitions' => $disk['partitions'] ?? [],
|
'partitions' => $disk['partitions'] ?? [],
|
||||||
],
|
],
|
||||||
'load' => $overview['load'] ?? [],
|
'load' => $overview['load'] ?? [],
|
||||||
'uptime' => $overview['uptime']['human'] ?? 'N/A',
|
'uptime' => $overview['uptime']['human'] ?? 'N/A',
|
||||||
|
'history' => $history,
|
||||||
|
'source' => 'agent',
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
$fallback = $this->getLocalMetrics();
|
||||||
|
if ($fallback !== null) {
|
||||||
|
if (empty($this->diskSnapshot)) {
|
||||||
|
$this->diskSnapshot = $fallback['disk'] ?? [];
|
||||||
|
}
|
||||||
|
$history = $this->getHistory(
|
||||||
|
(float) ($fallback['load']['1min'] ?? 0),
|
||||||
|
(float) ($fallback['cpu']['iowait'] ?? 0),
|
||||||
|
(float) ($fallback['memory']['usage'] ?? 0),
|
||||||
|
(float) ($fallback['memory']['swap_usage'] ?? 0),
|
||||||
|
$fallback['disk']['partitions'] ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cpu' => [
|
||||||
|
'usage' => round($fallback['cpu']['usage'] ?? 0, 1),
|
||||||
|
'iowait' => round($fallback['cpu']['iowait'] ?? 0, 1),
|
||||||
|
'cores' => $fallback['cpu']['cores'] ?? 0,
|
||||||
|
'model' => $fallback['cpu']['model'] ?? 'Local',
|
||||||
|
],
|
||||||
|
'memory' => $fallback['memory'],
|
||||||
|
'disk' => $fallback['disk'],
|
||||||
|
'load' => $fallback['load'],
|
||||||
|
'uptime' => $fallback['uptime'] ?? 'N/A',
|
||||||
|
'history' => $history,
|
||||||
|
'source' => 'local',
|
||||||
|
'warning' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'cpu' => ['usage' => 0, 'cores' => 0, 'model' => 'Error'],
|
'cpu' => ['usage' => 0, 'iowait' => 0, 'cores' => 0, 'model' => 'Error'],
|
||||||
'memory' => ['usage' => 0, 'used' => 0, 'total' => 0, 'free' => 0, 'cached' => 0],
|
'memory' => ['usage' => 0, 'used' => 0, 'total' => 0, 'free' => 0, 'cached' => 0],
|
||||||
'disk' => ['partitions' => []],
|
'disk' => $this->diskSnapshot,
|
||||||
'load' => [],
|
'load' => [],
|
||||||
'uptime' => 'N/A',
|
'uptime' => 'N/A',
|
||||||
|
'history' => $this->getHistory(0.0, 0.0, 0.0, 0.0, []),
|
||||||
|
'source' => 'error',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setRange(string $range): void
|
||||||
|
{
|
||||||
|
$this->range = $this->normalizeRange($range);
|
||||||
|
|
||||||
|
$config = $this->rangeConfig($this->range);
|
||||||
|
$history = $this->getHistory();
|
||||||
|
$this->dispatch('server-charts-range-changed', [
|
||||||
|
'history' => $history,
|
||||||
|
'history_points' => $config['points'],
|
||||||
|
'label_format' => $config['label_format'],
|
||||||
|
'interval_seconds' => $config['interval_seconds'],
|
||||||
|
'range' => $this->range,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHistory(
|
||||||
|
float $load = 0.0,
|
||||||
|
float $iowait = 0.0,
|
||||||
|
float $memory = 0.0,
|
||||||
|
float $swap = 0.0,
|
||||||
|
array $diskPartitions = []
|
||||||
|
): array {
|
||||||
|
if (! Schema::hasTable('server_metrics')) {
|
||||||
|
$config = $this->rangeConfig($this->range);
|
||||||
|
|
||||||
|
return $this->seedHistory(
|
||||||
|
$config['points'],
|
||||||
|
$config['label_format'],
|
||||||
|
$config['interval_seconds'],
|
||||||
|
$load,
|
||||||
|
$iowait,
|
||||||
|
$memory,
|
||||||
|
$swap,
|
||||||
|
$diskPartitions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->rangeConfig($this->range);
|
||||||
|
$since = now()->subMinutes($config['minutes']);
|
||||||
|
|
||||||
|
$records = ServerMetric::query()
|
||||||
|
->where('resolution', $config['resolution'])
|
||||||
|
->where('captured_at', '>=', $since)
|
||||||
|
->orderBy('captured_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
return $this->seedHistory(
|
||||||
|
$config['points'],
|
||||||
|
$config['label_format'],
|
||||||
|
$config['interval_seconds'],
|
||||||
|
$load,
|
||||||
|
$iowait,
|
||||||
|
$memory,
|
||||||
|
$swap,
|
||||||
|
$diskPartitions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$grouped = $records->groupBy(fn (ServerMetric $metric) => $metric->captured_at->format('Y-m-d H:i:s'));
|
||||||
|
$diskLabels = $records
|
||||||
|
->where('metric', 'disk_gb')
|
||||||
|
->pluck('label')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
$loadSeries = [];
|
||||||
|
$ioWaitSeries = [];
|
||||||
|
$memorySeries = [];
|
||||||
|
$swapSeries = [];
|
||||||
|
$diskSeries = [];
|
||||||
|
foreach ($diskLabels as $label) {
|
||||||
|
$diskSeries[$label] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($grouped as $timestamp => $rows) {
|
||||||
|
$labels[] = Carbon::createFromFormat('Y-m-d H:i:s', $timestamp)->format($config['label_format']);
|
||||||
|
$loadSeries[] = (float) ($rows->firstWhere('metric', 'load')->value ?? 0);
|
||||||
|
$ioWaitSeries[] = (float) ($rows->firstWhere('metric', 'iowait')->value ?? 0);
|
||||||
|
$memorySeries[] = (float) ($rows->firstWhere('metric', 'memory')->value ?? 0);
|
||||||
|
$swapSeries[] = (float) ($rows->firstWhere('metric', 'swap')->value ?? 0);
|
||||||
|
foreach ($diskLabels as $label) {
|
||||||
|
$diskSeries[$label][] = (float) ($rows->firstWhere(fn (ServerMetric $metric) => $metric->metric === 'disk_gb' && $metric->label === $label)->value ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($labels) < $config['points']) {
|
||||||
|
$seed = $this->seedHistory(
|
||||||
|
$config['points'],
|
||||||
|
$config['label_format'],
|
||||||
|
$config['interval_seconds'],
|
||||||
|
$load,
|
||||||
|
$iowait,
|
||||||
|
$memory,
|
||||||
|
$swap,
|
||||||
|
$diskPartitions,
|
||||||
|
);
|
||||||
|
|
||||||
|
$missing = $config['points'] - count($labels);
|
||||||
|
$labels = array_merge(array_slice($seed['labels'], 0, $missing), $labels);
|
||||||
|
$loadSeries = array_merge(array_fill(0, $missing, null), $loadSeries);
|
||||||
|
$ioWaitSeries = array_merge(array_fill(0, $missing, null), $ioWaitSeries);
|
||||||
|
$memorySeries = array_merge(array_fill(0, $missing, null), $memorySeries);
|
||||||
|
$swapSeries = array_merge(array_fill(0, $missing, null), $swapSeries);
|
||||||
|
|
||||||
|
$seedDisk = $seed['disk'] ?? [];
|
||||||
|
foreach ($diskSeries as $label => $series) {
|
||||||
|
$prefix = array_fill(0, $missing, null);
|
||||||
|
$diskSeries[$label] = array_merge($prefix, $series);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($labels) > $config['points']) {
|
||||||
|
$labels = array_slice($labels, -$config['points']);
|
||||||
|
$loadSeries = array_slice($loadSeries, -$config['points']);
|
||||||
|
$ioWaitSeries = array_slice($ioWaitSeries, -$config['points']);
|
||||||
|
$memorySeries = array_slice($memorySeries, -$config['points']);
|
||||||
|
$swapSeries = array_slice($swapSeries, -$config['points']);
|
||||||
|
foreach ($diskSeries as $label => $series) {
|
||||||
|
$diskSeries[$label] = array_slice($series, -$config['points']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $labels,
|
||||||
|
'load' => $loadSeries,
|
||||||
|
'iowait' => $ioWaitSeries,
|
||||||
|
'memory' => $memorySeries,
|
||||||
|
'swap' => $swapSeries,
|
||||||
|
'disk' => $diskSeries,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rangeConfig(string $range): array
|
||||||
|
{
|
||||||
|
return match ($range) {
|
||||||
|
'5m' => ['minutes' => 5, 'points' => 30, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10],
|
||||||
|
'30m' => ['minutes' => 30, 'points' => 180, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10],
|
||||||
|
'day' => ['minutes' => 1440, 'points' => 24, 'resolution' => '1h', 'label_format' => 'H:00', 'interval_seconds' => 3600],
|
||||||
|
'week' => ['minutes' => 10080, 'points' => 28, 'resolution' => '6h', 'label_format' => 'M d H:00', 'interval_seconds' => 21600],
|
||||||
|
'month' => ['minutes' => 43200, 'points' => 30, 'resolution' => '1d', 'label_format' => 'M d', 'interval_seconds' => 86400],
|
||||||
|
default => ['minutes' => 30, 'points' => 180, 'resolution' => '10s', 'label_format' => 'H:i:s', 'interval_seconds' => 10],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRange(string $range): string
|
||||||
|
{
|
||||||
|
return in_array($range, ['5m', '30m', 'day', 'week', 'month'], true) ? $range : '30m';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedHistory(
|
||||||
|
int $points,
|
||||||
|
string $labelFormat,
|
||||||
|
int $intervalSeconds,
|
||||||
|
float $load,
|
||||||
|
float $iowait,
|
||||||
|
float $memory,
|
||||||
|
float $swap,
|
||||||
|
array $diskPartitions
|
||||||
|
): array {
|
||||||
|
$labels = [];
|
||||||
|
$loadSeries = [];
|
||||||
|
$ioWaitSeries = [];
|
||||||
|
$memorySeries = [];
|
||||||
|
$swapSeries = [];
|
||||||
|
$diskSeries = [];
|
||||||
|
$diskSeeds = [];
|
||||||
|
foreach ($diskPartitions as $partition) {
|
||||||
|
$mount = $partition['mount'] ?? null;
|
||||||
|
$usedGb = $this->bytesToGb((int) ($partition['used'] ?? 0));
|
||||||
|
if ($mount !== null) {
|
||||||
|
$diskSeeds[$mount] = $usedGb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($diskSeeds as $mount => $value) {
|
||||||
|
$diskSeries[$mount] = [];
|
||||||
|
}
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
for ($i = $points - 1; $i >= 0; $i--) {
|
||||||
|
$labels[] = $now->copy()->subSeconds($i * $intervalSeconds)->format($labelFormat);
|
||||||
|
$loadSeries[] = round($load, 3);
|
||||||
|
$ioWaitSeries[] = round($iowait, 2);
|
||||||
|
$memorySeries[] = round($memory, 1);
|
||||||
|
$swapSeries[] = round($swap, 1);
|
||||||
|
foreach ($diskSeeds as $mount => $value) {
|
||||||
|
$diskSeries[$mount][] = round($value, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $labels,
|
||||||
|
'load' => $loadSeries,
|
||||||
|
'iowait' => $ioWaitSeries,
|
||||||
|
'memory' => $memorySeries,
|
||||||
|
'swap' => $swapSeries,
|
||||||
|
'disk' => $diskSeries,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeTenSecondMetrics(float $load, float $iowait, float $memory, float $swap, array $partitions): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('server_metrics')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capturedAt = now()->setSecond(intdiv(now()->second, self::HISTORY_INTERVAL_SECONDS) * self::HISTORY_INTERVAL_SECONDS);
|
||||||
|
|
||||||
|
$this->storeMetric('load', '10s', $load, $capturedAt);
|
||||||
|
$this->storeMetric('iowait', '10s', $iowait, $capturedAt);
|
||||||
|
$this->storeMetric('memory', '10s', $memory, $capturedAt);
|
||||||
|
$this->storeMetric('swap', '10s', $swap, $capturedAt);
|
||||||
|
foreach ($partitions as $partition) {
|
||||||
|
$mount = $partition['mount'] ?? null;
|
||||||
|
if ($mount === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$used = (int) ($partition['used'] ?? 0);
|
||||||
|
$this->storeMetric('disk_gb', '10s', $this->bytesToGb($used), $capturedAt, $mount);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pruneResolution('10s', now()->subMinutes(30));
|
||||||
|
$this->storeAggregateMetric('load', $load);
|
||||||
|
$this->storeAggregateMetric('iowait', $iowait);
|
||||||
|
$this->storeAggregateMetric('memory', $memory);
|
||||||
|
$this->storeAggregateMetric('swap', $swap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeMetric(string $metric, string $resolution, float $value, Carbon $capturedAt, ?string $label = null): void
|
||||||
|
{
|
||||||
|
ServerMetric::updateOrCreate(
|
||||||
|
[
|
||||||
|
'metric' => $metric,
|
||||||
|
'label' => $label,
|
||||||
|
'resolution' => $resolution,
|
||||||
|
'captured_at' => $capturedAt,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => round($value, 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeAggregateMetric(string $metric, float $value): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$this->storeMetric($metric, '1h', $value, $this->bucketTimestamp($now, 3600));
|
||||||
|
$this->storeMetric($metric, '6h', $value, $this->bucketTimestamp($now, 21600));
|
||||||
|
$this->storeMetric($metric, '1d', $value, $this->bucketTimestamp($now, 86400));
|
||||||
|
|
||||||
|
$this->pruneResolution('1h', $now->copy()->subHours(24));
|
||||||
|
$this->pruneResolution('6h', $now->copy()->subDays(7));
|
||||||
|
$this->pruneResolution('1d', $now->copy()->subDays(31));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bucketTimestamp(Carbon $now, int $bucketSeconds): Carbon
|
||||||
|
{
|
||||||
|
return $now->copy()->setTimestamp(intdiv($now->timestamp, $bucketSeconds) * $bucketSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pruneResolution(string $resolution, Carbon $before): void
|
||||||
|
{
|
||||||
|
ServerMetric::query()
|
||||||
|
->where('resolution', $resolution)
|
||||||
|
->where('captured_at', '<', $before)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLocalMetrics(): ?array
|
||||||
|
{
|
||||||
|
$load = sys_getloadavg();
|
||||||
|
if (! is_array($load) || count($load) < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$loadData = [
|
||||||
|
'1min' => round((float) $load[0], 2),
|
||||||
|
'5min' => round((float) $load[1], 2),
|
||||||
|
'15min' => round((float) $load[2], 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
$cpuCores = 0;
|
||||||
|
if (is_readable('/proc/cpuinfo')) {
|
||||||
|
$lines = file('/proc/cpuinfo', FILE_IGNORE_NEW_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (str_starts_with($line, 'processor')) {
|
||||||
|
$cpuCores++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cpuCores = $cpuCores > 0 ? $cpuCores : 1;
|
||||||
|
|
||||||
|
$memory = $this->readMeminfo();
|
||||||
|
$disk = $this->readDiskUsage();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cpu' => [
|
||||||
|
'usage' => 0,
|
||||||
|
'iowait' => 0,
|
||||||
|
'cores' => $cpuCores,
|
||||||
|
'model' => 'Local',
|
||||||
|
],
|
||||||
|
'memory' => $memory,
|
||||||
|
'disk' => $disk,
|
||||||
|
'load' => $loadData,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchDiskSnapshot(?AgentClient $agent = null): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = $agent ?? new AgentClient;
|
||||||
|
$disk = $client->metricsDisk()['data'] ?? [];
|
||||||
|
if (! empty($disk)) {
|
||||||
|
return $disk;
|
||||||
|
}
|
||||||
|
} catch (\Exception) {
|
||||||
|
// Ignore and fall back to local disk stats below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->readDiskUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readMeminfo(): array
|
||||||
|
{
|
||||||
|
$memInfo = [];
|
||||||
|
if (is_readable('/proc/meminfo')) {
|
||||||
|
$lines = file('/proc/meminfo', FILE_IGNORE_NEW_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^(\w+):\s+(\d+)/', $line, $matches)) {
|
||||||
|
$memInfo[$matches[1]] = (int) $matches[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalKb = $memInfo['MemTotal'] ?? 0;
|
||||||
|
$freeKb = $memInfo['MemFree'] ?? 0;
|
||||||
|
$availableKb = $memInfo['MemAvailable'] ?? $freeKb;
|
||||||
|
$buffersKb = $memInfo['Buffers'] ?? 0;
|
||||||
|
$cachedKb = $memInfo['Cached'] ?? 0;
|
||||||
|
$swapTotalKb = $memInfo['SwapTotal'] ?? 0;
|
||||||
|
$swapFreeKb = $memInfo['SwapFree'] ?? 0;
|
||||||
|
$swapUsedKb = max(0, $swapTotalKb - $swapFreeKb);
|
||||||
|
|
||||||
|
$usedKb = max(0, $totalKb - $availableKb);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'usage' => $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 1) : 0,
|
||||||
|
'used' => round($usedKb / 1024, 0),
|
||||||
|
'total' => round($totalKb / 1024, 0),
|
||||||
|
'free' => round($freeKb / 1024, 0),
|
||||||
|
'cached' => round($cachedKb / 1024, 0),
|
||||||
|
'swap_used' => round($swapUsedKb / 1024, 0),
|
||||||
|
'swap_total' => round($swapTotalKb / 1024, 0),
|
||||||
|
'swap_usage' => $swapTotalKb > 0 ? round(($swapUsedKb / $swapTotalKb) * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readDiskUsage(): array
|
||||||
|
{
|
||||||
|
$total = @disk_total_space('/') ?: 0;
|
||||||
|
$free = @disk_free_space('/') ?: 0;
|
||||||
|
$used = max(0, $total - $free);
|
||||||
|
$usagePercent = $total > 0 ? round(($used / $total) * 100, 1) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'partitions' => [
|
||||||
|
[
|
||||||
|
'filesystem' => '/',
|
||||||
|
'mount' => '/',
|
||||||
|
'used' => $used,
|
||||||
|
'total' => $total,
|
||||||
|
'usage_percent' => $usagePercent,
|
||||||
|
'used_human' => $this->formatBytes($used),
|
||||||
|
'total_human' => $this->formatBytes($total),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$size = max(0, $bytes);
|
||||||
|
$unit = 0;
|
||||||
|
while ($size >= 1024 && $unit < count($units) - 1) {
|
||||||
|
$size /= 1024;
|
||||||
|
$unit++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($size, 1).' '.$units[$unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bytesToGb(int $bytes): float
|
||||||
|
{
|
||||||
|
if ($bytes <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes / 1024 / 1024 / 1024, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/Models/ServerMetric.php
Normal file
26
app/Models/ServerMetric.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18736,7 +18736,9 @@ function metricsCpu(array $params): array
|
|||||||
usleep(100000); // 100ms
|
usleep(100000); // 100ms
|
||||||
$stat2 = file_get_contents('/proc/stat');
|
$stat2 = file_get_contents('/proc/stat');
|
||||||
|
|
||||||
$cpuUsage = calculateCpuUsage($stat1, $stat2);
|
$cpuStats = calculateCpuStats($stat1, $stat2);
|
||||||
|
$cpuUsage = $cpuStats['usage'];
|
||||||
|
$ioWait = $cpuStats['iowait'];
|
||||||
|
|
||||||
// Per-core usage
|
// Per-core usage
|
||||||
$coreUsages = [];
|
$coreUsages = [];
|
||||||
@@ -18757,6 +18759,7 @@ function metricsCpu(array $params): array
|
|||||||
'model' => $cpuModel,
|
'model' => $cpuModel,
|
||||||
'cores' => $cpuCores,
|
'cores' => $cpuCores,
|
||||||
'usage' => $cpuUsage,
|
'usage' => $cpuUsage,
|
||||||
|
'iowait' => $ioWait,
|
||||||
'core_usage' => $coreUsages,
|
'core_usage' => $coreUsages,
|
||||||
'load' => $load,
|
'load' => $load,
|
||||||
'frequency' => getCpuFrequency(),
|
'frequency' => getCpuFrequency(),
|
||||||
@@ -18764,29 +18767,39 @@ function metricsCpu(array $params): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateCpuUsage(string $stat1, string $stat2): float
|
function calculateCpuStats(string $stat1, string $stat2): array
|
||||||
{
|
{
|
||||||
preg_match('/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m', $stat1, $m1);
|
preg_match('/^cpu\s+(.+)$/m', $stat1, $m1);
|
||||||
preg_match('/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m', $stat2, $m2);
|
preg_match('/^cpu\s+(.+)$/m', $stat2, $m2);
|
||||||
|
|
||||||
if (empty($m1) || empty($m2)) {
|
if (empty($m1[1]) || empty($m2[1])) {
|
||||||
return 0;
|
return ['usage' => 0.0, 'iowait' => 0.0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$idle1 = $m1[4] + $m1[5];
|
$v1 = array_map('intval', preg_split('/\s+/', trim($m1[1])));
|
||||||
$idle2 = $m2[4] + $m2[5];
|
$v2 = array_map('intval', preg_split('/\s+/', trim($m2[1])));
|
||||||
|
|
||||||
$total1 = array_sum(array_slice($m1, 1));
|
$idle1 = ($v1[3] ?? 0) + ($v1[4] ?? 0);
|
||||||
$total2 = array_sum(array_slice($m2, 1));
|
$idle2 = ($v2[3] ?? 0) + ($v2[4] ?? 0);
|
||||||
|
|
||||||
|
$total1 = array_sum($v1);
|
||||||
|
$total2 = array_sum($v2);
|
||||||
|
|
||||||
$totalDiff = $total2 - $total1;
|
$totalDiff = $total2 - $total1;
|
||||||
$idleDiff = $idle2 - $idle1;
|
$idleDiff = $idle2 - $idle1;
|
||||||
|
$iowaitDiff = ($v2[4] ?? 0) - ($v1[4] ?? 0);
|
||||||
|
|
||||||
if ($totalDiff == 0) {
|
if ($totalDiff == 0) {
|
||||||
return 0;
|
return ['usage' => 0.0, 'iowait' => 0.0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return round((($totalDiff - $idleDiff) / $totalDiff) * 100, 1);
|
$usage = (($totalDiff - $idleDiff) / $totalDiff) * 100;
|
||||||
|
$iowait = ($iowaitDiff / $totalDiff) * 100;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'usage' => round($usage, 1),
|
||||||
|
'iowait' => round(max(0, $iowait), 1),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCpuFrequency(): array
|
function getCpuFrequency(): array
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
10
install.sh
10
install.sh
@@ -418,6 +418,7 @@ install_packages() {
|
|||||||
socat
|
socat
|
||||||
sshpass
|
sshpass
|
||||||
pigz
|
pigz
|
||||||
|
locales
|
||||||
|
|
||||||
# Security (always installed)
|
# Security (always installed)
|
||||||
fail2ban
|
fail2ban
|
||||||
@@ -518,6 +519,15 @@ install_packages() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if command -v locale-gen >/dev/null 2>&1; then
|
||||||
|
info "Configuring locales..."
|
||||||
|
if ! grep -q '^en_US.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then
|
||||||
|
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
|
||||||
|
fi
|
||||||
|
locale-gen >/dev/null 2>&1 || warn "Locale generation failed"
|
||||||
|
update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || warn "Failed to set default locale"
|
||||||
|
fi
|
||||||
|
|
||||||
# Unhold packages in case user wants to install them manually later
|
# Unhold packages in case user wants to install them manually later
|
||||||
apt-mark unhold apache2 libapache2-mod-php libapache2-mod-php8.4 2>/dev/null || true
|
apt-mark unhold apache2 libapache2-mod-php libapache2-mod-php8.4 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,13 @@ 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()
|
||||||
|
|||||||
32
tests/Unit/ServerChartsWidgetTest.php
Normal file
32
tests/Unit/ServerChartsWidgetTest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Widgets\ServerChartsWidget;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ServerChartsWidgetTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_history_seeding_includes_swap_and_disk_series(): void
|
||||||
|
{
|
||||||
|
$widget = app(ServerChartsWidget::class);
|
||||||
|
|
||||||
|
$history = $widget->getHistory(1.25, 2.5, 42.5, 12.3, [
|
||||||
|
['mount' => '/', 'used' => 1024 * 1024 * 1024],
|
||||||
|
['mount' => '/boot', 'used' => 512 * 1024 * 1024],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('swap', $history);
|
||||||
|
$this->assertArrayHasKey('iowait', $history);
|
||||||
|
$this->assertArrayHasKey('disk', $history);
|
||||||
|
$this->assertArrayHasKey('/', $history['disk']);
|
||||||
|
$this->assertArrayHasKey('/boot', $history['disk']);
|
||||||
|
$this->assertCount(count($history['labels']), $history['swap']);
|
||||||
|
$this->assertCount(count($history['labels']), $history['iowait']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user