Files
jabali-panel/app/Filament/Admin/Widgets/ServerChartsWidget.php

576 lines
20 KiB
PHP

<?php
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';
protected ?string $pollingInterval = '10s';
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,
'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;
$overview = $agent->metricsOverview();
$disk = $this->diskSnapshot;
if (empty($disk)) {
$disk = $this->fetchDiskSnapshot($agent);
$this->diskSnapshot = $disk;
}
$cpu = $overview['cpu'] ?? [];
$memory = $overview['memory'] ?? [];
$swap = $memory['swap'] ?? [];
$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',
],
'memory' => [
'usage' => round($memUsage, 1),
'used' => $memory['used'] ?? 0,
'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, 'iowait' => 0, 'cores' => 0, 'model' => 'Error'],
'memory' => ['usage' => 0, 'used' => 0, 'total' => 0, 'free' => 0, 'cached' => 0],
'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);
}
}