Add cpu line graph (#7403)

* add cpu line graph

* add cpu line graph

* add cpu line graph

* add cpu line graph

* add cpu line graph

* Add FreeBSD CPU status

* Add new dashboard type Line

* Add missing public declaration

* Allow new dashboard type Line to be selected option

* Use new Line type for graphing instead of doughnut
This commit is contained in:
frytimo
2025-06-30 18:54:13 -03:00
committed by GitHub
parent 2af950cfb3
commit 28dbb803de
8 changed files with 346 additions and 15 deletions

View File

@@ -52,4 +52,85 @@ class bsd_system_information extends system_information {
public function get_uptime() {
return shell_exec('uptime');
}
public function get_cpu_percent_per_core(): array {
static $last = [];
$results = [];
// Read the raw CPU time ticks from sysctl (returns flat array of cores)
$raw = trim(shell_exec('sysctl -n kern.cp_times'));
if (!$raw)
return [];
$parts = array_map('intval', preg_split('/\s+/', $raw));
$num_cores = count($parts) / 5;
for ($core = 0; $core < $num_cores; $core++) {
$offset = $core * 5;
$user = $parts[$offset];
$nice = $parts[$offset + 1];
$sys = $parts[$offset + 2];
$intr = $parts[$offset + 3];
$idle = $parts[$offset + 4];
$total = $user + $nice + $sys + $intr + $idle;
if (!isset($last[$core])) {
$last[$core] = ['total' => $total, 'idle' => $idle];
$results[$core] = 0;
continue;
}
$delta_total = $total - $last[$core]['total'];
$delta_idle = $idle - $last[$core]['idle'];
$usage = $delta_total > 0 ? (1 - ($delta_idle / $delta_total)) * 100 : 0;
$results[$core] = round($usage, 2);
$last[$core] = ['total' => $total, 'idle' => $idle];
}
return $results;
}
/**
*
* @staticvar array $last
* @param string $interface
* @return array
* @depends FreeBSD Version 12
*/
public function get_network_speed(string $interface = 'em0'): array {
static $last = [];
// Run netstat for the interface
$output = shell_exec("netstat -bI {$interface} 2>/dev/null");
if (!$output)
return ['rx_bps' => 0, 'tx_bps' => 0];
$lines = explode("\n", trim($output));
if (count($lines) < 2)
return ['rx_bps' => 0, 'tx_bps' => 0];
$cols = preg_split('/\s+/', $lines[1]);
$rx_bytes = (int) $cols[6]; // Ibytes
$tx_bytes = (int) $cols[9]; // Obytes
$now = microtime(true);
if (!isset($last[$interface])) {
$last[$interface] = ['rx' => $rx_bytes, 'tx' => $tx_bytes, 'time' => $now];
return ['rx_bps' => 0, 'tx_bps' => 0];
}
$delta_time = $now - $last[$interface]['time'];
$delta_rx = $rx_bytes - $last[$interface]['rx'];
$delta_tx = $tx_bytes - $last[$interface]['tx'];
$last[$interface] = ['rx' => $rx_bytes, 'tx' => $tx_bytes, 'time' => $now];
return [
'rx_bps' => $delta_rx / $delta_time,
'tx_bps' => $delta_tx / $delta_time
];
}
}

View File

@@ -50,7 +50,8 @@ class linux_system_information extends system_information {
$core_count = 0;
foreach ($lines1 as $i => $line1) {
if (strpos($line1, 'cpu') !== 0 || $line1 === 'cpu') continue;
if (strpos($line1, 'cpu') !== 0 || $line1 === 'cpu')
continue;
$parts1 = preg_split('/\s+/', $line1);
$parts2 = preg_split('/\s+/', $lines2[$i]);
@@ -75,4 +76,68 @@ class linux_system_information extends system_information {
public function get_uptime() {
return shell_exec('uptime');
}
public function get_cpu_percent_per_core(): array {
static $last = [];
$lines = file('/proc/stat');
$results = [];
foreach ($lines as $line) {
if (preg_match('/^cpu(\d+)\s+(.+)$/', $line, $matches)) {
$core = (int) $matches[1];
$parts = preg_split('/\s+/', trim($matches[2]));
$total = array_sum($parts);
$idle = $parts[3] ?? 0;
if (!isset($last[$core])) {
$last[$core] = ['total' => $total, 'idle' => $idle];
$results[$core] = 0;
} else {
$delta_total = $total - $last[$core]['total'];
$delta_idle = $idle - $last[$core]['idle'];
$usage = $delta_total > 0 ? (1 - ($delta_idle / $delta_total)) * 100 : 0;
$results[$core] = round($usage, 2);
$last[$core] = ['total' => $total, 'idle' => $idle];
}
}
}
return $results;
}
public function get_network_speed(string $interface = 'eth0'): array {
static $last = [];
// Read network stats
$data = file('/proc/net/dev');
foreach ($data as $line) {
if (strpos($line, $interface . ':') !== false) {
$parts = preg_split('/\s+/', trim(str_replace(':', ' ', $line)));
$rx_bytes = (int) $parts[1];
$tx_bytes = (int) $parts[9];
$now = microtime(true);
if (!isset($last[$interface])) {
$last[$interface] = ['rx' => $rx_bytes, 'tx' => $tx_bytes, 'time' => $now];
return ['rx_bps' => 0, 'tx_bps' => 0];
}
$delta_time = $now - $last[$interface]['time'];
$delta_rx = $rx_bytes - $last[$interface]['rx'];
$delta_tx = $tx_bytes - $last[$interface]['tx'];
$last[$interface] = ['rx' => $rx_bytes, 'tx' => $tx_bytes, 'time' => $now];
return [
'rx_bps' => $delta_rx / $delta_time,
'tx_bps' => $delta_tx / $delta_time
];
}
}
return ['rx_bps' => 0, 'tx_bps' => 0];
}
}

View File

@@ -87,28 +87,40 @@ class system_dashboard_service extends base_websocket_system_service {
}
public function on_cpu_status($message = null): void {
// Get the CPU status
$cpu_percent = self::$system_information->get_cpu_percent();
// Get total and per-core CPU usage
$cpu_percent_total = self::$system_information->get_cpu_percent();
$cpu_percent_per_core = self::$system_information->get_cpu_percent_per_core();
// Send the response
// Prepare response
$response = new websocket_message();
$response
->payload([self::CPU_STATUS_TOPIC => $cpu_percent])
->payload([
self::CPU_STATUS_TOPIC => [
'total' => $cpu_percent_total,
'per_core' => array_values($cpu_percent_per_core)
]
])
->service_name(self::get_service_name())
->topic(self::CPU_STATUS_TOPIC);
// Check if we are responding
// Include message ID if responding to a request
if ($message !== null && $message instanceof websocket_message) {
$response->id($message->id());
}
// Log the broadcast
$this->debug("Broadcasting CPU percent '$cpu_percent'");
// Log for debugging
$this->debug(sprintf(
"Broadcasting CPU total %.2f%% with %d cores",
$cpu_percent_total,
count($cpu_percent_per_core)
));
// Send to subscribers
// Send the broadcast
$this->respond($response);
}
public static function get_service_name(): string {
return "dashboard.system.information";
}

View File

@@ -34,6 +34,8 @@ abstract class system_information {
abstract public function get_cpu_cores(): int;
abstract public function get_uptime();
abstract public function get_cpu_percent(): float;
abstract public function get_cpu_percent_per_core(): array;
abstract public function get_network_speed(string $interface = 'eth0'): array;
public function get_load_average() {
return sys_getloadavg();

View File

@@ -60,8 +60,7 @@
subscriber::save_token($token, [system_dashboard_service::get_service_name()]);
//add half doughnut chart
if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut"): ?>
if ($dashboard_chart_type === 'line') { ?>
<div class='hud_chart' style='width: 175px;'><canvas id='system_cpu_status_chart'></canvas></div>
<script>
@@ -105,7 +104,148 @@
// Function is called automatically by the websocket_client.js when there is a CPU status update
function update_cpu_chart(payload) {
let cpu_status = payload.cpu_status;
const cores = payload.cpu_status?.per_core;
if (!Array.isArray(cores) || cores.length !== num_cores) return;
const chart = window.system_cpu_status_chart;
if (!chart) return;
// Store into ring buffer
cores.forEach((val, i) => {
cpu_history[i][cpu_index] = Math.round(val);
});
cpu_index = (cpu_index + 1) % max_points;
// Rotate each dataset's ring buffer to match chart order
chart.data.datasets.forEach((dataset, i) => {
const rotated = cpu_history[i].slice(cpu_index).concat(cpu_history[i].slice(0, cpu_index));
dataset.data = rotated;
});
chart.update();
// Optional: update total CPU %
const td_cpu_status = document.getElementById('td_system_cpu_status_chart');
if (td_cpu_status && payload.cpu_status?.total !== undefined) {
td_cpu_status.textContent = `${Math.round(payload.cpu_status.total)}%`;
}
}
// Set chart options
const max_points = 60;
const num_cores = <?= $cpu_cores ?>;
let cpu_history = Array.from({ length: num_cores }, () => new Array(max_points).fill(null));
let cpu_index = 0;
// Color palette (distinct and visually stacked)
const cpu_colors = ['#00bcd4', '#8bc34a', '#ffc107', '#e91e63'];
// Initialize the chart
window.system_cpu_status_chart = new Chart(
document.getElementById('system_cpu_status_chart').getContext('2d'),
{
type: 'line',
data: {
labels: Array.from({ length: max_points }, (_, i) => i + 1),
datasets: Array.from({ length: num_cores }, (_, i) => ({
label: `CPU ${i}`,
data: [...cpu_history[i]],
fill: true,
borderColor: cpu_colors[i % cpu_colors.length],
backgroundColor: cpu_colors[i % cpu_colors.length],
tension: 0.3,
pointRadius: 0
}))
},
options: {
animation: false,
scales: {
y: {
beginAtZero: true,
stacked: true,
min: 0,
max: num_cores * 100,
ticks: {
stepSize: 100
}
},
x: {
ticks: {
autoSkip: true,
callback: function (val, index) {
return (index % 100 === 0 ? ' ' : ' ');
}
},
grid: {
drawOnChartArea: false
}
}
},
plugins: {
tooltip: {
mode: 'index',
intersect: false
},
legend: {
display: false
}
}
}
}
);
connect_cpu_status_websocket();
</script>
<?php }
//add half doughnut chart
if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut") { ?>
<div class='hud_chart' style='width: 175px;'><canvas id='system_cpu_status_chart'></canvas></div>
<script>
const cpu_status_auth_token = {
name: "<?= $token['name']; ?>",
hash: "<?= $token['hash']; ?>"
}
const cpu_status_subject = '<?php echo system_dashboard_service::CPU_STATUS_TOPIC; ?>';
const dashboard_cpu_usage_chart_main_color = [
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[0] ?? '#03c04a'); ?>',
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[1] ?? '#ff9933'); ?>',
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[2] ?? '#ea4c46'); ?>'
];
function connect_cpu_status_websocket() {
client = new ws_client(`wss://${window.location.hostname}/websockets/`, cpu_status_auth_token);
client.ws.addEventListener("open", async () => {
try {
console.log('Connected');
console.log('Requesting authentication');
// Wait until we are authenticated
await client.request('authentication');
console.log('authenticated');
// Bind event handler so websocket_client.js can call the function when it
// receives the cpu_status event
client.onEvent(cpu_status_subject, update_cpu_chart);
} catch (err) {
console.error("WS setup failed: ", err);
return;
}
});
client.ws.addEventListener("close", async () => {
console.warn("Websocket Disconnected");
});
}
// Function is called automatically by the websocket_client.js when there is a CPU status update
function update_cpu_chart(payload) {
let cpu_status = Math.round(payload.cpu_status.total);
const chart = window.system_cpu_status_chart;
if (!chart) return;
@@ -185,8 +325,7 @@
connect_cpu_status_websocket();
</script>
<?php endif; ?>
<?php
<?php }
if ($dashboard_chart_type == "number") {
echo "<span class='hud_stat'>".round($percent_cpu)."%</span>";
}

View File

@@ -110,7 +110,7 @@
if (!progress) return;
// Update progress bar
cpu_percent = Math.round(payload.cpu_status);
cpu_percent = Math.round(payload.cpu_status.total);
progress.style.width = `${cpu_percent}%`;
progress.innerHTML = `${cpu_percent}%`;
}

View File

@@ -648,6 +648,33 @@ $text['label-doughnut']['zh-cn'] = "油炸圈饼";
$text['label-doughnut']['ja-jp'] = "ドーナツ";
$text['label-doughnut']['ko-kr'] = "도넛";
$text['label-line']['en-us'] = "Line";
$text['label-line']['en-gb'] = "Line";
$text['label-line']['ar-eg'] = "خط";
$text['label-line']['de-at'] = "Leitung";
$text['label-line']['de-ch'] = "Leitung";
$text['label-line']['de-de'] = "Leitung";
$text['label-line']['el-gr'] = "Γραμμή";
$text['label-line']['es-cl'] = "Línea";
$text['label-line']['es-mx'] = "Línea";
$text['label-line']['fr-ca'] = "Ligne";
$text['label-line']['fr-fr'] = "Ligne";
$text['label-line']['he-il'] = "קו";
$text['label-line']['it-it'] = "Linea";
$text['label-line']['ka-ge'] = "ხაზი";
$text['label-line']['nl-nl'] = "Lijn";
$text['label-line']['pl-pl'] = "Linia";
$text['label-line']['pt-br'] = "Linha";
$text['label-line']['pt-pt'] = "Linha";
$text['label-line']['ro-ro'] = "Linie";
$text['label-line']['ru-ru'] = "Линия";
$text['label-line']['sv-se'] = "Linje";
$text['label-line']['uk-ua'] = "Лінія";
$text['label-line']['tr-tr'] = "Hat";
$text['label-line']['zh-cn'] = "线路";
$text['label-line']['ja-jp'] = "回線";
$text['label-line']['ko-kr'] = "회선";
$text['label-progress_bar']['en-us'] = "Progress Bar";
$text['label-progress_bar']['en-gb'] = "Progress Bar";
$text['label-progress_bar']['ar-eg'] = "شريط التقدم";

View File

@@ -828,6 +828,11 @@
echo "<td class='vtable' style='position: relative;' align='left'>\n";
echo " <select name='dashboard_chart_type' class='formfld'>\n";
echo " <option value='doughnut'>".$text['label-doughnut']."</option>\n";
if ($dashboard_chart_type === 'line') {
echo " <option value='line' selected='selected'>".$text['label-line']."</option>\n";
} else {
echo " <option value='line'>".$text['label-line']."</option>\n";
}
if ($dashboard_chart_type == "icon" || in_array($dashboard_path, ['domains/domains', 'xml_cdr/missed_calls', 'voicemails/voicemails', 'xml_cdr/recent_calls', 'registrations/registrations'])) {
echo " <option value='icon' ".($dashboard_chart_type == "icon" ? "selected='selected'" : null).">".$text['label-icon']."</option>\n";
}