Improve server charts and installer locales

This commit is contained in:
root
2026-01-29 03:12:33 +02:00
parent b51139f65f
commit 884b3b27a8
13 changed files with 1631 additions and 387 deletions

View File

@@ -5,7 +5,7 @@
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
Version: 0.9-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.

View File

@@ -1 +1 @@
VERSION=0.9-rc21 VERSION=0.9-rc23

View 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();
}
}

View File

@@ -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])

View File

@@ -4,11 +4,16 @@ 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';
@@ -17,36 +22,87 @@ class ServerChartsWidget extends Widget
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);
}
} }

View 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',
];
}
}

View File

@@ -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

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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

View File

@@ -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()

View 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']);
}
}