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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,494 @@
<?php
//application details
$apps[$x]['name'] = "Websockets Service";
$apps[$x]['uuid'] = "c43e956a-cd38-4b27-838b-db43dc3f3204";
$apps[$x]['category'] = "";
$apps[$x]['subcategory'] = "";
$apps[$x]['version'] = "1.0";
$apps[$x]['license'] = "Mozilla Public License 1.1";
$apps[$x]['url'] = "http://www.fusionpbx.com";
$apps[$x]['description']['en-us'] = "Web sockets service";
$apps[$x]['description']['en-gb'] = "Web sockets service";
$apps[$x]['description']['ar-eg'] = "";
$apps[$x]['description']['de-at'] = "";
$apps[$x]['description']['de-ch'] = "";
$apps[$x]['description']['de-de'] = "";
$apps[$x]['description']['es-cl'] = "";
$apps[$x]['description']['es-mx'] = "";
$apps[$x]['description']['fr-ca'] = "";
$apps[$x]['description']['fr-fr'] = "";
$apps[$x]['description']['he-il'] = "";
$apps[$x]['description']['it-it'] = "";
$apps[$x]['description']['ka-ge'] = "";
$apps[$x]['description']['nl-nl'] = "";
$apps[$x]['description']['pl-pl'] = "";
$apps[$x]['description']['pt-br'] = "";
$apps[$x]['description']['pt-pt'] = "";
$apps[$x]['description']['ro-ro'] = "";
$apps[$x]['description']['ru-ru'] = "";
$apps[$x]['description']['sv-se'] = "";
$apps[$x]['description']['uk-ua'] = "";
//default settings
$y=0;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "f7d404f6-7184-4eef-9487-7b2ec213c1fc";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "active_calls";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "auto_reload_seconds";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "5";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "false";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Number of seconds before refreshing the active calls page. Use 0 to disable or set to false (default).";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "bfa7aa18-2e66-429d-8756-216f84de2667";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "active_calls";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "remove_completed_calls";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "boolean";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Remove calls when they are completed. Default is True";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "ac1afb07-6b46-43de-8fa0-d30e5868a7fb";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "active_calls";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "truncate_application_data_length";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "80";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Number of characters to show in the application column data. Default value is 80. To disable truncating use 0.";
//permission details
$y=0;
$apps[$x]['permissions'][$y]['name'] = "call_active_view";
$apps[$x]['permissions'][$y]['menu']['uuid'] = "eba3d07f-dd5c-6b7b-6880-493b44113ade";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
//$y++;
//$apps[$x]['permissions'][$y]['name'] = "call_active_transfer";
//$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_eavesdrop";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_hangup";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
//$y++;
//$apps[$x]['permissions'][$y]['name'] = "call_active_park";
//$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
//$y++;
//$apps[$x]['permissions'][$y]['name'] = "call_active_rec";
//$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_all";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_direction";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_profile";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_application";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_codec";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_secure";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
/////////////////////////////////////////////////////////////////////////
// Permissions that could be implemented on a per switch event capture //
/////////////////////////////////////////////////////////////////////////
/*
//
// The following permissions relate to the switch event socket events that are emitted
// Each of the permissions can be assigned to the subscriber so they can receive the
// event when it arrives on the web socket server instance.
//
$apps[$x]['permissions'][$y]['name'] = "event_all";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_command";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_custom";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_clone";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_create";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_destroy";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_state";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_callstate";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_answer";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_hangup";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_hangup_complete";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_execute";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_execute_complete";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_hold";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_unhold";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_bridge";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_unbridge";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_progress";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_progress_media";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_outgoing";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_application";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_originate";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_uuid";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_api";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_log";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_inbound_chan";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_outbound_chan";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_startup";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_shutdown";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_publish";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_unpublish";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_talk";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_notalk";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_session_crash";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_module_load";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_module_unload";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_dtmf";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_message";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_presence_in";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_notify_in";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_presence_out";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_presence_probe";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_message_waiting";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_message_query";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_roster";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_codec";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_background_job";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_detected_speech";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_detected_tone";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_private_command";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_heartbeat";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_trap";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_add_schedule";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_del_schedule";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_exe_schedule";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_re_schedule";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_reloadxml";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_notify";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_phone_feature";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_phone_feature_subscribe";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_send_message";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_recv_message";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_request_params";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_data";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_general";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_command";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_session_heartbeat";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_client_disconnected";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_server_disconnected";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_send_info";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_recv_info";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_recv_rtcp_message";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_send_rtcp_message";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_call_secure";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_nat";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_record_start";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_record_stop";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_playback_start";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_playback_stop";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_call_update";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_failure";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_socket_data";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_media_bug_start";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_media_bug_stop";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_conference_data_query";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_conference_data";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_call_setup_req";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_call_setup_result";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_call_detail";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_device_state";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_text";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_shutdown_requested";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_all";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_park";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_channel_unpark";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
// These events are the API commands and can be expanded for more modules
$y++;
$apps[$x]['permissions'][$y]['name'] = "event_valet_parking::info";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<?php
/*
$y=0;
$apps[$x]['menu'][$y]['title']['en-us'] = "Active Calls";
$apps[$x]['menu'][$y]['title']['en-gb'] = "Active Calls";
@@ -29,10 +29,9 @@
$apps[$x]['menu'][$y]['parent_uuid'] = "0438b504-8613-7887-c420-c837ffb20cb1";
$apps[$x]['menu'][$y]['category'] = "internal";
$apps[$x]['menu'][$y]['icon'] = "";
$apps[$x]['menu'][$y]['path'] = "/app/calls_active/calls_active.php";
$apps[$x]['menu'][$y]['path'] = "/app/active_calls/active_calls.php";
$apps[$x]['menu'][$y]['order'] = "";
$apps[$x]['menu'][$y]['groups'][] = "superadmin";
$apps[$x]['menu'][$y]['groups'][] = "admin";
$y++;
?>
*/

View File

