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