Websockets (#7393)

* Initial commit of websockets

* Move app_menu to the active_calls websockets

* Fix hangup function

* Remove connection wait-state on web socket server so events can process

* Add timestamp and debug level to console for service debug output

* Remove debug exit

* Fix typo for ws_client instead of ws_server

* Update app_config.php

* Fix typo and remove empty function

* Remove call to empty function

* Fix the menu to point to the correct location

* Remove Logging Class

* Rename service file

* Rename service file

* Fix the in progress browser request

* Fix browser reload and implement 'active_calls' default values

* Add apply_filter function

* Create new permission_filter object

* In progress active calls now use filter

* Add invalid_uuid_exception class

* add event_key_filter to honor user permissions

* add and_link and or_link for filters

* Fix disconnected subscriber and add filters to honor permissions

* Add $key and $value for filter

* define a service name

* catch throwable instead of exception

* Add $key and $value for filter and allow returning null

* Update permission checks when loading page

* Add apply_filter function to honor subscriber permissions

* Add create_filter_chain_for function to honor subscriber permissions

* Add apply_filter function to honor subscriber permissions

* Add apply_filter function to honor subscriber permissions

* create interface to allow filterable payload

* create interface to define functions required for websocket services

* Pass in service class when creating a service token

* Allow key/name and return null for filter

* Adjust subscriber exceptions to return the ID of the subscriber

* Add event filter to filter chain

* Add command line options for ip and port for websockets and switch

* update service to use is_a syntax

* initial commit of base class for websockets system services

* initial commit of the system cpu status service

* remove extra line feed

* fix path on active_calls

* initial proof of concept for cpu status updated by websockets

* Allow returning null

* Use default settings to set the interval for cpu status broadcast

* Improve the CPU percent function for Linux systems

* Show more debug information

* Allow child processes to re-connect to the web socket service

* Fix websockets as plural instead of singular

* Add class name list-row

* Update active_calls.php

* Update active_calls.php

* Update websocket_client.js

* Update app_config.php

* Update app_menu.php

* Update debian-websockets.service

* Update debian-active_calls.service

---------

Co-authored-by: FusionPBX <markjcrane@gmail.com>
This commit is contained in:
frytimo
2025-06-24 16:07:57 -03:00
committed by GitHub
parent 86f0561e0c
commit d5286a12bc
44 changed files with 9419 additions and 22 deletions

View File

@@ -0,0 +1,297 @@
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
function create_arrow(direction, color) {
switch (direction) {
case 'inbound':
arrow = create_arrow_inbound(color);
break;
case 'outbound':
arrow = create_arrow_outbound(color);
break;
case 'local':
arrow = create_arrow_local(color);
break;
case 'voicemail':
arrow = create_voicemail_icon(color);
break;
case 'missed':
arrow = create_inbound_missed(color);
break;
}
return arrow;
}
function create_arrow_outbound(color, gridSize = 25) {
// Create SVG from SVG Namespace
const SVG_NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(SVG_NS, "svg");
// compute how much to scale the original 24-unit grid
const scale = gridSize / 25;
// Set color
svg.setAttribute("stroke", color);
// Set brush width
svg.setAttribute("width", gridSize);
svg.setAttribute("height", gridSize);
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
svg.setAttribute("fill", "none");
svg.setAttribute("stroke-width", 2 * scale);
svg.setAttribute("stroke-linecap", "round");
// Create line
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", (4 * scale).toString());
line.setAttribute("y1", (20 * scale).toString());
line.setAttribute("x2", (20 * scale).toString());
line.setAttribute("y2", (4 * scale).toString());
svg.appendChild(line);
// Create the arrow head (a right-angle triangle)
const head = document.createElementNS(SVG_NS, "polygon");
// head.setAttribute("points", "20,4 10,9 15,14");
head.setAttribute("points", [[20, 4], [10, 9], [15, 14]]
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
);
head.setAttribute("fill", color);
svg.appendChild(head);
return svg;
}
function create_arrow_inbound(color, gridSize = 25) {
const SVG_NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(SVG_NS, "svg");
// compute how much to scale the original 24-unit grid
const scale = gridSize / 25;
// size and viewport
svg.setAttribute("width", gridSize);
svg.setAttribute("height", gridSize);
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", color);
svg.setAttribute("stroke-width", 2 * scale);
svg.setAttribute("stroke-linecap", "round");
// scaled line from (4,4) → (20,20)
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", (4 * scale).toString());
line.setAttribute("y1", (4 * scale).toString());
line.setAttribute("x2", (20 * scale).toString());
line.setAttribute("y2", (20 * scale).toString());
svg.appendChild(line);
// scaled triangle head: (20,20), (10,15), (15,10)
const head = document.createElementNS(SVG_NS, "polygon");
head.setAttribute("points", [[20, 20], [10, 15], [15, 10]]
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
);
head.setAttribute("fill", color);
svg.appendChild(head);
return svg;
}
function create_arrow_local(color, gridSize = 25) {
const SVG_NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(SVG_NS, "svg");
// compute how much to scale the original 25-unit grid
const scale = gridSize / 25;
// sizing & styling
svg.setAttribute("width", gridSize);
svg.setAttribute("height", gridSize);
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", color);
svg.setAttribute("stroke-width", 2 * scale);
svg.setAttribute("stroke-linecap", "round");
// shaft
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", 6 * scale);
line.setAttribute("y1", 12 * scale);
line.setAttribute("x2", 18 * scale);
line.setAttribute("y2", 12 * scale);
svg.appendChild(line);
// left arrow head
const leftHead = document.createElementNS(SVG_NS, "polygon");
leftHead.setAttribute("points", [[6,8], [2,12], [6,16]]
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
);
leftHead.setAttribute("fill", color);
svg.appendChild(leftHead);
// right arrow head
const rightHead = document.createElementNS(SVG_NS, "polygon");
rightHead.setAttribute("points", [[18,8], [22,12], [18,16]]
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
);
rightHead.setAttribute("fill", color);
svg.appendChild(rightHead);
return svg;
}
function create_inbound_missed(color, gridSize = 25) {
const SVG_NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(SVG_NS, "svg");
// compute how much to scale the original 25-unit grid
const scale = gridSize / 25;
// size and viewport
svg.setAttribute("width", gridSize);
svg.setAttribute("height", gridSize);
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", color);
svg.setAttribute("stroke-width", 2 * scale);
svg.setAttribute("stroke-linecap", "round");
// 5. Reflective bounce polyline
const bounce = document.createElementNS(SVG_NS, 'polyline');
bounce.setAttribute('points', [[4, 4], [12, 12], [20, 4]]
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
);
bounce.setAttribute('stroke', color);
bounce.setAttribute('stroke-width', 2 * scale);
bounce.setAttribute('fill', 'none');
bounce.setAttribute('stroke-linecap', 'round');
bounce.setAttribute('marker-end', 'url(#arrowhead)');
svg.appendChild(bounce);
// scaled triangle head: tip[20,4], left wing[17,5], right wing[19, 7]
const head = document.createElementNS(SVG_NS, "polygon");
head.setAttribute("points", [[20, 4], [17, 5], [19, 7]]
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
);
head.setAttribute("fill", color);
svg.appendChild(head);
// Left earpiece
const left = document.createElementNS(SVG_NS, 'ellipse');
left.setAttribute("cx", 4 * scale);
left.setAttribute("cy", 17 * scale);
left.setAttribute("rx", 2 * scale);
left.setAttribute("ry", 1 * scale);
left.setAttribute("fill", color);
svg.appendChild(left);
// Right earpiece
const right = document.createElementNS(SVG_NS, 'ellipse');
right.setAttribute("cx", 18 * scale);
right.setAttribute("cy", 17 * scale);
right.setAttribute("rx", 2 * scale);
right.setAttribute("ry", 1 * scale);
right.setAttribute("fill", color);
svg.appendChild(right);
// Arc to join left and right
const startX = 3 * scale; // left cx + rx
const startY = 17 * scale; // cy - ry
const endX = 19 * scale; // right cx - rx
const endY = startY;
// choose radii so the handle bows upwards
const rx = (endX - startX) / 2; // half the distance
const ry = 2.2 * scale; // controls how tall the arc is
const arc = document.createElementNS(SVG_NS, 'path');
// Move to the leftearpiece top, then arc to the rightearpiece top
const d = `M${startX},${startY} A${rx},${ry} 0 0,1 ${endX},${endY}`;
arc.setAttribute('d', d);
arc.setAttribute('stroke', color);
arc.setAttribute('stroke-width', 2 * scale);
arc.setAttribute('stroke-linecap', 'round');
svg.appendChild(arc);
return svg;
}
function create_voicemail_icon(fillColor, gridSize = 25) {
// SVG namespace
const SVG_NS = 'http://www.w3.org/2000/svg';
const scale = gridSize / 25;
const width = scale * 25;
const height = scale * 25;
// Create the root SVG element
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', width);
svg.setAttribute('height', height);
svg.setAttribute('viewBox', '0 0 25 25');
svg.setAttribute('aria-hidden', 'true');
// Border rectangle (inserted first so it's underneath)
const border = document.createElementNS(SVG_NS, 'rect');
y = 7;
border.setAttribute('x', 1);
border.setAttribute('y', y);
border.setAttribute('width', 23);
border.setAttribute('height', 21 - y);
border.setAttribute('fill', 'none');
border.setAttribute('stroke', fillColor);
border.setAttribute('stroke-width', '2');
svg.appendChild(border);
// Left circle
const left_circle = document.createElementNS(SVG_NS, 'circle');
left_circle.setAttribute('cx', 7);
left_circle.setAttribute('cy', 14);
left_circle.setAttribute('r', 3);
left_circle.setAttribute('fill', 'none');
left_circle.setAttribute('stroke', fillColor);
left_circle.setAttribute('stroke-width', '2');
svg.appendChild(left_circle);
// Right circle
const right_circle = document.createElementNS(SVG_NS, 'circle');
right_circle.setAttribute('cx', 18);
right_circle.setAttribute('cy', 14);
right_circle.setAttribute('r', 3);
right_circle.setAttribute('fill', 'none');
right_circle.setAttribute('stroke', fillColor);
right_circle.setAttribute('stroke-width', '2');
svg.appendChild(right_circle);
// Connecting line
const bar = document.createElementNS(SVG_NS, 'line');
bar.setAttribute('x1', 6);
bar.setAttribute('y1', 11);
bar.setAttribute('x2', 19);
bar.setAttribute('y2', 11);
bar.setAttribute('stroke', fillColor);
bar.setAttribute('stroke-width', '2');
svg.appendChild(bar);
return svg;
}

View File

@@ -0,0 +1,136 @@
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.service_name, switch_event);
}
// 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(service, env) {
// if service==='event', topic carries the real event name:
let event = (typeof env === 'string')
? JSON.parse(env)
: env;
// dispatch event handlers
if (service === 'active.calls') {
const topic = event.event_name;
let handlers = this._eventHandlers.get(topic) || [];
if (handlers.length === 0) {
handlers = this._eventHandlers.get('*') || [];
}
for (const fn of handlers) {
try {
fn(event);
} catch (err) {
console.error(`Error in handler for "${topic}":`, err);
}
}
} else {
const handlers = this._eventHandlers.get(service) || [];
for (const fn of handlers) {
try {
fn(event.data, event);
} catch (err) {
console.error(`Error in handler for "${service}":`, err);
}
}
}
}
}