Update dashboard with cpu status websockets (#7400)

* Remove setting hardcoded timer

* Use websockets for real-time CPU status on dashboard

* Add system_dashboard service file

* moved javascript file to core
This commit is contained in:
frytimo
2025-06-27 18:25:32 -03:00
committed by GitHub
parent ef076f98a0
commit 807f80da94
6 changed files with 138 additions and 53 deletions

View File

@@ -60,39 +60,38 @@
subscriber::save_token($token, [system_dashboard_service::get_service_name()]);
//break the caching with version
$version = md5(file_get_contents(__DIR__, '/resources/javascript/websocket_client.js'));
//set script source
echo "<script src='/app/system/resources/javascript/websocket_client.js?v=$version'></script>\n";
//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 authToken = {
const cpu_status_auth_token = {
name: "<?= $token['name']; ?>",
hash: "<?= $token['hash']; ?>"
}
const serviceName = '<?php echo system_dashboard_service::get_service_name(); ?>'
const cpuStatusTopic = '<?php echo system_dashboard_service::CPU_STATUS_TOPIC; ?>';
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 connectWebsocket() {
client = new ws_client(`wss://${window.location.hostname}/websockets/`, authToken);
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');
client.onEvent(cpuStatusTopic, updateCpuChart);
client.request(serviceName, cpuStatusTopic);
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;
@@ -104,31 +103,33 @@
});
}
function bindEventHandlers(client) {
client.onEvent(cpuStatusTopic, updateCpuChart);
}
function updateCpuChart(payload) {
let cpuPercent = payload.cpu_status;
// 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 chart = window.system_cpu_status_chart;
if (!chart) return;
// Update chart data
cpuPercent = Math.round(cpuPercent);
chart.data.datasets[0].data = [cpuPercent, 100 - cpuPercent];
cpu_rounded = Math.round(cpu_status);
chart.data.datasets[0].data = [cpu_rounded, 100 - cpu_rounded];
// Update color based on threshold
if (cpuPercent <= 60) {
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[0];
} else if (cpuPercent <= 80) {
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[1];
if (cpu_rounded <= 60) {
chart.data.datasets[0].backgroundColor[0] = '<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[0] ?? '#03c04a'); ?>';
} else if (cpu_rounded <= 80) {
chart.data.datasets[0].backgroundColor[0] = '<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[1] ?? '#ff9933'); ?>';
} else {
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[2];
chart.data.datasets[0].backgroundColor[0] = '<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[2] ?? '#ea4c46'); ?>';
}
chart.options.plugins.chart_number_2.text = cpuPercent;
chart.options.plugins.chart_number_2.text = cpu_rounded;
chart.update();
// Update the row data
const td_cpu_status = document.getElementById('td_system_cpu_status_chart');
if (!td_cpu_status) { return; }
td_cpu_status.textContent = `${payload.cpu_status}%`;
}
window.system_cpu_status_chart = new Chart(
@@ -182,7 +183,7 @@
}
);
connectWebsocket();
connect_cpu_status_websocket();
</script>
<?php endif; ?>
<?php
@@ -203,7 +204,7 @@
if (!empty($percent_cpu)) {
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-cpu_usage']."</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$percent_cpu."%</td>\n";
echo "<td id='td_system_cpu_status_chart' valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$percent_cpu."%</td>\n";
echo "</tr>\n";
$c = ($c) ? 0 : 1;
}

View File