@@ -0,0 +1,853 @@
<?php
/*
* 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>
*/
/**
* Description of active_calls_service
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class active_calls_service extends service implements websocket_service_interface {
const SWITCH_EVENTS = [
['Event-Name' => 'CHANNEL_CREATE'],
['Event-Name' => 'CHANNEL_CALLSTATE'],
['Event-Name' => 'CALL_UPDATE'],
['Event-Name' => 'PLAYBACK_START'],
['Event-Name' => 'PLAYBACK_STOP'],
['Event-Name' => 'CHANNEL_DESTROY'],
['Event-Name' => 'CHANNEL_PARK'],
['Event-Name' => 'CHANNEL_UNPARK'],
['Event-Name' => 'CHANNEL_EXECUTE'],
['Event-Name' => 'HEARTBEAT'], // Ensures that the switch is still responding
['Event-Subclass' => 'valet_parking::info'],
];
const EVENT_KEYS = [
// Event name: CHANNEL_EXECUTE, CHANNEL_DESTROY, NEW_CALL...
'event_name',
// Unique Call Identifier to determine new/existing calls
'unique_id',
// Domain
'caller_context',
// Ringing, Hangup, Answered
'answer_state',
'channel_call_state',
// Time stamp
'caller_channel_created_time',
// Codecs
'channel_read_codec_name',
'channel_write_codec_name',
'channel_read_codec_rate',
'channel_write_codec_rate',
'caller_channel_name',
// Caller/Callee ID
'caller_caller_id_name',
'caller_caller_id_number',
'caller_destination_number',
// Encrypted
'secure',
// Application
'application',
'application_data',
'variable_current_application',
'playback_file_path',
// Valet parking info
'valet_extension',
'action',
'variable_referred_by_user',
'variable_pre_transfer_caller_id_name',
'variable_valet_parking_timeout',
// Direction
'call_direction',
'variable_call_direction',
'other_leg_rdnis',
'other_leg_unique_id',
'content_type',
];
//
// Maps the event key to the permission name
//
const PERMISSION_MAP = [
'channel_read_codec_name' => 'call_active_codec',
'channel_read_codec_rate' => 'call_active_codec',
'channel_write_codec_name' => 'call_active_codec',
'channel_write_codec_rate' => 'call_active_codec',
'caller_channel_name' => 'call_active_profile',
'secure' => 'call_active_secure',
'application' => 'call_active_application',
'playback_file_path' => 'call_active_application',
'variable_current_application'=> 'call_active_application',
];
/**
* Switch Event Socket
* @var event_socket
*/
private $event_socket;
/**
* Web Socket Client
* @var websocket_client
*/
private $ws_client;
/**
* Resource for the Switch Event Socket used to control blocking
* @var resource
*/
private $switch_socket;
private $topics;
/**
* Event Filter
* @var filter
*/
private $event_filter;
private static $switch_port = null;
private static $switch_host = null;
private static $switch_password = null;
private static $websocket_port = null;
private static $websocket_host = null;
/**
* Checks if an event exists in the SWITCH_EVENTS.
* @param string $event_name The value to search for.
* @return bool True if the value is found, false otherwise.
*/
public static function event_exists(string $event_name): bool {
if (!empty($event_name)) {
foreach (active_calls_service::SWITCH_EVENTS as $events) {
if (in_array($event_name, $events)) {
return true;
}
}
}
return false;
}
/**
* Checks if an event exists in the SWITCH_EVENTS ignoring case.
* This check is slower then the event_exists function. Whenever possible, it is recommended to use that function instead.
* @param string $event_name
* @return bool
*/
public static function event_exists_ignore_case(string $event_name): bool {
foreach (self::SWITCH_EVENTS as $events) {
foreach ($events as $value) {
if (strtolower($value) === strtolower($event_name)) {
return true;
}
}
}
return false;
}
/**
* Builds a filter for the subscriber
* @param subscriber $subscriber
* @return filter
*/
public static function create_filter_chain_for(subscriber $subscriber): filter {
return filter_chain::and_link([
new event_filter(self::SWITCH_EVENTS),
new permission_filter(self::PERMISSION_MAP, $subscriber->get_permissions()),
new event_key_filter(self::EVENT_KEYS),
]);
}
/**
* Returns the service name for this service that is used when the web browser clients subscriber
* to this service for updates
* @return string
*/
public static function get_service_name(): string {
return "active.calls";
}
/**
* Returns a string used to execute a hangup command
* @param string $uuid
* @return string
*/
public static function get_hangup_command(string $uuid): string {
return "bgapi uuid_kill $uuid";
}
/**
* Reloads the settings for the service so the service does not have to be restarted
* @return void
*/
protected function reload_settings(): void {
// re-read the config file to get any possible changes
parent::$config->read();
// use the connection information in the config file
// re-connect to the event socket
if ($this->connect_to_event_socket()) {
$this->register_event_socket_filters();
}
// re-connect to the websocket server
$this->connect_to_ws_server();
}
/**
* Displays the version of the active calls service in the console
* @return void
*/
protected static function display_version(): void {
echo "Active Calls Service 1.0\n";
}
/**
* Sets the command line options
*/
protected static function set_command_options() {
parent::append_command_option(
command_option::new()
->description('Set the Port to connect to the switch')
->short_option('p')
->short_description('-p <port>')
->long_option('switch-port')
->long_description('--switch-port <port>')
->callback('set_switch_port')
);
parent::append_command_option(
command_option::new()
->description('Set the IP address for the switch')
->short_option('i')
->short_description('-i <ip_addr>')
->long_option('switch-ip')
->long_description('--switch-ip <ip_addr>')
->callback('set_switch_host_address')
);
parent::append_command_option(
command_option::new()
->description('Set the password to be used with switch')
->short_option('o')
->short_description('-o <password>')
->long_option('switch-password')
->long_description('--switch-password <password>')
->callback('set_switch_password')
);
parent::append_command_option(
command_option::new()
->description('Set the Port to connect to the websockets service')
->short_option('w')
->short_description('-w <port>')
->long_option('websockets-port')
->long_description('--websockets-port <port>')
->callback('set_websockets_port')
);
parent::append_command_option(
command_option::new()
->description('Set the IP address for the websocket')
->short_option('k')
->short_description('-k <ip_addr>')
->long_option('websockets-address')
->long_description('--websockets-address <ip_addr>')
->callback('set_websockets_host_address')
);
}
protected static function set_websockets_port($port): void {
self::$websocket_port = $port;
}
protected static function set_websockets_host_address($host): void {
self::$websocket_host = $host;
}
protected static function set_switch_host_address($host): void {
self::$switch_host = $host;
}
protected static function set_switch_port($port): void {
self::$switch_port = $port;
}
protected static function set_switch_password($password): void {
self::$switch_password = $password;
}
/**
* Main entry point
* @return int Non-zero exit indicates an error has occurred
*/
public function run(): int {
// Notify connected web server socket when we close
register_shutdown_function(function ($ws_client) {
if ($ws_client !== null)
$ws_client->disconnect();
}, $this->ws_client);
// Create an active call filter using filter objects to create an 'OR' chain
$this->event_filter = filter_chain::or_link([new event_key_filter(active_calls_service::EVENT_KEYS)]);
// Register callbacks for the topics
$this->on_topic('in.progress', [$this, 'on_in_progress'] );
$this->on_topic('hangup', [$this, 'on_hangup'] );
$this->on_topic('eavesdrop', [$this, 'on_eavesdrop'] );
$this->on_topic('authenticate', [$this, 'on_authenticate']);
$this->info("Starting " . self::class . " service");
// Suppress the WebSocket Server Error Message so it doesn't flood the system logs
$suppress_ws_message = false;
// Suppress the Event Socket Error Message so it doesn't flood the system logs
$suppress_es_message = false;
while ($this->running) {
$read = [];
// reconnect to event_socket
if ($this->event_socket === null || !$this->event_socket->is_connected()) {
if (!$this->connect_to_event_socket()) {
if (!$suppress_es_message) $this->error("Unable to connect to switch event server");
$suppress_es_message = true;
} else {
$this->register_event_socket_filters();
}
}
// reconnect to websocket server
if ($this->ws_client === null || !$this->ws_client->is_connected()) {
//$this->warn("Web socket disconnected");
if (!$this->connect_to_ws_server()) {
if (!$suppress_ws_message) $this->error("Unable to connect to websocket server.");
$suppress_ws_message = true;
}
}
// The switch _socket_ is used to read the 'data ready' on the stream
if ($this->event_socket !== null && $this->event_socket->is_connected()) {
$read[] = $this->switch_socket;
$suppress_es_message = false;
}
if ($this->ws_client !== null && $this->ws_client->is_connected()) {
$read[] = $this->ws_client->socket();
$suppress_ws_message = false;
}
if (!empty($read)) {
$write = $except = [];
// Wait for an event and timeout at 1/3 of a second so we can re-check all connections
if (false === stream_select($read, $write, $except, 0, 333333)) {
// severe error encountered so exit
$this->running = false;
// Exit with non-zero exit code
return 1;
}
if (!empty($read)) {
$this->debug("Received event");
// Iterate over each socket event
foreach ($read as $resource) {
// Switch event
if ($resource === $this->switch_socket) {
$this->handle_switch_event();
// No need to process more in the loop
continue;
}
// Web socket event
if ($resource === $this->ws_client->socket()) {
$this->handle_websocket_event($this->ws_client);
continue;
}
}
}
}
}
// Normal termination
return 0;
}
private function debug(string $message) {
self::log($message, LOG_DEBUG);
}
private function warn(string $message) {
self::log($message, LOG_WARNING);
}
private function error(string $message) {
self::log($message, LOG_ERR);
}
private function info(string $message) {
self::log($message, LOG_INFO);
}
private function on_authenticate(websocket_message $websocket_message) {
$this->info("Authenticating with websocket server");
// Create a service token
[$token_name, $token_hash] = websocket_client::create_service_token(active_calls_service::get_service_name(), static::class);
// Request authentication as a service
$this->ws_client->authenticate($token_name, $token_hash);
}
private function on_in_progress(websocket_message $websocket_message) {
// Check permission
if (!$websocket_message->has_permission('call_active_view')) {
$this->warn("Permission 'call_active_show' not found in subscriber request");
websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic));
}
// Make sure we are connected
if (!$this->event_socket->is_connected()) {
return;
}
// Set up the response array
$response = [];
$response['service_name'] = SERVICE_NAME;
// Attach the original request ID and subscriber ID given from websocket server so it can route it back
$response['request_id'] = $websocket_message->request_id;
$response['resource_id'] = $websocket_message->resource_id;
$response['status_string'] = 'ok';
$response['status_code'] = 200;
// Get the active calls from the helper function
$calls = $this->get_active_calls($this->event_socket, $this->ws_client);
$count = count($calls);
$this->debug("Sending calls in progress ($count)");
// Use the subscribers permissions to filter out the event keys not permitted
$filter = filter_chain::or_link([new permission_filter(self::PERMISSION_MAP, $websocket_message->permissions())]);
/** @var event_message $event */
foreach ($calls as $event) {
// Remove keys that are not permitted by filter
$event->apply_filter($filter);
$response['payload'] = $event;
$response['topic'] = $event->name;
$websocket_response = new websocket_message($response);
websocket_client::send($this->ws_client->socket(), $websocket_response);
}
}
private function on_hangup(websocket_message $websocket_message) {
// Check permission
if (!$websocket_message->has_permission('call_active_hangup')) {
$this->warn("Permission 'call_active_hangup' not found in subscriber request");
websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic));
}
// Make sure we are connected
if (!$this->event_socket->is_connected()) {
$this->warn("Failed to hangup call because event socket no longer connected");
return;
}
// Set up the response array
$response = [];
$response['service_name'] = SERVICE_NAME;
$response['topic'] = $websocket_message->topic;
$response['request_id'] = $websocket_message->request_id;
// Get the payload
$payload = $websocket_message->payload;
// Get the UUID from the payloadc
$uuid = $payload['unique_id'] ?? '';
$response['status_message'] = 'success';
$response['status_code'] = 200;
//Notify switch to hangup and ignore the response
$response = $this->event_socket->request("bgapi uuid_kill $uuid");
// Notify websocket server of the result
websocket_client::send($this->ws_client->socket(), new websocket_message($response));
}
private function on_eavesdrop(websocket_message $websocket_message) {
// Check permission
if (!$websocket_message->has_permission('call_active_eavesdrop')) {
$this->warn("Permission 'call_active_eavesdrop' not found in subscriber request");
websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic));
}
// Make sure we are connected
if (!$this->event_socket->is_connected()) {
$this->warn("Failed to hangup call because event socket no longer connected");
return;
}
// Set up the response array
$response = [];
$response['service_name'] = SERVICE_NAME;
$response['topic'] = $websocket_message->topic;
$response['request_id'] = $websocket_message->request_id;
// Get the payload
$payload = $websocket_message->payload();
// Get the eavesdrop information from the payload to send to the switch
$uuid = $payload['unique_id'] ?? '';
$origination_caller_id_name = $payload['origination_caller_id_name'] ?? '';
$caller_caller_id_number = $payload['caller_caller_id_number'] ?? '';
$origination_caller_contact = $payload['origination_caller_contact'] ?? '';
$domain_name = $payload['domain_name'] ?? '';
$response['status_message'] = 'success';
$response['status_code'] = 200;
$api_cmd = "bgapi originate {origination_caller_id_name=$origination_caller_id_name,origination_caller_id_number=$caller_caller_id_number}user/$origination_caller_contact@$domain_name &eavesdrop($uuid)";
// Log the eavesdrop
$this->info("Eavesdrop on $uuid by $origination_caller_contact@$domain_name");
//
// Send to the switch and ignore the result
// Ignoring the switch information is important because on a busy system there will be more
// events so the response is not necessarily correct
//
$this->event_socket->request($api_cmd);
// Execute eavesdrop command
$response['status_message'] = 'success';
$response['status_code'] = 200;
// Notify websocket server of the result
websocket_client::send($this->ws_client->socket(), new websocket_message($response));
}
/**
* Connects to the web socket server using a websocket_client object
* @return bool
*/
private function connect_to_ws_server(): bool {
$host = self::$websocket_host ?? self::$config->get('websocket.host', '127.0.0.1');
$port = self::$websocket_port ?? self::$config->get('websocket.port', 8080);
try {
// Create a websocket client
$this->ws_client = new websocket_client("ws://$host:$port");
// Block stream for handshake and authentication
$this->ws_client->set_blocking(true);
// Connect to web socket server
$this->ws_client->connect();
// Disable the stream blocking
$this->ws_client->set_blocking(false);
$this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket());
} catch (\RuntimeException $re) {
//unable to connect
return false;
}
return true;
}
/**
* Connects to the switch event socket
* @return bool
*/
private function connect_to_event_socket(): bool {
// check if we have defined it already
if (!isset($this->switch_socket)) {
//default to false for the while loop below
$this->switch_socket = false;
}
// When no command line option is used to set the switch host, port, or password, get it from
// the config file. If it is not in the config file, then set a default value
$host = self::$switch_host ?? parent::$config->get('switch.event_socket.host', '127.0.0.1');
$port = self::$switch_port ?? parent::$config->get('switch.event_socket.port', 8021);
$password = self::$switch_password ?? parent::$config->get('switch.event_socket.password', 'ClueCon');
try {
//set up the socket away from the event_socket object so we have control over blocking
$this->switch_socket = stream_socket_client("tcp://$host:$port", $errno, $errstr, 5);
} catch (\RuntimeException $re) {
$this->warn('Unable to connect to event socket');
}
// If we didn't connect then return back false
if (!$this->switch_socket) {
return false;
}
// Block (wait) for responses so we can authenticate
stream_set_blocking($this->switch_socket, true);
// Create the event_socket object using the connected socket
$this->event_socket = new event_socket($this->switch_socket);
// The host and port are already provided when we connect the socket so just provide password
$this->event_socket->connect(null, null, $password);
// No longer need to wait for events
stream_set_blocking($this->switch_socket, false);
return $this->event_socket->is_connected();
}
/**
* Registers the switch events needed for active calls
*/
private function register_event_socket_filters() {
$this->event_socket->request('event plain all');
//
// CUSTOM and API are required to handle events such as:
// - 'valet_parking::info'
// - 'SMS::SEND_MESSAGE'
// - 'cache::flush'
// - 'sofia::register'
//
// $event_filter = [
// 'CUSTOM', // Event-Name is swapped with Event-Subclass
// 'API', // Event-Name is swapped with API-Command
// ];
// Merge API and CUSTOM with the events listening
// $events = array_merge(ws_active_calls::SWITCH_EVENTS, $event_filter);
// Add filters for active calls only
foreach (active_calls_service::SWITCH_EVENTS as $events) {
foreach ($events as $event_key => $event_name) {
$this->debug("Requesting event filter for [$event_key]=[$event_name]");
$response = $this->event_socket->request("filter $event_key $event_name");
while (!is_array($response)) {
$response = $this->event_socket->read_event();
}
if (is_array($response)) {
while (($response = array_pop($response)) !== "+OK filter added. [$event_key]=[$event_name]") {
$response = $this->event_socket->read_event();
usleep(1000);
}
}
$this->debug("Response: " . $response);
}
}
}
private function get_active_calls(): array {
$calls = [];
//
// We use a new socket connection to get the response because the switch
// can be firing events while we are processing so we need only this
// request answered
//
$event_socket = new event_socket();
$event_socket->connect();
// Make sure we are connected
if (!$event_socket->is_connected()) {
return $calls;
}
// Send the command on a new channel
$json = trim($event_socket->request('api show channels as json'));
$event_socket->close();
$json_array = json_decode($json, true);
if (empty($json_array["rows"])) {
return $calls;
}
// Map the rows returned to the active call format
foreach ($json_array["rows"] as $call) {
$message = new event_message($call, $this->event_filter);
$this->debug("MESSAGE: $message");
// adjust basic info to match an event setting the callstate to ringing
// so that a row can be created for it
$message->event_name = 'CHANNEL_CALLSTATE';
$message->answer_state = 'ringing';
$message->channel_call_state = 'RINGING';
$message->unique_id = $call['uuid'];
$message->call_direction = $call['direction'];
//set the codecs
$message->caller_channel_created_time = intval($call['created_epoch']) * 1000000;
$message->channel_read_codec_name = $call['read_codec'];
$message->channel_read_codec_rate = $call['read_rate'];
$message->channel_write_codec_name = $call['write_codec'];
$message->channel_write_codec_rate = $call['write_rate'];
//get the profile name
$message->caller_channel_name = $call['name'];
//domain or context
$message->caller_context = $call['context'];
$message->caller_caller_id_name = $call['initial_cid_name'];
$message->caller_caller_id_number = $call['initial_cid_num'];
$message->caller_destination_number = $call['initial_dest'];
$message->application = $call['application'] ?? '';
$message->secure = $call['secure'] ?? '';
if (true) {
$this->debug("-------- ACTIVE CALL ----------");
$this->debug($message);
$this->debug("In Progress: '$message->name', $message->unique_id");
$this->debug("-------------------------------");
}
$calls[] = $message;
}
return $calls;
}
/**
* Call each of the registered events for the websocket topic that has arrived
* @param string $topic
* @param websocket_message $websocket_message
*/
private function trigger_topic(string $topic, websocket_message $websocket_message) {
if (empty($topic) || empty($websocket_message)) {
return;
}
if (!empty($this->topics[$topic])) {
foreach ($this->topics[$topic] as $callback) {
call_user_func($callback, $websocket_message);
}
}
}
/**
* Allows the service to register a callback so when the topic arrives the callable is called
* @param type $topic
* @param type $callable
*/
public function on_topic($topic, $callable) {
if (!isset($this->topics[$topic])) {
$this->topics[$topic] = [];
}
$this->topics[$topic][] = $callable;
}
/**
* Handles the message from the web socket client and triggers the appropriate requested topic event
* @param resource $ws_client
* @return void
*/
private function handle_websocket_event() {
// Read the JSON string
$json_string = $this->ws_client->read();
// Nothing to do
if ($json_string === null) {
$this->warn('Message received from Websocket is empty');
return;
}
$this->debug("Received message on websocket: $json_string (" . strlen($json_string) . " bytes)");
// Get the web socket message as an object
$message = websocket_message::create_from_json_message($json_string);
// Nothing to do
if (empty($message->topic())) {
$this->error("Message received does not have topic");
return;
}
// Call the registered topic event
$this->trigger_topic($message->topic, $message, $this->ws_client);
}
/**
* Handles a switch event by reading the event and then dispatching to the web socket server
*/
private function handle_switch_event() {
$raw_event = $this->event_socket->read_event();
//$this->debug("=====================================");
//$this->debug("RAW EVENT: " . ($raw_event['$'] ?? ''));
//$this->debug("=====================================");
// get the switch message event object
$event = event_message::create_from_switch_event($raw_event, $this->event_filter);
// Log the event
$this->debug("EVENT: '" . $event->name . "'");
if (!$this->ws_client->is_connected()) {
$this->debug('Not connected to websocket host. Dropping Event');
return;
}
// Ensure it is an event that we are looking for
if (active_calls_service::event_exists($event->name)) {
// Create a message to send on websocket
$message = new websocket_message();
// Set the service name so subscribers can filter
$message->service(SERVICE_NAME);
// Set the topic to the event name
$message->topic = $event->name;
// The event is the payload
$message->payload($event->to_array());
// Notify system log of the message and event name
$this->debug("Sending Event: '$event->event_name'");
//send event to the web socket routing service
websocket_client::send($this->ws_client->socket(), $message);
}
}
/**
* Gets the array of enabled domains using the UUID as the array key and the domain name as the array value
* @return array
*/
private static function get_domain_names(database $database): array {
return array_column($database->execute("select domain_name, domain_uuid from v_domains where domain_enabled='true'") ?: [], 'domain_name', 'domain_uuid');
}
/**
* Queries the database to return the domain name for the given uuid
* @param string $domain_uuid
* @return string
*/
private static function get_domain_name_by_uuid(database $database, string $domain_uuid): string {
return $database->execute("select domain_name from v_domains where domain_enabled='true' and domain_uuid = :domain_uuid limit 1", ['domain_uuid' => $domain_uuid], 'column') ?: '';
}
/**
* Queries the database to return a single domain uuid for the given name. If more then one match is possible use the get_domain_names function.
* @param string $domain_name
* @return string
*/
private static function get_domain_uuid_by_name(database $database, string $domain_name): string {
return $database->execute("select domain_uuid from v_domains where domain_enabled='true' and domain_name = :domain_name limit 1", ['domain_name' => $domain_name], 'column') ?: '';
}
}

