347 lines
11 KiB
PHP
347 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use Carbon\CarbonImmutable;
|
|
use DateTimeZone;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class SysstatMetrics
|
|
{
|
|
/**
|
|
* @return array{labels: array<int, string>, load: array<int, float>, iowait: array<int, float>, memory: array<int, float>, swap: array<int, float>}
|
|
*/
|
|
public function history(int $points, int $intervalSeconds, string $labelFormat): array
|
|
{
|
|
if ($points <= 0 || $intervalSeconds <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$timezone = $this->systemTimezone();
|
|
$end = CarbonImmutable::now($timezone)->second(0);
|
|
if ($intervalSeconds >= 3600) {
|
|
$end = $end->minute(0);
|
|
}
|
|
if ($intervalSeconds >= 86400) {
|
|
$end = $end->hour(0)->minute(0);
|
|
}
|
|
$start = $end->subSeconds(($points - 1) * $intervalSeconds);
|
|
$samples = $this->readSamples($start, $end);
|
|
|
|
if (empty($samples)) {
|
|
return [];
|
|
}
|
|
|
|
return $this->resample($samples, $start, $points, $intervalSeconds, $labelFormat);
|
|
}
|
|
|
|
/**
|
|
* @return array{load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}|null
|
|
*/
|
|
public function latest(): ?array
|
|
{
|
|
$timezone = $this->systemTimezone();
|
|
$end = CarbonImmutable::now($timezone);
|
|
$start = $end->subMinutes(15);
|
|
$samples = $this->readSamples($start, $end);
|
|
|
|
if (empty($samples)) {
|
|
return null;
|
|
}
|
|
|
|
$last = end($samples);
|
|
if (! is_array($last)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'load1' => (float) ($last['load1'] ?? 0),
|
|
'load5' => (float) ($last['load5'] ?? 0),
|
|
'load15' => (float) ($last['load15'] ?? 0),
|
|
'iowait' => (float) ($last['iowait'] ?? 0),
|
|
'memory' => (float) ($last['memory'] ?? 0),
|
|
'swap' => (float) ($last['swap'] ?? 0),
|
|
];
|
|
}
|
|
|
|
public function timezoneName(): string
|
|
{
|
|
return $this->systemTimezone()->getName();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{timestamp: int, load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}>
|
|
*/
|
|
private function readSamples(CarbonImmutable $start, CarbonImmutable $end): array
|
|
{
|
|
$samples = [];
|
|
$current = $start->startOfDay();
|
|
$lastDay = $end->startOfDay();
|
|
|
|
while ($current <= $lastDay) {
|
|
$file = sprintf('/var/log/sysstat/sa%s', $current->format('d'));
|
|
if (! is_readable($file)) {
|
|
$current = $current->addDay();
|
|
|
|
continue;
|
|
}
|
|
|
|
$dayStart = $current->isSameDay($start) ? $start : $current->startOfDay();
|
|
$dayEnd = $current->isSameDay($end) ? $end : $current->endOfDay();
|
|
$statistics = $this->readSadf($file, $dayStart, $dayEnd);
|
|
|
|
foreach ($statistics as $stat) {
|
|
$parsed = $this->parseSample($stat);
|
|
if ($parsed === null) {
|
|
continue;
|
|
}
|
|
$samples[] = $parsed;
|
|
}
|
|
|
|
$current = $current->addDay();
|
|
}
|
|
|
|
usort($samples, static fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']);
|
|
|
|
return $samples;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function readSadf(string $file, CarbonImmutable $start, CarbonImmutable $end): array
|
|
{
|
|
$process = new Process([
|
|
'sadf',
|
|
'-j',
|
|
'-T',
|
|
$file,
|
|
'--',
|
|
'-A',
|
|
'-s',
|
|
$start->format('H:i:s'),
|
|
'-e',
|
|
$end->format('H:i:s'),
|
|
]);
|
|
$process->run();
|
|
|
|
if (! $process->isSuccessful()) {
|
|
return [];
|
|
}
|
|
|
|
$payload = json_decode($process->getOutput(), true);
|
|
if (! is_array($payload)) {
|
|
return [];
|
|
}
|
|
|
|
$stats = $payload['sysstat']['hosts'][0]['statistics'] ?? [];
|
|
if (! is_array($stats)) {
|
|
return [];
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $stat
|
|
* @return array{timestamp: int, load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}|null
|
|
*/
|
|
private function parseSample(array $stat): ?array
|
|
{
|
|
$timestamp = $this->parseTimestamp($stat['timestamp'] ?? []);
|
|
if ($timestamp === null) {
|
|
return null;
|
|
}
|
|
|
|
$queue = $stat['queue'] ?? [];
|
|
$load1 = $this->getFloat($queue, ['ldavg-1', 'ldavg_1']) ?? 0.0;
|
|
$load5 = $this->getFloat($queue, ['ldavg-5', 'ldavg_5']) ?? 0.0;
|
|
$load15 = $this->getFloat($queue, ['ldavg-15', 'ldavg_15']) ?? 0.0;
|
|
|
|
$cpuLoad = $stat['cpu-load'] ?? $stat['cpu-load-all'] ?? [];
|
|
$iowait = $this->extractCpuMetric($cpuLoad, 'iowait');
|
|
|
|
$memory = $stat['memory'] ?? [];
|
|
$memPercent = $this->getFloat($memory, ['memused-percent', 'memused_percent', 'memused']);
|
|
if ($memPercent === null) {
|
|
$memPercent = $this->percentFromTotals($memory, 'kbmemused', 'kbmemfree', 'kbmemtotal');
|
|
}
|
|
|
|
$swap = $stat['swap'] ?? $stat['memory'] ?? [];
|
|
$swapPercent = $this->getFloat($swap, ['swpused-percent', 'swpused_percent', 'swpused']);
|
|
if ($swapPercent === null) {
|
|
$swapPercent = $this->percentFromTotals($swap, 'kbswpused', 'kbswpfree', 'kbswptotal');
|
|
}
|
|
|
|
return [
|
|
'timestamp' => $timestamp->getTimestamp(),
|
|
'load1' => (float) $load1,
|
|
'load5' => (float) $load5,
|
|
'load15' => (float) $load15,
|
|
'iowait' => (float) ($iowait ?? 0.0),
|
|
'memory' => (float) ($memPercent ?? 0.0),
|
|
'swap' => (float) ($swapPercent ?? 0.0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{labels: array<int, string>, load: array<int, float>, iowait: array<int, float>, memory: array<int, float>, swap: array<int, float>}
|
|
*/
|
|
private function resample(array $samples, CarbonImmutable $start, int $points, int $intervalSeconds, string $labelFormat): array
|
|
{
|
|
$labels = [];
|
|
$loadSeries = [];
|
|
$ioWaitSeries = [];
|
|
$memorySeries = [];
|
|
$swapSeries = [];
|
|
|
|
$index = 0;
|
|
$current = null;
|
|
$count = count($samples);
|
|
|
|
for ($i = 0; $i < $points; $i++) {
|
|
$bucketTime = $start->addSeconds($i * $intervalSeconds);
|
|
while ($index < $count && $samples[$index]['timestamp'] <= $bucketTime->getTimestamp()) {
|
|
$current = $samples[$index];
|
|
$index++;
|
|
}
|
|
|
|
$labels[] = $bucketTime->format($labelFormat);
|
|
$loadSeries[] = $current ? round((float) $current['load1'], 3) : 0.0;
|
|
$ioWaitSeries[] = $current ? round((float) $current['iowait'], 2) : 0.0;
|
|
$memorySeries[] = $current ? round((float) $current['memory'], 1) : 0.0;
|
|
$swapSeries[] = $current ? round((float) $current['swap'], 1) : 0.0;
|
|
}
|
|
|
|
return [
|
|
'labels' => $labels,
|
|
'load' => $loadSeries,
|
|
'iowait' => $ioWaitSeries,
|
|
'memory' => $memorySeries,
|
|
'swap' => $swapSeries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $timestamp
|
|
*/
|
|
private function parseTimestamp(array $timestamp): ?CarbonImmutable
|
|
{
|
|
$date = $timestamp['date'] ?? null;
|
|
$time = $timestamp['time'] ?? null;
|
|
if (! is_string($date) || ! is_string($time)) {
|
|
return null;
|
|
}
|
|
|
|
$value = CarbonImmutable::createFromFormat('Y-m-d H:i:s', $date.' '.$time, $this->systemTimezone());
|
|
if ($value === false) {
|
|
return null;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function systemTimezone(): DateTimeZone
|
|
{
|
|
static $timezone = null;
|
|
|
|
if ($timezone instanceof DateTimeZone) {
|
|
return $timezone;
|
|
}
|
|
|
|
$name = getenv('TZ') ?: null;
|
|
if (! $name && is_file('/etc/timezone')) {
|
|
$name = trim((string) file_get_contents('/etc/timezone'));
|
|
}
|
|
if (! $name && is_link('/etc/localtime')) {
|
|
$target = readlink('/etc/localtime');
|
|
if (is_string($target) && str_contains($target, '/zoneinfo/')) {
|
|
$name = substr($target, strpos($target, '/zoneinfo/') + 10);
|
|
}
|
|
}
|
|
if (! $name) {
|
|
$name = config('app.timezone') ?: date_default_timezone_get();
|
|
}
|
|
|
|
try {
|
|
$timezone = new DateTimeZone($name);
|
|
} catch (\Exception $e) {
|
|
$timezone = new DateTimeZone('UTC');
|
|
}
|
|
|
|
return $timezone;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $source
|
|
*/
|
|
private function getFloat(array $source, array $keys): ?float
|
|
{
|
|
foreach ($keys as $key) {
|
|
if (array_key_exists($key, $source) && is_numeric($source[$key])) {
|
|
return (float) $source[$key];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $source
|
|
*/
|
|
private function extractCpuMetric(mixed $source, string $metric): ?float
|
|
{
|
|
if (is_array($source) && array_key_exists($metric, $source) && is_numeric($source[$metric])) {
|
|
return (float) $source[$metric];
|
|
}
|
|
|
|
if (is_array($source)) {
|
|
$entries = $source;
|
|
if (array_values($entries) === $entries) {
|
|
foreach ($entries as $entry) {
|
|
if (! is_array($entry)) {
|
|
continue;
|
|
}
|
|
$cpu = $entry['cpu'] ?? $entry['cpu-load'] ?? null;
|
|
if ($cpu === 'all' || $cpu === 0 || $cpu === '0') {
|
|
if (isset($entry[$metric]) && is_numeric($entry[$metric])) {
|
|
return (float) $entry[$metric];
|
|
}
|
|
}
|
|
}
|
|
foreach ($entries as $entry) {
|
|
if (is_array($entry) && isset($entry[$metric]) && is_numeric($entry[$metric])) {
|
|
return (float) $entry[$metric];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $source
|
|
*/
|
|
private function percentFromTotals(array $source, string $usedKey, string $freeKey, string $totalKey): ?float
|
|
{
|
|
$used = $source[$usedKey] ?? null;
|
|
$free = $source[$freeKey] ?? null;
|
|
$total = $source[$totalKey] ?? null;
|
|
|
|
if (is_numeric($total) && (float) $total > 0) {
|
|
$usedValue = is_numeric($used) ? (float) $used : null;
|
|
if ($usedValue === null && is_numeric($free)) {
|
|
$usedValue = (float) $total - (float) $free;
|
|
}
|
|
if ($usedValue !== null) {
|
|
return round(($usedValue / (float) $total) * 100, 2);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|