@@ -63,12 +63,61 @@
$used_space = $tmp[2] ?? '-';
$total_space = $tmp[1] ?? '-';
$percent_disk_usage = '';
foreach ($tmp as $stat) {
if (substr_count($stat, '%') > 0) { $percent_disk_usage = rtrim($stat,'%'); break; }
}
}
//include websocket real-time updates
$token = (new token())->create($_SERVER['PHP_SELF']);
echo " <input id='token' type='hidden' name='" . $token['name'] . "' value='" . $token['hash'] . "'>\n";
subscriber::save_token($token, [system_dashboard_service::get_service_name()]);
?>
<script>
const system_status_auth_token = {
name: "<?= $token['name']; ?>",
hash: "<?= $token['hash']; ?>"
}
function connect_system_cpu_status_websocket() {
system_status_client = new ws_client(`wss://${window.location.hostname}/websockets/`, system_status_auth_token);
system_status_client.ws.addEventListener("open", async () => {
try {
console.log('Connected');
console.log('Requesting authentication');
// Wait until we are authenticated
await system_status_client.request('authentication');
console.log('authenticated');
// call the update_system_cpu_status function when a cpu usage event occurs
system_status_client.onEvent('<?php echo system_dashboard_service::CPU_STATUS_TOPIC; ?>', update_system_cpu_status);
} catch (err) {
console.error("WS setup failed: ", err);
return;
}
});
system_status_client.ws.addEventListener("close", async () => {
console.warn("Websocket Disconnected");
});
}
function update_system_cpu_status(payload) {
const progress = document.getElementById('cpu_status_progress_bar');
if (!progress) return;
// Update progress bar
cpu_percent = Math.round(payload.cpu_status);
progress.style.width = `${cpu_percent}%`;
progress.innerHTML = `${cpu_percent}%`;
}
connect_system_cpu_status_websocket();
</script>
<?php
//show the results
echo " <div class='hud_content' ".($dashboard_details_state == "disabled" ?: "onclick=\"$('#hud_system_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"").">\n";
@@ -137,7 +186,7 @@
if ($dashboard_row_span > 1) {
echo " <span class='hud_title cpu_usage' style='text-align: left; font-size: 11px; line-height: 1.8; font-weight: unset; padding-left: 10%;'>".$text['label-processor_usage']."</span>\n";
echo " <div class='progress_container' style='width: 80%; height: 15px; border-radius: 10px; background: ".($settings->get('theme', 'dashboard_cpu_usage_chart_sub_color') ?? '#d4d4d4').";'>\n";
echo " <div class='progress_bar' style='width: ".($percent_cpu > 100 ? 100 : $percent_cpu)."%; height: 15px; border-radius: 10px; font-size: x-small; color: ".$row['dashboard_number_text_color']."; background: ".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color') ?? '#03c04a').";'>".($percent_cpu > 100 ? 100 : round($percent_cpu))."%</div>\n";
echo " <div id='cpu_status_progress_bar' class='progress_bar' style='width: ".($percent_cpu > 100 ? 100 : $percent_cpu)."%; height: 15px; border-radius: 10px; font-size: x-small; color: ".$row['dashboard_number_text_color']."; background: ".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color') ?? '#03c04a').";'>".($percent_cpu > 100 ? 100 : round($percent_cpu))."%</div>\n";
echo " </div>\n";
echo " <div style='width: 100%; height: 15px'>&nbsp;</div>\n";
}
@@ -185,15 +234,15 @@
if (file_exists('/etc/os-release')) {
$os_release = parse_ini_file('/etc/os-release');
$os_info = $os_release['PRETTY_NAME'] ?? '';
}
}
// Fallback to basic uname info
elseif (function_exists('php_uname')) {
$os_info = php_uname('s') . ' ' . php_uname('r'); // e.g. "Linux 5.10.0"
}
// Clean up the output
$os_info = str_replace('"', '', $os_info); // Remove quotes if present
if (!empty($os_info)) {
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-os_version']."</td>\n";
@@ -224,18 +273,18 @@
if (!empty($meminfo)) {
$meminfo = preg_replace('/\s+/', ' ', trim($meminfo));
$parts = explode(' ', $meminfo);
$total = $parts[1];
$used = $parts[2];
$percent_memory = round(($used / $total) * 100, 1);
// Set style color based on thresholds
$style = ($percent_memory > 90) ? "color: red;" : (($percent_memory > 75) ? "color: orange;" : "");
// Format with used/total (e.g. "40% (3.2G/8G)")
$total_h = round($total / (1024*1024*1024), 1) . 'G';
$used_h = round($used / (1024*1024*1024), 1) . 'G';
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-memory_usage']."</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right; $style'>".$percent_memory."% (".$used_h." / ".$total_h.")"."</td>\n";
@@ -250,10 +299,10 @@
if (!empty($swapinfo)) {
$swapinfo = preg_replace('/\s+/', ' ', trim($swapinfo));
$parts = explode(' ', $swapinfo);
$swap_total = $parts[1];
$swap_used = $parts[2];
// Only show swap if it exists (total > 0)
if ($swap_total > 0) {
$percent_swap = round(($swap_used / $swap_total) * 100, 1);
@@ -262,7 +311,7 @@
// Set style color based on thresholds
$style = ($percent_swap > 90) ? "color: red;" : (($percent_swap > 75) ? "color: orange;" : "");
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-swap_usage']."</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right; $style'>".$percent_swap."% (".$swap_used_h." / ".$swap_total_h.")"."</td>\n";
@@ -274,11 +323,11 @@
//disk usage display
if (stristr(PHP_OS, 'Linux') || stristr(PHP_OS, 'FreeBSD')) {
if (!empty($percent_disk_usage) && $used_space != '-' && $total_space != '-') {
// Set style color based on thresholds
$style = ($percent_disk_usage > 90) ? "color: red;" : (($percent_disk_usage > 75) ? "color: orange;" : "");
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-disk_usage']."</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right; $style'>".$percent_disk_usage."% (".$used_space." / ".$total_space.")"."</td>\n";
@@ -305,30 +354,30 @@
$connections = sizeof($tmp) - 1;
}
}
if (!empty($sql_current) && !empty($sql_max)) {
if (!isset($database)) { $database = new database; }
// Get current connections
$current_connections = $database->select($sql_current, null, 'column');
// Get max connections (handles both PostgreSQL & MySQL)
$max_result = $database->select($sql_max, null, ($db_type == 'pgsql') ? 'column' : 'row');
$max_connections = ($db_type == 'mysql') ? $max_result['Value'] : $max_result;
// Format as "current/max"
$connections = ($current_connections !== false && $max_connections !== false)
? "Current: " . $current_connections . ", Max: " . $max_connections
$connections = ($current_connections !== false && $max_connections !== false)
? "Current: " . $current_connections . ", Max: " . $max_connections
: "N/A";
unset($sql_current, $sql_max);
}
if (!empty($connections)) {
// Set style color based on thresholds
$ratio = $current_connections / $max_connections;
$style = ($ratio > 0.9) ? "color: red;" : (($ratio > 0.75) ? "color: orange;" : "");
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='" . $row_style[$c] . " hud_text'>" . $text['label-database_connections'] . "</td>\n";
echo "<td valign='top' class='" . $row_style[$c] . " hud_text' style='text-align: right; $style'>" . $connections . "</td>\n";

View File

@@ -1,116 +0,0 @@
class ws_client {
constructor(url, token) {
this.ws = new WebSocket(url);
this.ws.addEventListener('message', this._onMessage.bind(this));
this._nextId = 1;
this._pending = new Map();
this._eventHandlers = new Map();
// The token is submitted on every request
this.token = token;
}
// internal message handler called when event occurs on the socket
_onMessage(ev) {
let message;
let switch_event;
try {
console.log(ev.data);
message = JSON.parse(ev.data);
// check for authentication request
if (message.status_code === 407) {
console.log('Authentication Required');
return;
}
switch_event = message.payload;
//console.log('envelope received: ',env);
} catch (err) {
console.error('Error parsing JSON data:', err);
//console.error('Invalid JSON:', ev.data);
return;
}
// Pull out the request_id first
const rid = message.request_id ?? null;
// If this is the response to a pending request
if (rid && this._pending.has(rid)) {
// Destructure with defaults in case they're missing
const {
service,
topic = '',
status = 'ok',
code = 200,
payload = {}
} = message;
const {resolve, reject} = this._pending.get(rid);
this._pending.delete(rid);
if (status === 'ok' && code >= 200 && code < 300) {
resolve({service, topic, payload, code, message});
} else {
const err = new Error(message || `Error ${code}`);
err.code = code;
reject(err);
}
return;
}
// Otherwise it's a serverpushed event…
// e.g. env.service === 'event' or env.topic is your event name
this._dispatchEvent(message);
}
// Send a request to the websocket server using JSON string
request(service, topic = null, payload = {}) {
const request_id = String(this._nextId++);
const env = {
request_id: request_id,
service,
...(topic !== null ? {topic} : {}),
token: this.token,
payload: payload
};
const raw = JSON.stringify(env);
this.ws.send(raw);
return new Promise((resolve, reject) => {
this._pending.set(request_id, {resolve, reject});
// TODO: get timeout working to reject if no response in X ms
});
}
subscribe(topic) {
return this.request('active.calls', topic);
}
unsubscribe(topic) {
return this.request('active.calls', topic);
}
// register a callback for server-pushes
onEvent(topic, handler) {
console.log('registering event listener for ' + topic);
if (!this._eventHandlers.has(topic)) {
this._eventHandlers.set(topic, []);
}
this._eventHandlers.get(topic).push(handler);
}
/**
* Dispatch a serverpush event envelope to all registered handlers.
* @param {object} env
*/
_dispatchEvent(message) {
const service = message.service_name;
const topic = message.topic;
const handlers = this._eventHandlers.get(topic) || [];
for (const fn of handlers) {
try {
fn(message.payload);
} catch (err) {
console.error(`Error in handler for "${topic}":`, err);
}
}
}
}

View File

@@ -0,0 +1,33 @@
; Author: Mark J Crane <markjcrane@fusionpbx.com>
; cp /var/www/fusionpbx/app/system/resources/service/debian.service /etc/systemd/system/system_dashboard.service
; systemctl daemon-reload
; systemctl enable --now system_dashboard
[Unit]
Description=FusionPBX System Dashboard Information Service
Wants=network-online.target
Requires=network.target local-fs.target
;Requires=network.target local-fs.target postgresql.service
After=network.target network-online.target local-fs.target
;After=network.target network-online.target local-fs.target postgresql.service
StartLimitIntervalSec=0
[Service]
Type=simple
;Type=forking
PIDFile=/var/run/fusionpbx/system_dashboard.pid
WorkingDirectory=/var/www/fusionpbx
;Environment="USER=www-data"
;Environment="GROUP=www-data"
;EnvironmentFile=-/etc/default/fusionpbx
ExecStartPre=/bin/mkdir -p /var/run/fusionpbx
;ExecStartPre=/bin/chown -R ${USER}:${GROUP} /var/www/fusionpbx
ExecStart=/usr/bin/php /var/www/fusionpbx/app/system/resources/service/system.php --no-fork
User=www-data
Group=www-data
TimeoutSec=55s
Restart=always
[Install]
WantedBy=multi-user.target
Also=