View File

@@ -0,0 +1,85 @@
<?php
/*
* 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>
*/
/**
* Filter an event based on event name or event subclass or event command
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class event_filter implements filter {
private $event_names;
public function __construct(array $event_names) {
$this->add_event_names($event_names);
}
public function __invoke(string $key, $value): ?bool {
if ($key !== 'event_name') {
return true;
}
return $this->has_event_name($value);
}
/**
* Adds a single event name filter
* @param string $name
*/
public function add_event_name(string $name) {
$this->event_names[$name] = $name;
}
/**
* Adds the array list to the filters.
* @param array $event_names
*/
public function add_event_names(array $event_names) {
// Add all event key filters passed
foreach ($event_names as $event_name) {
if (is_array($event_name)) {
$this->add_event_names($event_name);
} else {
$this->add_event_name($event_name);
}
}
}
public function has_event_name(string $name): ?bool {
if (isset($this->event_names[$name]))
return true;
//
// If the event name is not allowed by the permissions given in
// this object, then the entire event must be dropped. I could
// not figure out a better way to do this except to throw an
// exception so that the caller can drop the message.
//
// TODO: Find another way not so expensive to reject the payload
//
return null;
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* 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>
*/
/**
* Active call filter class definition
* @author Tim Fry <tim@fusionpbx.com>
*/
class event_key_filter implements filter {
private $filters;
public function __construct(array $filters = []) {
$this->add_filters($filters);
}
public function __invoke(string $key, $value): ?bool {
return $this->has_filter_key($key);
}
/**
* Adds a single filter
* @param string $key
*/
public function add_filter(string $key) {
$this->filters[$key] = $key;
}
/**
* Returns the current list of filters
* @return array
*/
public function get_filters(): array {
return array_values($this->filters);
}
/**
* Removes a single list of filters
* @param string $key
*/
public function remove_filter(string $key) {
unset($this->filters[$key]);
}
/**
* Clears all filters
*/
public function clear_filters() {
$this->filters = [];
}
/**
* Adds the array list to the filters.
* @param array $list_of_keys
*/
public function add_filters(array $list_of_keys) {
// Add all event key filters passed
foreach ($list_of_keys as $key) {
$this->filters[$key] = $key;
}
}
public function has_filter_key(string $key): bool {
return isset($this->filters[$key]);
}
}

View File

@@ -0,0 +1,323 @@
<?php
/*
* 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>
*/
/**
* Tracks switch events in an object instead of array
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class event_message implements filterable_payload {
const BODY_ARRAY_KEY = '_body';
const EVENT_SWAP_API = 0x01;
const EVENT_USE_SUBCLASS = 0x02;
// Default keys in the event to capture
public static $keys = [];
/**
* Associative array to store the event with the key name always lowercase and the hyphen replaced with an underscore
* @var array
*/
private $event;
private $body;
/**
* Only permitted keys on this list are allowed to be inserted in to the event_message object
* @var filter
*/
private $event_filter;
/**
* Creates an event message
* @param array $event_array
* @param filter $filter
*/
public function __construct(array $event_array, ?filter $filter = null) {
// Set the event to an empty array
$this->event = [];
// Clear the memory area for body and key_filter
$this->body = null;
$this->event_filter = $filter;
// Set the event array to match
foreach ($event_array as $name => $value) {
$this->__set($name, $value);
}
}
/**
* Sanitizes the key name and then stores the value in the event property as an associative array
* @param string $name
* @param string $value
* @return void
*/
public function __set(string $name, $value) {
self::sanitize_event_key($name);
// Use the filter chain to ensure the key is allowed
if ($this->event_filter === null || ($this->event_filter)($name, $value)) {
$this->event[$name] = $value;
}
}
/**
* Sanitizes the key name and then returns the value stored in the event property
* @param string $name Name of the event key
* @return string Returns the stored value or an empty string
*/
public function __get(string $name) {
self::sanitize_event_key($name);
if ($name === 'name') $name = 'event_name';
return $this->event[$name] ?? '';
}
public function __toArray(): array {
$array = [];
foreach ($this->event as $key => $value) {
$array[$key] = $value;
}
return $array;
}
public function to_array(): array {
return $this->__toArray();
}
public function apply_filter(filter $filter) {
foreach ($this->event as $key => $value) {
$result = ($filter)($key, $value);
if ($result === null) {
$this->event = [];
} elseif (!$result) {
unset($this->event[$key]);
}
}
return $this;
}
public static function parse_active_calls($json_string): array {
$calls = [];
$json_array = json_decode($json_string, true);
if (empty($json_array["rows"])) {
return $calls;
}
foreach ($json_array["rows"] as $call) {
$message = new event_message($call);
// adjust basic info to match an event setting the callstate to ringing
// so that a row can be created for it
$message['event_name'] = 'CHANNEL_CALLSTATE';
$message['answer_state'] = 'ringing';
$message['channel_call_state'] = 'ACTIVE';
$message['unique_id'] = $call['uuid'];
$message['call_direction'] = $call['direction'];
//set the codecs
$message['caller_channel_created_time'] = intval($call['created_epoch']) * 1000000;
$message['channel_read_codec_name'] = $call['read_codec'];
$message['channel_read_codec_rate'] = $call['read_rate'];
$message['channel_write_codec_name'] = $call['write_codec'];
$message['channel_write_codec_rate'] = $call['write_rate'];
//get the profile name
$message['caller_channel_name'] = $call['name'];
//domain or context
$message['caller_context'] = $call['context'];
$message['caller_caller_id_name'] = $call['initial_cid_name'];
$message['caller_caller_id_number'] = $call['initial_cid_num'];
$message['caller_destination_number'] = $call['initial_dest'];
$message['application'] = $call['application'] ?? '';
$message['secure'] = $call['secure'] ?? '';
$calls[] = $message;
}
return $calls;
}
/**
* Creates a websocket_message_event object from a json string
* @param type $json_string
* @return self|null
*/
public static function create_from_json($json_string) {
if (is_array($json_string)) {
print_r(debug_backtrace());
die();
}
$array = json_decode($json_string, true);
if ($array !== false) {
return new static($array);
}
return null;
}
public static function create_from_switch_event($raw_event, filter $filter = null, $flags = 3): self {
// Set the options from the flags passed
$swap_api_name_with_event_name = ($flags & self::EVENT_SWAP_API) !== 0;
$swap_subclass_event_name_with_event_name = ($flags & self::EVENT_USE_SUBCLASS) !== 0;
// Get the payload and ignore the headers
if (is_array($raw_event) && isset($raw_event['$'])) {
$raw_event = $raw_event['$'];
}
//check if it is still an array
if (is_array($raw_event)) {
$raw_event = array_pop($raw_event);
}
$event_array = [];
foreach (explode("\n", $raw_event) as $line) {
$parts = explode(':', $line, 2);
$key = '';
$value = '';
if (count($parts) > 0) {
$key = $parts[0];
}
if (count($parts) > 1) {
$value = urldecode(trim($parts[1]));
}
if (!empty($key)) {
$event_array[$key] = $value;
}
}
//check for body
if (!empty($event_array['Content-Length'])) {
$event_array['_body'] = substr($raw_event, -1*$event_array['Content-Length']);
}
// Instead of using 'CUSTOM' for the Event-Name we use the actual API-Command when it is available instead
if ($swap_api_name_with_event_name && !empty($event_array['API-Command'])) {
// swap the values
[$event_array['Event-Name'], $event_array['API-Command']] = [$event_array['API-Command'], $event_array['Event-Name']];
}
// Promote the Event-Subclass name to the Event-Name
if ($swap_subclass_event_name_with_event_name && !empty($event_array['Event-Subclass'])) {
// swap the values
[$event_array['Event-Name'], $event_array['Event-Subclass']] = [$event_array['Event-Subclass'], $event_array['Event-Name']];
}
// Return the new object
return new static($event_array, $filter);
}
/**
* Return a Json representation for this object when the object is echoed or printed
* @return string
* @override websocket_message
*/
public function __toString(): string {
return json_encode($this->to_array());
}
/**
* Set or Get the body
* @param null|string $body
* @return self|string
*/
public function body(?string $body = null) {
// Check if we are setting the value for body
if (func_num_args() > 0) {
// Set the value
$this->body = $body;
// Return the object for chaining
return $this;
}
// A request was made to get the value from body
return $this->body;
}
public function event_to_array(): array {
$array = [];
foreach ($this->event as $key => $value) {
$array[$key] = $value;
}
if ($this->body !== null) {
$array[self::BODY_ARRAY_KEY] = $this->body;
}
return $array;
}
public function getIterator(): \Traversable {
yield from $this->event_to_array();
}
public function offsetExists(mixed $offset): bool {
self::sanitize_event_key($offset);
return isset($this->event[$offset]);
}
public function offsetGet(mixed $offset): mixed {
self::sanitize_event_key($offset);
if ($offset === self::BODY_ARRAY_KEY) {
return $this->body;
}
return $this->event[$offset];
}
public function offsetSet(mixed $offset, mixed $value): void {
self::sanitize_event_key($offset);
if ($offset === self::BODY_ARRAY_KEY) {
$this->body = $value;
} else {
$this->event[$offset] = $value;
}
}
public function offsetUnset(mixed $offset): void {
self::sanitize_event_key($offset);
if ($offset === self::BODY_ARRAY_KEY) {
$this->body = null;
} else {
unset($this->event[$offset]);
}
}
/**
* Sanitizes key by replacing '-' with '_', converts to lowercase, and only allows digits 0-9 and letters a-z
* @param string $key
* @return string
*/
public static function sanitize_event_key(string &$key) /* : never */ {
$key = preg_replace('/[^a-z0-9_]/', '', str_replace('-', '_', strtolower($key)));
//rewrite 'name' to 'event_name'
if ($key === 'name') $key = 'event_name';
}
}

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);
}
}
}
}
}

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/*
* 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>
*/
/**
* The purpose of this file is to subscribe to the switch events and then notify the web socket service of the event.
*
* Requirements:
* - PHP 7.1 or higher
*
* When an event is received from the switch, it is sent to the web socket service. The web socket service then
* broadcasts the event to all subscribers that have subscribed to the service that broadcasted the event. The
* web socket service only has information about who is connected. Each connection to the web socket service
* is called a subscriber. Subscribers can be either a service or a web client. When a token is created and
* given to the subscriber class using the "save_token" method, the subscriber is a web client. When the
* method used is "save_service_token", the subscriber is still a subscriber but now has elevated privileges.
* Each service can still subscribe to other events from other services just like regular subscribers. But,
* services have the added ability to broadcast events to other subscribers.
*
* Line 1 of this file allows the script to be executable
*/
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n");
}
require_once dirname(__DIR__, 4) . '/resources/require.php';
define('SERVICE_NAME', active_calls_service::get_service_name());
try {
$active_calls_service = active_calls_service::create();
// Exit using whatever status run returns
exit($active_calls_service->run());
} catch (Exception $ex) {
echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage();
// Exit with error code
exit($ex->getCode());
}

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Active Calls Websocket Service
[Service]
ExecStart=/usr/bin/php /var/www/fusionpbx/app/active_calls/resources/service/active_calls.php --no-fork
Restart=on-failure
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target

