1798 lines
51 KiB
PHP
1798 lines
51 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
$controlApiLibrary = defined('CONTROL_API_LIBRARY') && CONTROL_API_LIBRARY;
|
|
|
|
if (!$controlApiLibrary && session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
|
|
require_once __DIR__ . '/../config/config.php';
|
|
|
|
if (!$controlApiLibrary && !signed_in()) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'login_required',
|
|
], 401);
|
|
}
|
|
|
|
function bytes_human(int|float $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
$v = (float)$bytes;
|
|
$i = 0;
|
|
|
|
while ($v >= 1024 && $i < count($units) - 1) {
|
|
$v /= 1024;
|
|
$i++;
|
|
}
|
|
|
|
return ($i === 0 ? (string)(int)$v : number_format($v, 2)) . ' ' . $units[$i];
|
|
}
|
|
|
|
function command_short(string $value, int $limit = 120): string
|
|
{
|
|
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
|
|
|
if ($value === '') {
|
|
return 'N/A';
|
|
}
|
|
|
|
return mb_strlen($value) > $limit
|
|
? mb_substr($value, 0, $limit - 1) . '...'
|
|
: $value;
|
|
}
|
|
|
|
function human_seconds(int $s): string
|
|
{
|
|
$d = intdiv($s, 86400);
|
|
$s %= 86400;
|
|
|
|
$h = intdiv($s, 3600);
|
|
$s %= 3600;
|
|
|
|
$m = intdiv($s, 60);
|
|
|
|
return ($d > 0 ? $d . 'd ' : '') . sprintf('%02dh %02dm', $h, $m);
|
|
}
|
|
|
|
function human_remaining_seconds(int $seconds): string
|
|
{
|
|
if ($seconds <= 0) {
|
|
return '-';
|
|
}
|
|
|
|
$hours = intdiv($seconds, 3600);
|
|
$minutes = intdiv($seconds % 3600, 60);
|
|
|
|
if ($hours > 0) {
|
|
return sprintf('%dh %02dm', $hours, $minutes);
|
|
}
|
|
|
|
return sprintf('%dm', max(1, $minutes));
|
|
}
|
|
|
|
function dmesg_log(): array
|
|
{
|
|
$path = '/tmp/dmesg.log';
|
|
|
|
if (!is_readable($path)) {
|
|
return [
|
|
'path' => $path,
|
|
'available' => false,
|
|
'lines' => [],
|
|
'line_count' => 0,
|
|
'size_bytes' => 0,
|
|
'updated_at' => null,
|
|
'message' => 'dmesg log is not available yet.',
|
|
];
|
|
}
|
|
|
|
$size = filesize($path) ?: 0;
|
|
$chunk = file_get_contents($path);
|
|
if ($chunk === false) {
|
|
return [
|
|
'path' => $path,
|
|
'available' => false,
|
|
'lines' => [],
|
|
'line_count' => 0,
|
|
'size_bytes' => $size,
|
|
'updated_at' => null,
|
|
'message' => 'failed to open dmesg log.',
|
|
];
|
|
}
|
|
|
|
$lines = array_values(array_filter(
|
|
preg_split('/\R/', $chunk) ?: [],
|
|
static fn($line): bool => trim((string)$line) !== ''
|
|
));
|
|
|
|
return [
|
|
'path' => $path,
|
|
'available' => true,
|
|
'lines' => array_reverse($lines),
|
|
'line_count' => count($lines),
|
|
'size_bytes' => $size,
|
|
'updated_at' => date('Y-m-d H:i:s', filemtime($path) ?: time()),
|
|
];
|
|
}
|
|
|
|
function fan_paths(): array
|
|
{
|
|
$candidates = glob('/sys/devices/platform/cooling_fan/hwmon/hwmon*/pwm1') ?: [];
|
|
$candidates = array_merge(
|
|
$candidates,
|
|
glob('/sys/class/hwmon/hwmon*/pwm1') ?: []
|
|
);
|
|
|
|
foreach ($candidates as $pwm) {
|
|
$dir = dirname($pwm);
|
|
|
|
return [
|
|
'base' => $dir,
|
|
'pwm' => $pwm,
|
|
'enable' => $dir . '/pwm1_enable',
|
|
'rpm' => $dir . '/fan1_input',
|
|
'name' => first_readable([$dir . '/name']) ?: 'cooling_fan',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'base' => 'N/A',
|
|
'pwm' => 'N/A',
|
|
'enable' => 'N/A',
|
|
'rpm' => 'N/A',
|
|
'name' => 'N/A',
|
|
];
|
|
}
|
|
|
|
function read_int_file(string $file): int
|
|
{
|
|
if ($file === '' || $file === 'N/A' || !is_readable($file)) {
|
|
return 0;
|
|
}
|
|
|
|
$raw = trim((string)@file_get_contents($file));
|
|
|
|
return is_numeric($raw) ? (int)$raw : 0;
|
|
}
|
|
|
|
function cpu_temp(): float
|
|
{
|
|
$raw = first_readable([
|
|
'/sys/class/thermal/thermal_zone0/temp',
|
|
'/sys/devices/virtual/thermal/thermal_zone0/temp',
|
|
]);
|
|
|
|
if ($raw !== '' && is_numeric($raw)) {
|
|
return round(((float)$raw) / 1000, 2);
|
|
}
|
|
|
|
$vc = sh(['/usr/bin/vcgencmd', 'measure_temp'], false, 3)['out'];
|
|
|
|
if (preg_match('/([0-9.]+)/', $vc, $m)) {
|
|
return round((float)$m[1], 2);
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
function fan_target_pwm(float $temp): int
|
|
{
|
|
if ($temp >= 80) {
|
|
return 255;
|
|
}
|
|
|
|
if ($temp <= 50) {
|
|
return 0;
|
|
}
|
|
|
|
$ratio = ($temp - 50) / 30;
|
|
|
|
return max(0, min(255, (int)round($ratio * 255)));
|
|
}
|
|
|
|
function fan_ramped_pwm(int $current, int $desired, float $temp): int
|
|
{
|
|
if ($temp >= 80) {
|
|
return $desired;
|
|
}
|
|
|
|
if ($desired > $current) {
|
|
return min($desired, $current + 2);
|
|
}
|
|
|
|
if ($desired < $current) {
|
|
return max($desired, $current - 2);
|
|
}
|
|
|
|
return $desired;
|
|
}
|
|
|
|
function write_sys_value(string $path, int $value): bool
|
|
{
|
|
if ($path === '' || $path === 'N/A') {
|
|
return false;
|
|
}
|
|
|
|
if (@file_put_contents($path, $value . "\n", LOCK_EX) !== false) {
|
|
return true;
|
|
}
|
|
|
|
$cmd = 'printf ' . escapeshellarg($value . "\n") . ' > ' . escapeshellarg($path);
|
|
return sh(['/bin/sh', '-lc', $cmd], true, 5)['code'] === 0;
|
|
}
|
|
|
|
function apply_fan_policy(): array
|
|
{
|
|
$state = get_control_state();
|
|
$paths = fan_paths();
|
|
|
|
$temp = cpu_temp();
|
|
|
|
$mode = (string)($state['mode'] ?? 'auto');
|
|
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
|
|
$mode = 'auto';
|
|
}
|
|
|
|
$manualPwm = max(0, min(255, (int)($state['manual_pwm'] ?? 120)));
|
|
|
|
$currentPwm = read_int_file($paths['pwm']);
|
|
$desired = match ($mode) {
|
|
'manual' => $manualPwm,
|
|
'off' => 0,
|
|
default => fan_target_pwm($temp),
|
|
};
|
|
$target = $mode === 'auto'
|
|
? fan_ramped_pwm($currentPwm, $desired, $temp)
|
|
: $desired;
|
|
|
|
$enableOk = write_sys_value($paths['enable'], $mode === 'off' ? 0 : 1);
|
|
$pwmOk = write_sys_value($paths['pwm'], $target);
|
|
|
|
usleep(60000);
|
|
|
|
$actualPwm = read_int_file($paths['pwm']);
|
|
|
|
$rpm = read_int_file($paths['rpm']);
|
|
|
|
return [
|
|
'mode' => $mode,
|
|
'target_pwm' => $target,
|
|
'actual_pwm' => $actualPwm,
|
|
'temp_c' => $temp,
|
|
'rpm' => $rpm,
|
|
'ok' => $enableOk && $pwmOk,
|
|
'paths' => $paths,
|
|
'enable_value' => first_readable([$paths['enable']]) ?: 'N/A',
|
|
];
|
|
}
|
|
|
|
function mem_info(): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach (@file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
|
if (preg_match('/^([^:]+):\s+(\d+)/', $line, $m)) {
|
|
$rows[$m[1]] = (int)$m[2];
|
|
}
|
|
}
|
|
|
|
$total = (int)($rows['MemTotal'] ?? 0);
|
|
$available = (int)($rows['MemAvailable'] ?? 0);
|
|
$used = max(0, $total - $available);
|
|
|
|
$swapTotal = (int)($rows['SwapTotal'] ?? 0);
|
|
$swapFree = (int)($rows['SwapFree'] ?? 0);
|
|
|
|
return [
|
|
'total_mb' => round($total / 1024),
|
|
'used_mb' => round($used / 1024),
|
|
'free_mb' => round($available / 1024),
|
|
'percent' => $total > 0 ? round($used / $total * 100, 1) : 0,
|
|
'swap_total_mb' => round($swapTotal / 1024),
|
|
'swap_used_mb' => round(max(0, $swapTotal - $swapFree) / 1024),
|
|
];
|
|
}
|
|
|
|
function disk_info(string $path = '/var/www/control'): array
|
|
{
|
|
$total = @disk_total_space($path) ?: 0;
|
|
$free = @disk_free_space($path) ?: 0;
|
|
|
|
if ($total <= 0 || $free <= 0) {
|
|
$out = trim(sh(['/bin/df', '-kP', $path], false, 3)['out']);
|
|
$lines = preg_split('/\R/', $out);
|
|
$parts = isset($lines[1]) ? preg_split('/\s+/', trim($lines[1])) : [];
|
|
|
|
if (count($parts) >= 6) {
|
|
$total = (int)$parts[1] * 1024;
|
|
$free = (int)$parts[3] * 1024;
|
|
}
|
|
}
|
|
|
|
$used = max(0, $total - $free);
|
|
|
|
return [
|
|
'path' => '/',
|
|
'total_kb' => (int)round($total / 1024),
|
|
'used_kb' => (int)round($used / 1024),
|
|
'free_kb' => (int)round($free / 1024),
|
|
'percent' => $total > 0 ? round($used / $total * 100, 1) : 0,
|
|
];
|
|
}
|
|
|
|
function active_user_info(): array
|
|
{
|
|
$out = trim(sh(['/usr/bin/who'], false, 3)['out']);
|
|
|
|
if ($out === '') {
|
|
return [
|
|
'users' => 0,
|
|
'sessions' => 0,
|
|
'names' => '',
|
|
'display' => '0 users / 0 sessions',
|
|
];
|
|
}
|
|
|
|
$lines = preg_split('/\R/', $out) ?: [];
|
|
$lines = array_values(array_filter($lines, fn($line) => trim($line) !== ''));
|
|
|
|
$names = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$parts = preg_split('/\s+/', trim($line));
|
|
if (!empty($parts[0])) {
|
|
$names[$parts[0]] = true;
|
|
}
|
|
}
|
|
|
|
$userNames = array_keys($names);
|
|
|
|
return [
|
|
'users' => count($userNames),
|
|
'sessions' => count($lines),
|
|
'names' => implode(', ', $userNames),
|
|
'display' => count($userNames) . ' users / ' . count($lines) . ' sessions',
|
|
];
|
|
}
|
|
|
|
function os_info(): array
|
|
{
|
|
$os = [];
|
|
|
|
foreach (@file('/etc/os-release', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
|
if (preg_match('/^([A-Z_]+)=(.*)$/', $line, $m)) {
|
|
$os[$m[1]] = trim($m[2], '"');
|
|
}
|
|
}
|
|
|
|
$uptimeRaw = trim((string)@file_get_contents('/proc/uptime'));
|
|
$uptimeSec = $uptimeRaw !== ''
|
|
? (int)floor((float)explode(' ', $uptimeRaw)[0])
|
|
: 0;
|
|
|
|
return [
|
|
'hostname' => gethostname() ?: 'N/A',
|
|
'os' => $os['PRETTY_NAME'] ?? 'N/A',
|
|
'kernel' => php_uname('r'),
|
|
'arch' => php_uname('m'),
|
|
'model' => trim(@file_get_contents('/proc/device-tree/model') ?: '') ?: 'N/A',
|
|
'uptime' => human_seconds($uptimeSec),
|
|
'uptime_seconds' => $uptimeSec,
|
|
];
|
|
}
|
|
|
|
function network_info(): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach (glob('/sys/class/net/*') ?: [] as $dir) {
|
|
$name = basename($dir);
|
|
|
|
if ($name === 'lo') {
|
|
continue;
|
|
}
|
|
|
|
$rx = (int)first_readable([$dir . '/statistics/rx_bytes']);
|
|
$tx = (int)first_readable([$dir . '/statistics/tx_bytes']);
|
|
|
|
$rows[] = [
|
|
'name' => $name,
|
|
'state' => first_readable([$dir . '/operstate']) ?: 'unknown',
|
|
'carrier' => first_readable([$dir . '/carrier']) === '1' ? 'up' : 'down',
|
|
'mac' => first_readable([$dir . '/address']) ?: 'N/A',
|
|
'mtu' => first_readable([$dir . '/mtu']) ?: 'N/A',
|
|
'rx_bytes' => $rx,
|
|
'tx_bytes' => $tx,
|
|
'rx_human' => bytes_human($rx),
|
|
'tx_human' => bytes_human($tx),
|
|
'ipv4' => trim(sh(['/usr/sbin/ip', '-4', '-o', 'addr', 'show', 'dev', $name], false, 3)['out']) ?: 'N/A',
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function latest_sensor(): array
|
|
{
|
|
$stmt = db()->query("
|
|
SELECT *
|
|
FROM sensor_logs
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
");
|
|
|
|
return $stmt->fetch() ?: [];
|
|
}
|
|
|
|
function sensor_history(int $limit = 240): array
|
|
{
|
|
$limit = max(1, min(1500, $limit));
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
sl.recorded_at AS time,
|
|
sl.cpu_temp_c AS temp_c,
|
|
sl.fan_rpm,
|
|
sl.fan_efficiency,
|
|
sl.rp1_temp_c,
|
|
sl.cpu_voltage,
|
|
sl.cpu_watts,
|
|
sl.battery_voltage,
|
|
sl.battery_percent,
|
|
sl.pwm_value AS fan_pwm,
|
|
sl.pwm_percent,
|
|
sl.pwm_mode,
|
|
sl.cpu_load_1,
|
|
sl.cpu_load_5,
|
|
sl.cpu_load_15,
|
|
sl.disk_total_kb,
|
|
sl.disk_used_kb,
|
|
sl.disk_free_kb,
|
|
sl.mem_total_mb,
|
|
sl.mem_used_mb,
|
|
CASE
|
|
WHEN sl.mem_total_mb > 0 THEN ROUND(sl.mem_used_mb / sl.mem_total_mb * 100, 1)
|
|
ELSE 0
|
|
END AS mem_percent
|
|
FROM sensor_logs sl
|
|
ORDER BY sl.id DESC
|
|
LIMIT {$limit}
|
|
");
|
|
|
|
return array_reverse($stmt->fetchAll());
|
|
}
|
|
|
|
function battery_remaining_estimate(array $battery, array $history): array
|
|
{
|
|
$percent = $battery['percent'] ?? null;
|
|
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
|
return [
|
|
'display' => '-',
|
|
'seconds' => null,
|
|
'source' => 'battery_soc_missing',
|
|
'avg_watts' => null,
|
|
];
|
|
}
|
|
|
|
$percent = max(0.0, min(100.0, (float)$percent));
|
|
$wattValues = [];
|
|
|
|
foreach (array_slice($history, -90) as $row) {
|
|
$watts = $row['cpu_watts'] ?? null;
|
|
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
|
|
$wattValues[] = (float)$watts;
|
|
}
|
|
}
|
|
|
|
$avgWatts = $wattValues === []
|
|
? null
|
|
: array_sum($wattValues) / count($wattValues);
|
|
|
|
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
|
|
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
|
|
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
|
|
|
|
return [
|
|
'display' => human_remaining_seconds($seconds),
|
|
'seconds' => $seconds,
|
|
'source' => 'avg_watts',
|
|
'avg_watts' => round($avgWatts, 3),
|
|
'capacity_wh' => BATTERY_CAPACITY_WH,
|
|
];
|
|
}
|
|
|
|
$socRows = [];
|
|
foreach ($history as $row) {
|
|
$soc = $row['battery_percent'] ?? null;
|
|
$time = strtotime((string)($row['time'] ?? ''));
|
|
if ($soc !== null && $soc !== '' && is_numeric($soc) && $time > 0) {
|
|
$socRows[] = [
|
|
'time' => $time,
|
|
'soc' => (float)$soc,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (count($socRows) >= 30) {
|
|
$first = $socRows[0];
|
|
$last = $socRows[count($socRows) - 1];
|
|
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
|
|
$drop = (float)$first['soc'] - (float)$last['soc'];
|
|
|
|
if ($elapsed >= 120 && $drop >= 0.2) {
|
|
$dropPerSecond = $drop / $elapsed;
|
|
$seconds = (int)round($percent / $dropPerSecond);
|
|
|
|
return [
|
|
'display' => human_remaining_seconds($seconds),
|
|
'seconds' => $seconds,
|
|
'source' => 'soc_trend',
|
|
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'display' => '-',
|
|
'seconds' => null,
|
|
'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing',
|
|
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
|
|
];
|
|
}
|
|
|
|
function add_battery_remaining_history(array $history): array
|
|
{
|
|
$wattWindow = [];
|
|
|
|
foreach ($history as $index => $row) {
|
|
$watts = $row['cpu_watts'] ?? null;
|
|
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
|
|
$wattWindow[] = (float)$watts;
|
|
if (count($wattWindow) > 90) {
|
|
array_shift($wattWindow);
|
|
}
|
|
}
|
|
|
|
$percent = $row['battery_percent'] ?? null;
|
|
$avgWatts = $wattWindow === []
|
|
? null
|
|
: array_sum($wattWindow) / count($wattWindow);
|
|
|
|
$history[$index]['battery_remaining_seconds'] = null;
|
|
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0 && is_numeric($percent)) {
|
|
$safePercent = max(0.0, min(100.0, (float)$percent));
|
|
$remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100);
|
|
$history[$index]['battery_remaining_seconds'] = (int)round(($remainingWh / $avgWatts) * 3600);
|
|
}
|
|
}
|
|
|
|
return $history;
|
|
}
|
|
|
|
function process_service_name(int $pid): string
|
|
{
|
|
$cgroup = @file('/proc/' . $pid . '/cgroup', FILE_IGNORE_NEW_LINES) ?: [];
|
|
|
|
foreach ($cgroup as $line) {
|
|
if (preg_match('/([^\/:]+\.service)(?:\/|$)/', $line, $m)) {
|
|
return $m[1];
|
|
}
|
|
}
|
|
|
|
return 'N/A';
|
|
}
|
|
|
|
function process_args(int $pid): string
|
|
{
|
|
$raw = @file_get_contents('/proc/' . $pid . '/cmdline');
|
|
|
|
if (is_string($raw) && $raw !== '') {
|
|
return command_short(str_replace("\0", ' ', trim($raw, "\0")));
|
|
}
|
|
|
|
$comm = trim((string)@file_get_contents('/proc/' . $pid . '/comm'));
|
|
|
|
return $comm !== '' ? $comm : 'N/A';
|
|
}
|
|
|
|
function process_resource_data(int $limit = 6): array
|
|
{
|
|
$limit = max(3, min(12, $limit));
|
|
|
|
$ps = sh([
|
|
'/bin/ps',
|
|
'-eo',
|
|
'pid=,ppid=,user=,pcpu=,pmem=,rss=,comm=,args=',
|
|
'--sort=-pcpu',
|
|
], false, 4)['out'];
|
|
|
|
$cpu = [];
|
|
$mem = [];
|
|
|
|
foreach (preg_split('/\R/', trim($ps)) ?: [] as $line) {
|
|
$line = trim($line);
|
|
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
$parts = preg_split('/\s+/', $line, 8);
|
|
|
|
if (count($parts) < 8 || !ctype_digit($parts[0])) {
|
|
continue;
|
|
}
|
|
|
|
$pid = (int)$parts[0];
|
|
$row = [
|
|
'pid' => $pid,
|
|
'ppid' => (int)$parts[1],
|
|
'user' => $parts[2],
|
|
'cpu_percent' => round((float)$parts[3], 1),
|
|
'mem_percent' => round((float)$parts[4], 1),
|
|
'rss_mb' => round(((int)$parts[5]) / 1024, 1),
|
|
'name' => $parts[6],
|
|
'service' => process_service_name($pid),
|
|
'command' => command_short($parts[7]),
|
|
];
|
|
|
|
if ($row['cpu_percent'] > 0 || count($cpu) < $limit) {
|
|
$cpu[] = $row;
|
|
}
|
|
|
|
$mem[] = $row;
|
|
}
|
|
|
|
usort($mem, fn($a, $b) => ($b['mem_percent'] <=> $a['mem_percent']) ?: ($b['rss_mb'] <=> $a['rss_mb']));
|
|
|
|
return [
|
|
'cpu' => array_slice($cpu, 0, $limit),
|
|
'memory' => array_slice($mem, 0, $limit),
|
|
];
|
|
}
|
|
|
|
function notice_process_signature(array $processes): string
|
|
{
|
|
$cpu = null;
|
|
foreach (($processes['cpu'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
$service = (string)($row['service'] ?? '');
|
|
if (
|
|
str_contains($command, '/bin/ps')
|
|
|| str_contains($command, 'api.php')
|
|
|| str_contains($command, 'php-fpm')
|
|
|| $service === 'fanpanel-apply.service'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cpu = $row;
|
|
break;
|
|
}
|
|
|
|
$mem = null;
|
|
foreach (($processes['memory'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$mem = $row;
|
|
break;
|
|
}
|
|
|
|
return hash('sha256', json_encode([
|
|
'cpu' => $cpu === null ? null : [
|
|
'pid' => $cpu['pid'] ?? null,
|
|
'service' => $cpu['service'] ?? null,
|
|
'command' => $cpu['command'] ?? $cpu['name'] ?? null,
|
|
],
|
|
'mem' => $mem === null ? null : [
|
|
'pid' => $mem['pid'] ?? null,
|
|
'service' => $mem['service'] ?? null,
|
|
'command' => $mem['command'] ?? $mem['name'] ?? null,
|
|
],
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
function trimmed_average(array $values, float $fallback): float
|
|
{
|
|
$values = array_values(array_filter(
|
|
$values,
|
|
fn($value) => is_numeric($value) && (float)$value > 0
|
|
));
|
|
|
|
if (count($values) < 5) {
|
|
return $fallback;
|
|
}
|
|
|
|
sort($values, SORT_NUMERIC);
|
|
$trim = (int)floor(count($values) * 0.1);
|
|
if ($trim > 0 && count($values) > $trim * 2) {
|
|
$values = array_slice($values, $trim, count($values) - ($trim * 2));
|
|
}
|
|
|
|
return array_sum($values) / max(1, count($values));
|
|
}
|
|
|
|
function notice_rolling_baseline(array $history, float $temp, float $rpm, float $pwm, array $state = []): array
|
|
{
|
|
$rows = array_slice($history, -180);
|
|
if (count($rows) > 3) {
|
|
$rows = array_slice($rows, 0, -3);
|
|
}
|
|
|
|
$temps = [];
|
|
$rpms = [];
|
|
$pwms = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$rowTemp = (float)($row['temp_c'] ?? 0);
|
|
$rowRpm = (float)($row['fan_rpm'] ?? 0);
|
|
$rowPwm = (float)($row['fan_pwm'] ?? 0);
|
|
|
|
if ($rowTemp > 0) $temps[] = $rowTemp;
|
|
if ($rowRpm > 0) $rpms[] = $rowRpm;
|
|
if ($rowPwm > 0) $pwms[] = $rowPwm;
|
|
}
|
|
|
|
$fallbackTemp = (float)($state['baseline_temp'] ?? 0) > 0 ? (float)$state['baseline_temp'] : $temp;
|
|
$fallbackRpm = (float)($state['baseline_rpm'] ?? 0) > 0 ? (float)$state['baseline_rpm'] : $rpm;
|
|
$fallbackPwm = (float)($state['baseline_pwm'] ?? 0) > 0 ? (float)$state['baseline_pwm'] : $pwm;
|
|
|
|
return [
|
|
'temp' => trimmed_average($temps, $fallbackTemp),
|
|
'rpm' => trimmed_average($rpms, $fallbackRpm),
|
|
'pwm' => trimmed_average($pwms, $fallbackPwm),
|
|
'samples' => count($rows),
|
|
];
|
|
}
|
|
|
|
function fan_spike_analysis(array $history, array $fan, array $system, array $processes = []): array
|
|
{
|
|
$rpm = (float)($fan['rpm'] ?? 0);
|
|
$pwm = (float)($fan['pwm'] ?? 0);
|
|
$temp = (float)($system['temp_c'] ?? 0);
|
|
$state = system_notice_state();
|
|
$rollingBaseline = notice_rolling_baseline($history, $temp, $rpm, $pwm, $state);
|
|
|
|
$currentState = (string)($state['state'] ?? 'normal');
|
|
if (!in_array($currentState, ['normal', 'alert'], true)) {
|
|
$currentState = 'normal';
|
|
}
|
|
|
|
$bootEpoch = (int)(time() - max(0, (int)($system['uptime_seconds'] ?? 0)));
|
|
$alertStartedEpoch = strtotime((string)($state['alert_started_at'] ?? '')) ?: 0;
|
|
if ($currentState === 'alert' && $bootEpoch > 0 && ($alertStartedEpoch === 0 || $alertStartedEpoch < $bootEpoch)) {
|
|
$currentState = 'normal';
|
|
}
|
|
|
|
$rpmAvg = $currentState === 'alert' ? (float)($state['baseline_rpm'] ?? 0) : (float)$rollingBaseline['rpm'];
|
|
$pwmAvg = $currentState === 'alert' ? (float)($state['baseline_pwm'] ?? 0) : (float)$rollingBaseline['pwm'];
|
|
$tempAvg = $currentState === 'alert' ? (float)($state['baseline_temp'] ?? 0) : (float)$rollingBaseline['temp'];
|
|
|
|
if ($rpmAvg <= 0) $rpmAvg = $rpm;
|
|
if ($pwmAvg <= 0) $pwmAvg = $pwm;
|
|
if ($tempAvg <= 0) $tempAvg = $temp;
|
|
|
|
$rpmDelta = $rpm - $rpmAvg;
|
|
$pwmDelta = $pwm - $pwmAvg;
|
|
$tempDelta = $temp - $tempAvg;
|
|
$rollingRpmDelta = $rpm - (float)$rollingBaseline['rpm'];
|
|
$rollingTempDelta = $temp - (float)$rollingBaseline['temp'];
|
|
|
|
$mode = (string)($fan['mode'] ?? '');
|
|
if ($mode === 'off') {
|
|
$offBaselineReason = 'fan_off_baseline';
|
|
$hasOffBaseline = (string)($state['active_reason'] ?? '') === $offBaselineReason;
|
|
|
|
$tempAvg = $hasOffBaseline && (float)($state['baseline_temp'] ?? 0) > 0
|
|
? (float)$state['baseline_temp']
|
|
: $temp;
|
|
$rpmAvg = $hasOffBaseline && (float)($state['baseline_rpm'] ?? 0) >= 0
|
|
? (float)$state['baseline_rpm']
|
|
: $rpm;
|
|
$pwmAvg = $hasOffBaseline && (float)($state['baseline_pwm'] ?? 0) >= 0
|
|
? (float)$state['baseline_pwm']
|
|
: $pwm;
|
|
|
|
if (!$hasOffBaseline) {
|
|
save_system_notice_state('normal', $tempAvg, $rpmAvg, $pwmAvg, $offBaselineReason);
|
|
}
|
|
|
|
return [
|
|
'active' => false,
|
|
'summary' => 'Fan off baseline is fixed.',
|
|
'rpm_delta' => round($rpm - $rpmAvg, 1),
|
|
'pwm_delta' => round($pwm - $pwmAvg, 1),
|
|
'temp_delta' => round($temp - $tempAvg, 1),
|
|
'rpm_avg' => round($rpmAvg, 1),
|
|
'pwm_avg' => round($pwmAvg, 1),
|
|
'temp_avg' => round($tempAvg, 1),
|
|
'notice_state' => 'normal',
|
|
'baseline_source' => 'frozen_off',
|
|
'baseline_samples' => (int)$rollingBaseline['samples'],
|
|
];
|
|
}
|
|
|
|
$rpmExpected = $mode !== 'off' && ($pwm >= 20 || $pwmAvg >= 20);
|
|
|
|
$reasons = [];
|
|
if ($rpmExpected && abs($rpmDelta) >= 1000) $reasons[] = 'RPM ' . signed_delta_text($rpmDelta);
|
|
if (abs($tempDelta) >= 3.0) $reasons[] = 'TEMP ' . signed_delta_text($tempDelta, 'C');
|
|
|
|
$spiking = false;
|
|
|
|
if ($currentState === 'normal') {
|
|
$spiking = $reasons !== [];
|
|
if ($spiking) {
|
|
save_system_notice_state(
|
|
'alert',
|
|
$tempAvg,
|
|
$rpmAvg,
|
|
$pwmAvg,
|
|
implode(', ', $reasons),
|
|
true,
|
|
$tempDelta,
|
|
$rpmDelta,
|
|
notice_process_signature($processes)
|
|
);
|
|
} else {
|
|
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
|
}
|
|
} else {
|
|
$recovered = (abs($rpmDelta) <= 500 && abs($tempDelta) <= 1.5)
|
|
|| (abs($rollingRpmDelta) <= 500 && abs($rollingTempDelta) <= 1.5);
|
|
if ($recovered || $reasons === []) {
|
|
$currentState = 'normal';
|
|
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
|
$tempAvg = (float)$rollingBaseline['temp'];
|
|
$rpmAvg = (float)$rollingBaseline['rpm'];
|
|
$pwmAvg = (float)$rollingBaseline['pwm'];
|
|
$rpmDelta = $rpm - $rpmAvg;
|
|
$pwmDelta = $pwm - $pwmAvg;
|
|
$tempDelta = $temp - $tempAvg;
|
|
} else {
|
|
save_system_notice_state(
|
|
'alert',
|
|
$tempAvg,
|
|
$rpmAvg,
|
|
$pwmAvg,
|
|
implode(', ', $reasons),
|
|
false,
|
|
$tempDelta,
|
|
$rpmDelta,
|
|
notice_process_signature($processes)
|
|
);
|
|
}
|
|
}
|
|
|
|
$summary = 'No system notice detected in recent samples.';
|
|
if ($spiking) {
|
|
$summary = 'System notice: ' . implode(', ', $reasons);
|
|
} elseif ($currentState === 'alert' && $reasons !== []) {
|
|
$summary = 'System notice active: ' . implode(', ', $reasons);
|
|
}
|
|
|
|
return [
|
|
'active' => $spiking,
|
|
'summary' => $summary,
|
|
'rpm_delta' => round($rpmDelta, 1),
|
|
'pwm_delta' => round($pwmDelta, 1),
|
|
'temp_delta' => round($tempDelta, 1),
|
|
'rpm_avg' => round($rpmAvg, 1),
|
|
'pwm_avg' => round($pwmAvg, 1),
|
|
'temp_avg' => round($tempAvg, 1),
|
|
'notice_state' => $spiking ? 'alert' : $currentState,
|
|
'baseline_source' => $currentState === 'alert' ? 'frozen_alert' : 'rolling',
|
|
'baseline_samples' => (int)$rollingBaseline['samples'],
|
|
];
|
|
}
|
|
|
|
function system_notice_state(): array
|
|
{
|
|
$stmt = db()->query("
|
|
SELECT
|
|
state,
|
|
baseline_temp,
|
|
baseline_rpm,
|
|
baseline_pwm,
|
|
active_reason,
|
|
active_temp_delta,
|
|
active_rpm_delta,
|
|
process_signature,
|
|
alert_started_at,
|
|
last_alert_at,
|
|
updated_at
|
|
FROM system_notice_state
|
|
WHERE id = 1
|
|
LIMIT 1
|
|
");
|
|
|
|
return $stmt->fetch() ?: [];
|
|
}
|
|
|
|
function save_system_notice_state(
|
|
string $state,
|
|
float $baselineTemp,
|
|
float $baselineRpm,
|
|
float $baselinePwm,
|
|
?string $reason = null,
|
|
bool $alertStarted = false,
|
|
float $activeTempDelta = 0.0,
|
|
float $activeRpmDelta = 0.0,
|
|
?string $processSignature = null
|
|
): void {
|
|
$stmt = db()->prepare("
|
|
INSERT INTO system_notice_state (
|
|
id,
|
|
state,
|
|
baseline_temp,
|
|
baseline_rpm,
|
|
baseline_pwm,
|
|
active_reason,
|
|
active_temp_delta,
|
|
active_rpm_delta,
|
|
process_signature,
|
|
alert_started_at,
|
|
last_alert_at
|
|
) VALUES (
|
|
1,
|
|
:state,
|
|
:baseline_temp,
|
|
:baseline_rpm,
|
|
:baseline_pwm,
|
|
:active_reason,
|
|
:active_temp_delta,
|
|
:active_rpm_delta,
|
|
:process_signature,
|
|
IF(:alert_started = 1, CURRENT_TIMESTAMP, NULL),
|
|
IF(:alert_started_last = 1, CURRENT_TIMESTAMP, NULL)
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
state = VALUES(state),
|
|
baseline_temp = VALUES(baseline_temp),
|
|
baseline_rpm = VALUES(baseline_rpm),
|
|
baseline_pwm = VALUES(baseline_pwm),
|
|
active_reason = VALUES(active_reason),
|
|
active_temp_delta = VALUES(active_temp_delta),
|
|
active_rpm_delta = VALUES(active_rpm_delta),
|
|
process_signature = VALUES(process_signature),
|
|
alert_started_at = IF(:alert_started_update = 1, CURRENT_TIMESTAMP, alert_started_at),
|
|
last_alert_at = IF(:alert_started_last_update = 1, CURRENT_TIMESTAMP, last_alert_at),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
");
|
|
|
|
$stmt->execute([
|
|
':state' => in_array($state, ['normal', 'alert'], true) ? $state : 'normal',
|
|
':baseline_temp' => round($baselineTemp, 2),
|
|
':baseline_rpm' => round($baselineRpm, 2),
|
|
':baseline_pwm' => round($baselinePwm, 2),
|
|
':active_reason' => $reason,
|
|
':active_temp_delta' => round($activeTempDelta, 2),
|
|
':active_rpm_delta' => round($activeRpmDelta, 2),
|
|
':process_signature' => $processSignature,
|
|
':alert_started' => $alertStarted ? 1 : 0,
|
|
':alert_started_last' => $alertStarted ? 1 : 0,
|
|
':alert_started_update' => $alertStarted ? 1 : 0,
|
|
':alert_started_last_update' => $alertStarted ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
function add_fan_spike_log(array $spike, array $fan, array $system, array $processes): int
|
|
{
|
|
$summary = (string)($spike['summary'] ?? '');
|
|
$rpmDelta = round((float)($spike['rpm_delta'] ?? 0), 1);
|
|
$pwmDelta = round((float)($spike['pwm_delta'] ?? 0), 1);
|
|
$tempDelta = round((float)($spike['temp_delta'] ?? 0), 1);
|
|
$spikeKey = 'fan:' . date('YmdHi');
|
|
$loggedProcesses = notice_downward_only($tempDelta, $rpmDelta)
|
|
? ['cpu' => [], 'memory' => []]
|
|
: $processes;
|
|
|
|
$stmt = db()->prepare("
|
|
INSERT IGNORE INTO fan_spike_logs (
|
|
spike_key,
|
|
summary,
|
|
rpm_delta,
|
|
pwm_delta,
|
|
temp_delta,
|
|
current_rpm,
|
|
current_pwm,
|
|
current_temp,
|
|
cpu_process,
|
|
memory_process
|
|
) VALUES (
|
|
:spike_key,
|
|
:summary,
|
|
:rpm_delta,
|
|
:pwm_delta,
|
|
:temp_delta,
|
|
:current_rpm,
|
|
:current_pwm,
|
|
:current_temp,
|
|
:cpu_process,
|
|
:memory_process
|
|
)
|
|
");
|
|
|
|
$stmt->execute([
|
|
':spike_key' => $spikeKey,
|
|
':summary' => $summary !== '' ? $summary : null,
|
|
':rpm_delta' => $rpmDelta,
|
|
':pwm_delta' => $pwmDelta,
|
|
':temp_delta' => $tempDelta,
|
|
':current_rpm' => $fan['rpm'] ?? null,
|
|
':current_pwm' => $fan['pwm'] ?? null,
|
|
':current_temp' => $system['temp_c'] ?? null,
|
|
':cpu_process' => json_encode($loggedProcesses['cpu'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
':memory_process' => json_encode($loggedProcesses['memory'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
]);
|
|
|
|
if ($stmt->rowCount() <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return (int)db()->lastInsertId();
|
|
}
|
|
|
|
function signed_delta_text(float $value, string $suffix = ''): string
|
|
{
|
|
$sign = $value >= 0 ? '+' : '';
|
|
|
|
return $sign . number_format($value, 1) . $suffix;
|
|
}
|
|
|
|
function signed_delta_compact(float $value, int $decimals, string $suffix = ''): string
|
|
{
|
|
$sign = $value >= 0 ? '+' : '';
|
|
|
|
return $sign . number_format($value, $decimals) . $suffix;
|
|
}
|
|
|
|
function push_process_name(array $row): string
|
|
{
|
|
$service = (string)($row['service'] ?? '');
|
|
if ($service !== '' && $service !== 'N/A') {
|
|
return $service;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
|
|
if (
|
|
str_contains($command, '/codex ')
|
|
|| str_contains($command, '/codex')
|
|
|| str_contains($command, 'codex app-server')
|
|
) {
|
|
return 'codex';
|
|
}
|
|
|
|
if (str_contains($command, '.vscode-server')) {
|
|
return 'vscode-server';
|
|
}
|
|
|
|
if (str_contains($command, 'python3 -m homeassistant')) {
|
|
return 'homeassistant';
|
|
}
|
|
|
|
if (str_contains($command, 'firefox')) {
|
|
return 'firefox';
|
|
}
|
|
|
|
if ($command === '') {
|
|
$command = (string)($row['name'] ?? '');
|
|
}
|
|
|
|
return $command !== '' ? $command : 'N/A';
|
|
}
|
|
|
|
function process_identity(array $row): string
|
|
{
|
|
return implode('|', [
|
|
(string)($row['pid'] ?? ''),
|
|
(string)($row['service'] ?? ''),
|
|
(string)($row['command'] ?? $row['name'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
function expected_process_text(array $processes): string
|
|
{
|
|
$cpu = null;
|
|
foreach (($processes['cpu'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
$service = (string)($row['service'] ?? '');
|
|
if (
|
|
str_contains($command, '/bin/ps')
|
|
|| str_contains($command, 'api.php')
|
|
|| str_contains($command, 'php-fpm')
|
|
|| $service === 'fanpanel-apply.service'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cpu = $row;
|
|
break;
|
|
}
|
|
|
|
if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 0) >= 1.0) {
|
|
return 'CPU ' . push_process_name($cpu);
|
|
}
|
|
|
|
$mem = null;
|
|
foreach (($processes['memory'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$mem = $row;
|
|
break;
|
|
}
|
|
|
|
if ($mem !== null) {
|
|
return 'MEM ' . push_process_name($mem);
|
|
}
|
|
|
|
return 'CPU/MEM 원인 후보 없음';
|
|
}
|
|
|
|
function expected_process_detail_text(array $processes): string
|
|
{
|
|
$cpu = null;
|
|
foreach (($processes['cpu'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
$service = (string)($row['service'] ?? '');
|
|
if (
|
|
str_contains($command, '/bin/ps')
|
|
|| str_contains($command, 'api.php')
|
|
|| str_contains($command, 'php-fpm')
|
|
|| $service === 'fanpanel-apply.service'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cpu = $row;
|
|
break;
|
|
}
|
|
|
|
$mem = null;
|
|
foreach (($processes['memory'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$mem = $row;
|
|
break;
|
|
}
|
|
|
|
if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) {
|
|
return push_process_name($cpu);
|
|
}
|
|
|
|
$parts = [];
|
|
|
|
if ($cpu !== null) {
|
|
$parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), push_process_name($cpu));
|
|
}
|
|
|
|
if ($mem !== null) {
|
|
$parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), push_process_name($mem));
|
|
}
|
|
|
|
return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts);
|
|
}
|
|
|
|
function delta_state_text(float $value): string
|
|
{
|
|
return $value >= 0 ? '높음' : '낮음';
|
|
}
|
|
|
|
function notice_downward_only(float $tempDelta, float $rpmDelta): bool
|
|
{
|
|
$tempTriggered = abs($tempDelta) >= 3.0;
|
|
$rpmTriggered = abs($rpmDelta) >= 1000;
|
|
|
|
if (!$tempTriggered && !$rpmTriggered) {
|
|
return false;
|
|
}
|
|
|
|
if ($tempTriggered && $tempDelta >= 0) {
|
|
return false;
|
|
}
|
|
|
|
if ($rpmTriggered && $rpmDelta >= 0) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function send_fan_spike_push(array $spike, array $fan, array $system, array $processes): array
|
|
{
|
|
$tempDelta = (float)($spike['temp_delta'] ?? 0);
|
|
$rpmDelta = (float)($spike['rpm_delta'] ?? 0);
|
|
$tempAvg = (float)($spike['temp_avg'] ?? 0);
|
|
$rpmAvg = (float)($spike['rpm_avg'] ?? 0);
|
|
$pwmAvg = (float)($spike['pwm_avg'] ?? 0);
|
|
$reasons = [];
|
|
|
|
if (abs($tempDelta) >= 3.0) {
|
|
$reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강');
|
|
}
|
|
|
|
if (abs($rpmDelta) >= 1000) {
|
|
$reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강');
|
|
}
|
|
|
|
if ($reasons === []) {
|
|
$reasons[] = '순간 변화';
|
|
}
|
|
|
|
$body = '기록된 이유: '
|
|
. implode(', ', $reasons)
|
|
. "\n평균: "
|
|
. number_format($tempAvg, 1)
|
|
. '°C / '
|
|
. number_format($rpmAvg, 0)
|
|
. ' RPM / PWM '
|
|
. number_format($pwmAvg, 0)
|
|
. "\n현재: "
|
|
. number_format((float)($system['temp_c'] ?? 0), 1)
|
|
. '°C / '
|
|
. number_format((float)($fan['rpm'] ?? 0), 0)
|
|
. ' RPM / PWM '
|
|
. number_format((float)($fan['pwm'] ?? 0), 0)
|
|
. (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes));
|
|
|
|
return send_push_payload([
|
|
'title' => '시스템 유의사항',
|
|
'body' => $body,
|
|
'url' => '/',
|
|
'tag' => 'system-notice-' . date('YmdHi'),
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'data' => [
|
|
'summary' => $spike['summary'] ?? '',
|
|
'rpm_delta' => $spike['rpm_delta'] ?? 0,
|
|
'pwm_delta' => $spike['pwm_delta'] ?? 0,
|
|
'temp_delta' => $spike['temp_delta'] ?? 0,
|
|
'expected_process' => expected_process_text($processes),
|
|
],
|
|
]);
|
|
}
|
|
|
|
function latest_system_notice_push_epoch(): int
|
|
{
|
|
$stmt = db()->query("
|
|
SELECT UNIX_TIMESTAMP(MAX(created_at))
|
|
FROM push_event_logs
|
|
WHERE event = 'send_success'
|
|
AND meta LIKE '%\"tag\":\"system-notice-%'
|
|
");
|
|
|
|
return (int)($stmt->fetchColumn() ?: 0);
|
|
}
|
|
|
|
function fan_spike_history(int $limit = 100): array
|
|
{
|
|
$limit = max(1, min(500, $limit));
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
id,
|
|
created_at,
|
|
summary,
|
|
rpm_delta,
|
|
pwm_delta,
|
|
temp_delta,
|
|
current_rpm,
|
|
current_pwm,
|
|
current_temp,
|
|
cpu_process,
|
|
memory_process
|
|
FROM fan_spike_logs
|
|
WHERE id IN (
|
|
SELECT MAX(id)
|
|
FROM fan_spike_logs
|
|
GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:%i')
|
|
)
|
|
ORDER BY id DESC
|
|
LIMIT {$limit}
|
|
");
|
|
|
|
$rows = $stmt->fetchAll();
|
|
|
|
foreach ($rows as &$row) {
|
|
$row['cpu_process'] = json_decode((string)($row['cpu_process'] ?? '[]'), true) ?: [];
|
|
$row['memory_process'] = json_decode((string)($row['memory_process'] ?? '[]'), true) ?: [];
|
|
}
|
|
|
|
unset($row);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function dnsmasq_leases(): array
|
|
{
|
|
$map = [];
|
|
|
|
foreach (@file('/var/lib/misc/dnsmasq.leases', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
|
$p = preg_split('/\s+/', trim($line));
|
|
|
|
if (count($p) >= 4) {
|
|
$mac = strtolower($p[1]);
|
|
|
|
$map[$mac] = [
|
|
'expire_ts' => (int)$p[0],
|
|
'mac' => $mac,
|
|
'ip' => $p[2],
|
|
'hostname' => $p[3] === '*' ? 'N/A' : $p[3],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
function iw_station_dump(string $iface): string
|
|
{
|
|
if (!preg_match('/^[A-Za-z0-9_.:-]+$/', $iface)) {
|
|
return '';
|
|
}
|
|
|
|
return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out'];
|
|
}
|
|
|
|
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases): array
|
|
{
|
|
$rows = [];
|
|
$cur = null;
|
|
|
|
foreach (explode("\n", $text) as $line) {
|
|
$t = trim($line);
|
|
|
|
if (preg_match('/^Station\s+([0-9a-f:]+)/i', $t, $m)) {
|
|
if ($cur !== null) {
|
|
$rows[] = $cur;
|
|
}
|
|
|
|
$mac = strtolower($m[1]);
|
|
$lease = $leases[$mac] ?? [];
|
|
$cur = [
|
|
'band' => $band,
|
|
'iface' => $iface,
|
|
'mac' => $mac,
|
|
'ip' => $lease['ip'] ?? 'N/A',
|
|
'hostname' => $lease['hostname'] ?? 'N/A',
|
|
'name' => $lease['hostname'] ?? $mac,
|
|
'signal' => 'N/A',
|
|
'tx_bitrate' => 'N/A',
|
|
'rx_bitrate' => 'N/A',
|
|
'connected_time' => 'N/A',
|
|
'inactive_time' => 'N/A',
|
|
'rx_bytes' => 0,
|
|
'tx_bytes' => 0,
|
|
'rx_packets' => 0,
|
|
'tx_packets' => 0,
|
|
'tx_failed' => 0,
|
|
];
|
|
continue;
|
|
}
|
|
|
|
if ($cur === null) {
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^signal:\s+(.+)/', $t, $m)) $cur['signal'] = $m[1];
|
|
elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) $cur['tx_bitrate'] = $m[1];
|
|
elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) $cur['rx_bitrate'] = $m[1];
|
|
elseif (preg_match('/^connected time:\s+(.+)/', $t, $m)) $cur['connected_time'] = $m[1];
|
|
elseif (preg_match('/^inactive time:\s+(.+)/', $t, $m)) $cur['inactive_time'] = $m[1];
|
|
elseif (preg_match('/^rx bytes:\s+(\d+)/', $t, $m)) $cur['rx_bytes'] = (int)$m[1];
|
|
elseif (preg_match('/^tx bytes:\s+(\d+)/', $t, $m)) $cur['tx_bytes'] = (int)$m[1];
|
|
elseif (preg_match('/^rx packets:\s+(\d+)/', $t, $m)) $cur['rx_packets'] = (int)$m[1];
|
|
elseif (preg_match('/^tx packets:\s+(\d+)/', $t, $m)) $cur['tx_packets'] = (int)$m[1];
|
|
elseif (preg_match('/^tx failed:\s+(\d+)/', $t, $m)) $cur['tx_failed'] = (int)$m[1];
|
|
}
|
|
|
|
if ($cur !== null) {
|
|
$rows[] = $cur;
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function wifi_time_ms(string $value): int
|
|
{
|
|
if (preg_match('/(\d+)/', $value, $m)) {
|
|
return (int)$m[1];
|
|
}
|
|
|
|
return PHP_INT_MAX;
|
|
}
|
|
|
|
function wifi_signal_dbm(string $value): int
|
|
{
|
|
if (preg_match('/-?\d+/', $value, $m)) {
|
|
return (int)$m[0];
|
|
}
|
|
|
|
return -999;
|
|
}
|
|
|
|
function dedupe_wifi_clients(array $clients): array
|
|
{
|
|
$deduped = [];
|
|
|
|
foreach ($clients as $client) {
|
|
$mac = strtolower((string)($client['mac'] ?? ''));
|
|
$key = $mac !== '' && $mac !== 'n/a'
|
|
? 'mac:' . $mac
|
|
: 'row:' . ($client['band'] ?? '') . ':' . ($client['ip'] ?? '') . ':' . ($client['hostname'] ?? '');
|
|
|
|
if (!isset($deduped[$key])) {
|
|
$deduped[$key] = $client;
|
|
continue;
|
|
}
|
|
|
|
$currentInactive = wifi_time_ms((string)($deduped[$key]['inactive_time'] ?? ''));
|
|
$nextInactive = wifi_time_ms((string)($client['inactive_time'] ?? ''));
|
|
$currentSignal = wifi_signal_dbm((string)($deduped[$key]['signal'] ?? ''));
|
|
$nextSignal = wifi_signal_dbm((string)($client['signal'] ?? ''));
|
|
|
|
if ($nextInactive < $currentInactive || ($nextInactive === $currentInactive && $nextSignal > $currentSignal)) {
|
|
$deduped[$key] = $client;
|
|
}
|
|
}
|
|
|
|
return array_values($deduped);
|
|
}
|
|
|
|
function wifi_data(): array
|
|
{
|
|
$leases = dnsmasq_leases();
|
|
$clients = [];
|
|
|
|
foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) {
|
|
$clients = array_merge(
|
|
$clients,
|
|
parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases)
|
|
);
|
|
}
|
|
|
|
$clients = dedupe_wifi_clients($clients);
|
|
|
|
return [
|
|
'clients' => $clients,
|
|
'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')),
|
|
'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')),
|
|
'leases' => array_values($leases),
|
|
];
|
|
}
|
|
|
|
function action_rows(int $limit = 80): array
|
|
{
|
|
$limit = max(1, min(300, $limit));
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
created_at AS ts,
|
|
action_type AS action,
|
|
CONCAT(
|
|
action_type,
|
|
IF(pwm_mode IS NULL, '', CONCAT(' ', pwm_mode)),
|
|
IF(pwm_value IS NULL, '', CONCAT(' pwm ', pwm_value)),
|
|
IF(note IS NULL OR note = '', '', CONCAT(' / ', note))
|
|
) AS message,
|
|
success,
|
|
actor_ip
|
|
FROM fan_actions
|
|
ORDER BY id DESC
|
|
LIMIT {$limit}
|
|
");
|
|
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function collect_snapshot(bool $applyFan = true): array
|
|
{
|
|
if ($applyFan) {
|
|
$policy = apply_fan_policy();
|
|
} else {
|
|
$state = get_control_state();
|
|
$policy = [
|
|
'mode' => (string)($state['mode'] ?? 'auto'),
|
|
'target_pwm' => (int)($state['manual_pwm'] ?? 120),
|
|
'actual_pwm' => 0,
|
|
'temp_c' => 0.0,
|
|
'rpm' => 0,
|
|
'ok' => true,
|
|
'paths' => [],
|
|
'enable_value' => 'N/A',
|
|
];
|
|
}
|
|
|
|
$latest = latest_sensor();
|
|
|
|
$pwm = isset($latest['pwm_value']) ? (int)$latest['pwm_value'] : 0;
|
|
if ($latest === false || $latest === [] || !isset($latest['pwm_value'])) {
|
|
$pwm = (int)$policy['actual_pwm'];
|
|
}
|
|
|
|
$rpm = (int)($latest['fan_rpm'] ?? 0);
|
|
if ($rpm <= 0) {
|
|
$rpm = (int)$policy['rpm'];
|
|
}
|
|
|
|
$temp = (float)($latest['cpu_temp_c'] ?? 0);
|
|
if ($temp <= 0) {
|
|
$temp = (float)$policy['temp_c'];
|
|
}
|
|
|
|
$load = sys_getloadavg() ?: [0, 0, 0];
|
|
$mem = mem_info();
|
|
$disk = disk_info('/');
|
|
$os = os_info();
|
|
$cpuPower = $applyFan ? cpu_power_status() : [
|
|
'voltage' => isset($latest['cpu_voltage']) ? (float)$latest['cpu_voltage'] : null,
|
|
'watts' => isset($latest['cpu_watts']) ? (float)$latest['cpu_watts'] : null,
|
|
];
|
|
$battery = $applyFan ? battery_status() : [
|
|
'voltage' => isset($latest['battery_voltage']) ? (float)$latest['battery_voltage'] : null,
|
|
'percent' => isset($latest['battery_percent']) ? (float)$latest['battery_percent'] : null,
|
|
];
|
|
|
|
if ($applyFan) {
|
|
add_sensor_log([
|
|
'cpu_temp_c' => $temp,
|
|
'fan_rpm' => $rpm,
|
|
'fan_efficiency' => fan_efficiency($rpm, $temp, $cpuPower['watts'] ?? null),
|
|
'rp1_temp_c' => rp1_temp_c(),
|
|
'cpu_voltage' => $cpuPower['voltage'],
|
|
'cpu_watts' => $cpuPower['watts'],
|
|
'battery_voltage' => $battery['voltage'],
|
|
'battery_percent' => $battery['percent'],
|
|
'pwm_value' => $pwm,
|
|
'pwm_percent' => round($pwm / 255 * 100, 2),
|
|
'pwm_mode' => $policy['mode'],
|
|
'cpu_load_1' => round((float)$load[0], 2),
|
|
'cpu_load_5' => round((float)$load[1], 2),
|
|
'cpu_load_15' => round((float)$load[2], 2),
|
|
'mem_total_mb' => $mem['total_mb'],
|
|
'mem_used_mb' => $mem['used_mb'],
|
|
'mem_free_mb' => $mem['free_mb'],
|
|
'disk_total_kb' => $disk['total_kb'],
|
|
'disk_used_kb' => $disk['used_kb'],
|
|
'disk_free_kb' => $disk['free_kb'],
|
|
'uptime_seconds' => $os['uptime_seconds'],
|
|
'hostname' => $os['hostname'],
|
|
]);
|
|
}
|
|
|
|
$fan = [
|
|
'mode' => $policy['mode'],
|
|
'pwm' => $pwm,
|
|
'percent' => round($pwm / 255 * 100, 1),
|
|
'rpm' => $rpm,
|
|
'enable' => $policy['enable_value'],
|
|
'target_pwm' => $policy['target_pwm'],
|
|
'policy_ok' => $policy['ok'],
|
|
'paths' => $policy['paths'],
|
|
];
|
|
|
|
$system = array_merge($os, [
|
|
'temp_c' => $temp,
|
|
'load' => [
|
|
round((float)$load[0], 2),
|
|
round((float)$load[1], 2),
|
|
round((float)$load[2], 2),
|
|
],
|
|
'active_users' => active_user_info(),
|
|
'memory' => $mem,
|
|
'disk' => $disk,
|
|
]);
|
|
|
|
$history = add_battery_remaining_history(sensor_history(240));
|
|
$battery['remaining'] = battery_remaining_estimate($battery, $history);
|
|
|
|
$processes = process_resource_data(6);
|
|
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
|
|
|
|
if (!empty($fanSpike['active'])) {
|
|
$latestNoticePush = latest_system_notice_push_epoch();
|
|
$spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes);
|
|
if ($spikeLogId > 0) {
|
|
$fanSpike['log_id'] = $spikeLogId;
|
|
if ($latestNoticePush <= 0 || time() - $latestNoticePush >= 600) {
|
|
$fanSpike['push'] = send_fan_spike_push($fanSpike, $fan, $system, $processes);
|
|
} else {
|
|
$fanSpike['push'] = [
|
|
'sent' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 'system_notice_cooldown',
|
|
'cooldown_seconds' => 600,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$snapshot = [
|
|
'ok' => true,
|
|
'generated_at' => date('Y-m-d H:i:s'),
|
|
'time_ms' => (int)round(microtime(true) * 1000),
|
|
|
|
'fan' => $fan,
|
|
'system' => $system,
|
|
'battery' => $battery,
|
|
|
|
'wifi' => wifi_data(),
|
|
'history' => $history,
|
|
'processes' => $processes,
|
|
'fan_spike' => $fanSpike,
|
|
'fan_spike_history' => fan_spike_history(100),
|
|
];
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
function control_api_dispatch(): void
|
|
{
|
|
$action = $_GET['action'] ?? $_POST['action'] ?? 'status';
|
|
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|
require_csrf();
|
|
}
|
|
|
|
try {
|
|
if ($action === 'dmesg') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => dmesg_log(),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'status') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => collect_snapshot(false),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'push_devices') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => [
|
|
'devices' => push_device_rows(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
if ($action === 'push_status') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => push_subscription_status((string)($_GET['endpoint'] ?? $_POST['endpoint'] ?? '')),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'delete_push_device') {
|
|
delete_push_device((string)($_POST['endpoint_hash'] ?? ''));
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => [
|
|
'devices' => push_device_rows(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
if ($action === 'delete_push_endpoint') {
|
|
delete_push_endpoint((string)($_POST['endpoint'] ?? ''));
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => [
|
|
'devices' => push_device_rows(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
if ($action === 'fan') {
|
|
$mode = (string)($_POST['mode'] ?? 'auto');
|
|
$pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120)));
|
|
|
|
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_mode',
|
|
], 400);
|
|
}
|
|
|
|
set_control_state($mode, $pwm);
|
|
|
|
add_fan_action(
|
|
'fan_set',
|
|
$mode,
|
|
$pwm,
|
|
'web fan control',
|
|
true
|
|
);
|
|
|
|
$data = collect_snapshot(true);
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => $data,
|
|
]);
|
|
}
|
|
|
|
if ($action === 'wifi') {
|
|
$verb = (string)($_POST['verb'] ?? '');
|
|
$unit = (string)($_POST['unit'] ?? '');
|
|
$allowedUnits = [
|
|
'hostapd-24g.service',
|
|
'hostapd-5g.service',
|
|
'dnsmasq.service',
|
|
];
|
|
|
|
if (
|
|
!in_array($unit, $allowedUnits, true)
|
|
|| !in_array($verb, ['restart', 'reload'], true)
|
|
) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_wifi_request',
|
|
], 400);
|
|
}
|
|
|
|
$result = sh(['/usr/bin/systemctl', $verb, $unit], true, 25);
|
|
$out = $result['out'];
|
|
|
|
add_fan_action(
|
|
'wifi_' . $verb,
|
|
null,
|
|
null,
|
|
$unit . "\n" . mb_substr($out, 0, 1000),
|
|
true
|
|
);
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'output' => $out,
|
|
'data' => collect_snapshot(false),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'collect') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => collect_snapshot(true),
|
|
]);
|
|
}
|
|
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'unknown_action',
|
|
], 404);
|
|
} catch (Throwable $e) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'exception',
|
|
'message' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
if (!$controlApiLibrary) {
|
|
control_api_dispatch();
|
|
}
|