View File

@@ -29,7 +29,11 @@
$apps[$x]['description']['ru-ru'] = "Активные каналы в системе";
$apps[$x]['description']['sv-se'] = "";
$apps[$x]['description']['uk-ua'] = "";
/*
*
* Permissions have been migrated to the active_calls app
*
*
//permission details
$y=0;
$apps[$x]['permissions'][$y]['name'] = "call_active_view";
@@ -68,4 +72,4 @@
$y++;
$apps[$x]['permissions'][$y]['name'] = "call_active_secure";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
?>
*/

View File

@@ -0,0 +1,55 @@
<?php
/*
* The MIT License
*
* Copyright 2025 Tim Fry <tim@fusionpbx.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* Description of bsd_system_information
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class bsd_system_information extends system_information {
public function get_cpu_cores(): int {
$result = shell_exec("dmesg | grep -i --max-count 1 CPUs | sed 's/[^0-9]*//g'");
$cpu_cores = trim($result);
return $cpu_cores;
}
//get the CPU details
public function get_cpu_percent(): float {
$result = shell_exec('ps -A -o pcpu');
$percent_cpu = 0;
foreach (explode("\n", $result) as $value) {
if (is_numeric($value)) {
$percent_cpu = $percent_cpu + $value;
}
}
return $percent_cpu;
}
public function get_uptime() {
return shell_exec('uptime');
}
}

View File

@@ -0,0 +1,78 @@
<?php
/*
* The MIT License
*
* Copyright 2025 Tim Fry <tim@fusionpbx.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* Description of linux_system_information
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class linux_system_information extends system_information {
public function get_cpu_cores(): int {
$result = @trim(shell_exec("grep -P '^processor' /proc/cpuinfo"));
$cpu_cores = count(explode("\n", $result));
return $cpu_cores;
}
//get the CPU details
public function get_cpu_percent(): float {
$stat1 = file_get_contents('/proc/stat');
usleep(500000);
$stat2 = file_get_contents('/proc/stat');
$lines1 = explode("\n", trim($stat1));
$lines2 = explode("\n", trim($stat2));
$percent_cpu = 0;
$core_count = 0;
foreach ($lines1 as $i => $line1) {
if (strpos($line1, 'cpu') !== 0 || $line1 === 'cpu') continue;
$parts1 = preg_split('/\s+/', $line1);
$parts2 = preg_split('/\s+/', $lines2[$i]);
$total1 = array_sum(array_slice($parts1, 1));
$total2 = array_sum(array_slice($parts2, 1));
$idle1 = $parts1[4];
$idle2 = $parts2[4];
$total_delta = $total2 - $total1;
$idle_delta = $idle2 - $idle1;
$cpu_usage = ($total_delta - $idle_delta) / $total_delta * 100;
$percent_cpu += $cpu_usage;
$core_count++;
}
return round($percent_cpu / $core_count, 2);
}
public function get_uptime() {
return shell_exec('uptime');
}
}

View File

@@ -0,0 +1,134 @@
<?php
class system_dashboard_service extends base_websocket_system_service {
const PERMISSIONS = [
'system_view_cpu',
'system_view_backup',
'system_view_database',
'system_view_hdd',
'system_view_info',
'system_view_memcache',
'system_view_ram',
'system_view_support',
];
const CPU_STATUS_TOPIC = 'cpu_status';
/**
*
* @var system_information $system_information
*/
protected static $system_information;
/**
* Settings object
* @var settings
*/
private $settings;
/**
* Integer representing the number of seconds to broadcast the CPU usage
* @var int
*/
private $cpu_status_refresh_interval;
protected function reload_settings(): void {
static::set_system_information();
// re-read the config file to get any possible changes
parent::$config->read();
// re-connect to the websocket server if required
$this->connect_to_ws_server();
// Connect to the database
$database = new database(['config' => parent::$config]);
// get the interval
$this->settings = new settings(['database' => $database]);
// get the settings from the global defaults
$this->cpu_status_refresh_interval = $this->settings->get('dashboard', 'cpu_status_refresh_interval', 3);
}
/**
* @override base_websocket_system_service
* @return void
*/
protected function on_timer(): void {
// Send the CPU status
$this->on_cpu_status();
}
/**
* Executes once
* @return void
*/
protected function register_topics(): void {
// get the settings from the global defaults
$this->reload_settings();
// Create a system information object that can tell us the cpu regardless of OS
self::$system_information = system_information::new();
// Register the call back to respond to cpu_status requests
$this->on_topic('cpu_status', [$this, 'on_cpu_status']);
// Set a timer
$this->set_timer($this->cpu_status_refresh_interval);
// Notify the user of the interval
$this->info("Broadcasting CPU Status every {$this->cpu_status_refresh_interval}s");
}
public function on_cpu_status($message = null): void {
// Get the CPU status
$cpu_percent = self::$system_information->get_cpu_percent();
// Send the response
$response = new websocket_message();
$response
->payload([self::CPU_STATUS_TOPIC => $cpu_percent])
->service_name(self::get_service_name())
->topic(self::CPU_STATUS_TOPIC);
// Check if we are responding
if ($message !== null && $message instanceof websocket_message) {
$response->id($message->id());
}
// Log the broadcast
$this->debug("Broadcasting CPU percent '$cpu_percent'");
// Send to subscribers
$this->respond($response);
}
public static function get_service_name(): string {
return "dashboard.system.information";
}
public static function create_filter_chain_for(subscriber $subscriber): ?filter {
// Get the subscriber permissions
$permissions = $subscriber->get_permissions();
// Create a filter
$filter = filter_chain::and_link([new permission_filter()]);
// Match them to create a filter
foreach (self::PERMISSIONS as $permission) {
if (in_array($permission, $permissions)) {
$filter->add_permission($permission);
}
}
// Return the filter with user permissions to ensure they can't receive information they shouldn't
return $filter;
}
public static function set_system_information(): void {
self::$system_information = system_information::new();
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* The MIT License
*
* Copyright 2025 Tim Fry <tim@fusionpbx.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* Description of system_information
*
* @author Tim Fry <tim@fusionpbx.com>
*/
abstract class system_information {
abstract public function get_cpu_cores(): int;
abstract public function get_uptime();
abstract public function get_cpu_percent(): float;
public function get_load_average() {
return sys_getloadavg();
}
public static function new(): ?system_information {
if (stristr(PHP_OS, 'BSD')) {
return new bsd_system_information();
}
if (stristr(PHP_OS, 'Linux')) {
return new linux_system_information();
}
return null;
}
}

View File

@@ -1,9 +1,9 @@
<?php
//includes files
//includes files
require_once dirname(__DIR__, 4) . "/resources/require.php";
//check permisions
//check permisions
require_once "resources/check_auth.php";
if (permission_exists('xml_cdr_view')) {
//access granted
@@ -13,19 +13,19 @@
exit;
}
//add multi-lingual support
//add multi-lingual support
$language = new text;
$text = $language->get($_SESSION['domain']['language']['code'], 'app/system');
//system cpu status
//system cpu status
echo "<div class='hud_box'>\n";
//set the row style class names
//set the row style class names
$c = 0;
$row_style["0"] = "row_style0";
$row_style["1"] = "row_style1";
//get the CPU details
//get the CPU details
if (stristr(PHP_OS, 'BSD') || stristr(PHP_OS, 'Linux')) {
$result = shell_exec('ps -A -o pcpu');
@@ -50,17 +50,88 @@
}
//show the content
//show the content
echo "<div class='hud_content' ".($dashboard_details_state == "disabled" ?: "onclick=\"$('#hud_system_cpu_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"").">\n";
echo " <span class='hud_title'><a onclick=\"document.location.href='".PROJECT_PATH."/app/system/system.php'\">".$text['label-cpu_usage']."</a></span>\n";
//add half doughnut chart
if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut") {
?>
$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()]);
//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 system_cpu_status_chart = new Chart(
const authToken = {
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 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);
client.ws.addEventListener("open", async () => {
try {
console.log('Connected');
console.log('Requesting authentication');
await client.request('authentication');
client.onEvent(cpuStatusTopic, updateCpuChart);
client.request(serviceName, cpuStatusTopic);
} catch (err) {
console.error("WS setup failed: ", err);
return;
}
});
client.ws.addEventListener("close", async () => {
console.warn("Websocket Disconnected");
});
}
function bindEventHandlers(client) {
client.onEvent(cpuStatusTopic, updateCpuChart);
}
function updateCpuChart(payload) {
let cpuPercent = 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];
// 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];
} else {
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[2];
}
chart.options.plugins.chart_number_2.text = cpuPercent;
chart.update();
}
window.system_cpu_status_chart = new Chart(
document.getElementById('system_cpu_status_chart').getContext('2d'),
{
type: 'doughnut',
@@ -80,7 +151,7 @@
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_sub_color') ?? '#d4d4d4'); ?>'
],
borderColor: '<?php echo $settings->get('theme', 'dashboard_chart_border_color'); ?>',
borderWidth: '<?php echo $settings->get('theme', 'dashboard_chart_border_width'); ?>',
borderWidth: '<?php echo $settings->get('theme', 'dashboard_chart_border_width'); ?>'
}]
},
options: {
@@ -92,7 +163,7 @@
},
tooltip: {
yAlign: 'bottom',
displayColors: false,
displayColors: false
}
}
},
@@ -110,9 +181,11 @@
}]
}
);
connectWebsocket();
</script>
<?php
}
<?php endif; ?>
<?php
if ($dashboard_chart_type == "number") {
echo "<span class='hud_stat'>".round($percent_cpu)."%</span>";
}

View File

@@ -0,0 +1,173 @@
<?php
//includes files
require_once dirname(__DIR__, 4) . "/resources/require.php";
//check permisions
require_once "resources/check_auth.php";
if (permission_exists('xml_cdr_view')) {
//access granted
}
else {
echo "access denied";
exit;
}
//add multi-lingual support
$language = new text;
$text = $language->get($_SESSION['domain']['language']['code'], 'app/system');
//system cpu status
echo "<div class='hud_box'>\n";
//set the row style class names
$c = 0;
$row_style["0"] = "row_style0";
$row_style["1"] = "row_style1";
//get the CPU details
if (stristr(PHP_OS, 'BSD') || stristr(PHP_OS, 'Linux')) {
$result = shell_exec('ps -A -o pcpu');
$percent_cpu = 0;
foreach (explode("\n", $result) as $value) {
if (is_numeric($value)) { $percent_cpu = $percent_cpu + $value; }
}
if (stristr(PHP_OS, 'BSD')) {
$result = shell_exec("dmesg | grep -i --max-count 1 CPUs | sed 's/[^0-9]*//g'");
$cpu_cores = trim($result);
}
if (stristr(PHP_OS, 'Linux')) {
$result = @trim(shell_exec("grep -P '^processor' /proc/cpuinfo"));
$cpu_cores = count(explode("\n", $result));
}
if ($cpu_cores > 1) { $percent_cpu = $percent_cpu / $cpu_cores; }
$percent_cpu = round($percent_cpu, 2);
//uptime
$result = shell_exec('uptime');
$load_average = sys_getloadavg();
}
//show the content
echo "<div class='hud_content' ".($dashboard_details_state == "disabled" ?: "onclick=\"$('#hud_system_cpu_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"").">\n";
echo " <span class='hud_title'><a onclick=\"document.location.href='".PROJECT_PATH."/app/system/system.php'\">".$text['label-cpu_usage']."</a></span>\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 system_cpu_status_chart = new Chart(
document.getElementById('system_cpu_status_chart').getContext('2d'),
{
type: 'doughnut',
data: {
datasets: [{
data: ['<?php echo $percent_cpu; ?>', 100 - '<?php echo $percent_cpu; ?>'],
backgroundColor: [
<?php
if ($percent_cpu <= 60) {
echo "'".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[0] ?? '#03c04a')."',\n";
} else if ($percent_cpu <= 80) {
echo "'".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[1] ?? '#ff9933')."',\n";
} else if ($percent_cpu > 80) {
echo "'".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[2] ?? '#ea4c46')."',\n";
}
?>
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_sub_color') ?? '#d4d4d4'); ?>'
],
borderColor: '<?php echo $settings->get('theme', 'dashboard_chart_border_color'); ?>',
borderWidth: '<?php echo $settings->get('theme', 'dashboard_chart_border_width'); ?>',
}]
},
options: {
circumference: 180,
rotation: 270,
plugins: {
chart_number_2: {
text: '<?php echo round($percent_cpu); ?>'
},
tooltip: {
yAlign: 'bottom',
displayColors: false,
}
}
},
plugins: [{
id: 'chart_number_2',
beforeDraw(chart, args, options){
const {ctx, chartArea: {top, right, bottom, left, width, height} } = chart;
ctx.font = chart_text_size + ' ' + chart_text_font;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillStyle = '<?php echo $dashboard_number_text_color; ?>';
ctx.fillText(options.text + '%', width / 2, top + (height / 2) + 35);
ctx.save();
}
}]
}
);
</script>
<?php
}
if ($dashboard_chart_type == "number") {
echo "<span class='hud_stat'>".round($percent_cpu)."%</span>";
}
echo "</div>\n";
if ($dashboard_details_state != 'disabled') {
echo "<div class='hud_details hud_box' id='hud_system_cpu_status_details'>";
echo "<table class='tr_hover' width='100%' cellpadding='0' cellspacing='0' border='0'>\n";
echo "<tr>\n";
echo "<th class='hud_heading' width='50%'>".$text['label-name']."</th>\n";
echo "<th class='hud_heading' style='text-align: right;'>".$text['label-value']."</th>\n";
echo "</tr>\n";
if (PHP_OS == 'FreeBSD' || PHP_OS == 'Linux') {
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 "</tr>\n";
$c = ($c) ? 0 : 1;
}
if (!empty($cpu_cores)) {
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-cpu_cores']."</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$cpu_cores."</td>\n";
echo "</tr>\n";
$c = ($c) ? 0 : 1;
}
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-load_average']." (1)</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$load_average[0]."</td>\n";
echo "</tr>\n";
$c = ($c) ? 0 : 1;
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-load_average']." (5)</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$load_average[1]."</td>\n";
echo "</tr>\n";
$c = ($c) ? 0 : 1;
echo "<tr class='tr_link_void'>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-load_average']." (15)</td>\n";
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$load_average[2]."</td>\n";
echo "</tr>\n";
$c = ($c) ? 0 : 1;
}
echo "</table>\n";
echo "</div>";
//$n++;
echo "<span class='hud_expander' onclick=\"$('#hud_system_cpu_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"><span class='fas fa-ellipsis-h'></span></span>";
}
echo "</div>\n";
?>

View File

@@ -0,0 +1,116 @@
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,47 @@
<?php
/*
* 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>
*/
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n");
}
require_once dirname(__DIR__, 4) . '/resources/require.php';
try {
// Create the service
$system_dashboard_service = system_dashboard_service::create();
// Exit using whatever status run returns
exit($system_dashboard_service->run());
} catch (Throwable $ex) {
echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage();
// Exit with error code
exit($ex->getCode());
}