Operator panel using WebSocket (#7810)

This commit is contained in:
frytimo
2026-03-25 10:32:35 -03:00
committed by GitHub
parent 0a3d8154fa
commit 7503d0eec6
32 changed files with 7526 additions and 14 deletions
+231
View File
@@ -0,0 +1,231 @@
<?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>
*/
//application details
$apps[$x]['name'] = "Live Operator Panel";
$apps[$x]['uuid'] = "8493ad5d-9240-426c-b330-774ed0bd7ede";
$apps[$x]['category'] = "Switch";
$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'] = "A real-time operator panel using WebSockets for live call, conference, and agent management.";
$apps[$x]['description']['en-gb'] = "A real-time operator panel using WebSockets for live call, conference, and agent management.";
$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'] = "";
// Permissions are inherited from the basic_operator_panel app (uuid: dd3d173a-5d51-4231-ab22-b18c5b712bb2).
// The following permissions are reused:
// operator_panel_view - view the panel
// operator_panel_manage - supervisor: transfer, agent management
// operator_panel_originate - originate calls via drag-and-drop
// operator_panel_eavesdrop - listen to calls
// operator_panel_hangup - disconnect calls
// operator_panel_record - record calls
// operator_panel_call_details - view caller/callee details
// operator_panel_on_demand - on-demand availability status
// No new permissions are declared here.
//default settings
$y = 0;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "e150a7ae-e06c-4e48-98a2-fec35b469895";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "reconnect_delay";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "500";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Base delay in milliseconds before attempting to reconnect after disconnect.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "0dfc9e80-7451-46c3-b89d-0c14679d655e";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "ping_interval";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "5000";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Interval in milliseconds between keepalive ping requests.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "eeca82cf-3743-4728-9a4d-207a647788e0";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "auth_timeout";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "5000";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Timeout in milliseconds waiting for WebSocket authentication before redirecting to login.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "50b08093-19f6-450f-b534-b4aff88fdd38";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "pong_timeout";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "1500";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Timeout in milliseconds waiting for pong response before counting a failure.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "cb357440-9d16-40b3-ba99-ea7973a21610";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "pong_timeout_max_retries";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "2";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Number of pong timeouts allowed before reloading the page. During retries, status indicator shows warning color.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "10057611-f272-45a4-a23f-144633277596";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "max_reconnect_delay";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "5000";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Maximum delay in milliseconds between reconnection attempts (exponential backoff cap).";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "0cfc12b2-c9fd-4a45-99ef-fc7ed3616954";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "refresh_interval";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "0";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Optional interval in milliseconds to periodically refresh data. Set to 0 to disable (rely on WebSocket events only).";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "b4d6fbcb-0a6e-4e92-bbe6-d8c68776a4b5";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "agent_stats_interval";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "10";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Interval in seconds between agent stats broadcast to all subscribers.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "a2482295-3a47-4188-a4b8-6c303f2e62e8";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "card_label_position";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "left";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Position of extension group card labels. Valid values: top, left, right, bottom, hidden.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "c4a7db2a-ec69-4aef-a95b-1a8f2d7d2de1";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "registrations_reconcile_enabled";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "boolean";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "false";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Enable periodic registration-state reconciliation polling via action request. Disable to rely only on registration_change events.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "f3be5aa5-2b7e-4e8c-a00a-ae9e001ec646";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "debug_show_permissions_mode";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "off";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "false";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Debug permissions output mode: 'off', 'bytes', or 'full'.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "ef6bb923-21cd-4c0d-acdd-60646bfac3ab";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_connected";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "#28a745";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Color of the status indicator when connected and receiving pong responses.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "2749a174-80ec-475f-a3a3-40be46ce524f";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_warning";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "#ffc107";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Color of the status indicator when ping sent but pong not yet received (warning state).";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "dad10272-120f-42c3-8da6-34f17315dc6a";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_disconnected";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "#dc3545";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Color of the status indicator when disconnected or not authenticated.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "01be0a26-8ad4-4558-93e5-41979a2e84d7";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_connecting";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "#6c757d";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Color of the status indicator when connecting or authenticating.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "b6a02563-e1d8-4f7e-9732-68e2f1266d6c";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_icon_connected";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fa-solid fa-plug-circle-check";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for connected status.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "d06a9ef7-d589-4267-920f-351b9255b6d0";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_icon_warning";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fa-solid fa-plug-circle-exclamation";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for warning status (ping sent, awaiting pong).";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "7f747305-b93d-467f-9d27-44ab76c77c16";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_icon_disconnected";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fa-solid fa-plug-circle-xmark";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for disconnected status.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "f01ebd98-461b-4644-9946-38f3b8fd7fca";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_icon_connecting";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fa-solid fa-plug fa-fade";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for connecting status.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "b367ce0d-beb0-424a-9fc9-0c83690001ee";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_show_icon";
$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'] = "Show the Font Awesome icon next to the status text in the connection status indicator. Set to false to show text only.";
+31
View File
@@ -0,0 +1,31 @@
<?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>
*/
// Create default settings for operator panel call group card positions
if ($domains_processed == 1) {
}
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
<?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>
*/
// $y = 0;
// $apps[$x]['menu'][$y]['title']['en-us'] = "Operator Panel";
// $apps[$x]['menu'][$y]['title']['en-gb'] = "Operator Panel";
// $apps[$x]['menu'][$y]['title']['ar-eg'] = "لوحة المشغل";
// $apps[$x]['menu'][$y]['title']['de-at'] = "Bedienfeld";
// $apps[$x]['menu'][$y]['title']['de-ch'] = "Bedienfeld";
// $apps[$x]['menu'][$y]['title']['de-de'] = "Bedienfeld";
// $apps[$x]['menu'][$y]['title']['es-cl'] = "Panel del operador";
// $apps[$x]['menu'][$y]['title']['es-mx'] = "Panel del operador";
// $apps[$x]['menu'][$y]['title']['fr-ca'] = "Panneau de commande";
// $apps[$x]['menu'][$y]['title']['fr-fr'] = "Panneau Operateur";
// $apps[$x]['menu'][$y]['title']['he-il'] = "לוח מפעיל";
// $apps[$x]['menu'][$y]['title']['it-it'] = "Pannello Operatore";
// $apps[$x]['menu'][$y]['title']['ka-ge'] = "ოპერატორის პანელი";
// $apps[$x]['menu'][$y]['title']['nl-nl'] = "Bedienings paneel";
// $apps[$x]['menu'][$y]['title']['pl-pl'] = "Panel operatora";
// $apps[$x]['menu'][$y]['title']['pt-br'] = "Painel do operador";
// $apps[$x]['menu'][$y]['title']['pt-pt'] = "Painel operador";
// $apps[$x]['menu'][$y]['title']['ro-ro'] = "Panoul operator";
// $apps[$x]['menu'][$y]['title']['ru-ru'] = "Панель оператора";
// $apps[$x]['menu'][$y]['title']['sv-se'] = "Telefonist Panel";
// $apps[$x]['menu'][$y]['title']['uk-ua'] = "Панель оператора";
// $apps[$x]['menu'][$y]['title']['zh-cn'] = "操作面板";
// $apps[$x]['menu'][$y]['title']['ja-jp'] = "オペレータ パネル";
// $apps[$x]['menu'][$y]['title']['ko-kr'] = "운영자 패널";
// $apps[$x]['menu'][$y]['uuid'] = "8493ad5d-9240-426c-b330-774ed0bd7ede";
// $apps[$x]['menu'][$y]['parent_uuid'] = "fd29e39c-c936-f5fc-8e2b-611681b266b5";
// $apps[$x]['menu'][$y]['category'] = "internal";
// $apps[$x]['menu'][$y]['icon'] = "";
// $apps[$x]['menu'][$y]['path'] = "/app/operator_panel/index.php";
// $apps[$x]['menu'][$y]['order'] = "";
// $apps[$x]['menu'][$y]['groups'][] = "superadmin";
// $apps[$x]['menu'][$y]['groups'][] = "admin";
+692
View File
@@ -0,0 +1,692 @@
<?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>
*/
// Includes
require_once dirname(__DIR__, 2) . "/resources/require.php";
require_once "resources/check_auth.php";
// Check permissions
if (!permission_exists('operator_panel_view')) {
echo "access denied";
exit;
}
// Multi-lingual support
$language = new text;
$text = $language->get();
// Create token and register with the active operator panel service
$token = (new token())->create($_SERVER['PHP_SELF']);
subscriber::save_token($token, ['active.operator.panel']);
// Gather user permissions for the JS side
$perm = [
'operator_panel_view' => permission_exists('operator_panel_view'),
'operator_panel_manage' => permission_exists('operator_panel_manage'),
'operator_panel_hangup' => permission_exists('operator_panel_hangup'),
'operator_panel_eavesdrop' => permission_exists('operator_panel_eavesdrop'),
'operator_panel_coach' => permission_exists('operator_panel_coach'),
'operator_panel_record' => permission_exists('operator_panel_record'),
'operator_panel_originate' => permission_exists('operator_panel_originate'),
];
// WebSocket settings from default_settings
$ws_settings = [
'reconnect_delay' => (int)$settings->get('operator_panel', 'reconnect_delay', 500),
'ping_interval' => (int)$settings->get('operator_panel', 'ping_interval', 5000),
'auth_timeout' => (int)$settings->get('operator_panel', 'auth_timeout', 5000),
'pong_timeout' => (int)$settings->get('operator_panel', 'pong_timeout', 1500),
'max_reconnect_delay' => (int)$settings->get('operator_panel', 'max_reconnect_delay', 5000),
'pong_timeout_max_retries' => (int)$settings->get('operator_panel', 'pong_timeout_max_retries', 2),
'refresh_interval' => (int)$settings->get('operator_panel', 'refresh_interval', 0),
];
// Theme colors for connection status indicator
$status_colors = [
'connected' => $settings->get('theme', 'operator_panel_status_connected', '#28a745'),
'warning' => $settings->get('theme', 'operator_panel_status_warning', '#ffc107'),
'disconnected' => $settings->get('theme', 'operator_panel_status_disconnected', '#dc3545'),
'connecting' => $settings->get('theme', 'operator_panel_status_connecting', '#6c757d'),
];
$status_icons = [
'connected' => $settings->get('theme', 'operator_panel_status_icon_connected', 'fa-solid fa-plug-circle-check'),
'warning' => $settings->get('theme', 'operator_panel_status_icon_warning', 'fa-solid fa-plug-circle-exclamation'),
'disconnected' => $settings->get('theme', 'operator_panel_status_icon_disconnected', 'fa-solid fa-plug-circle-xmark'),
'connecting' => $settings->get('theme', 'operator_panel_status_icon_connecting', 'fa-solid fa-plug fa-fade'),
];
$status_show_icon = $settings->get('theme', 'operator_panel_status_show_icon', 'true') === 'true';
// Optional user status list for the presence dropdown
$user_statuses = ['Available', 'Available (On Demand)', 'On Break', 'Do Not Disturb', 'Logged Out'];
// Card label position for extension group cards: top, left, right, bottom, hidden
$card_label_position = strtolower((string)$settings->get('operator_panel', 'card_label_position', 'left'));
if (!in_array($card_label_position, ['top', 'left', 'right', 'bottom', 'hidden'], true)) {
$card_label_position = 'left';
}
// Optional polling reconciliation of registration state (can be disabled).
$registrations_reconcile_enabled = $settings->get('operator_panel', 'registrations_reconcile_enabled', 'false') === 'true';
// Get the logged-in user's own extension numbers (shown at top of Extensions panel)
// and primary eavesdrop destination extension
$user_own_extensions = [];
if (!empty($_SESSION['user']['extensions'])) {
// $_SESSION['user']['extensions'] is an array of extension number strings
$user_own_extensions = array_values(array_filter($_SESSION['user']['extensions']));
} elseif (!empty($_SESSION['user']['extension'])) {
foreach ($_SESSION['user']['extension'] as $ext_record) {
if (!empty($ext_record['destination'])) {
$user_own_extensions[] = $ext_record['destination'];
}
}
}
// Include the page header
$document['title'] = $text['title-operator_panel'] ?? 'Operator Panel';
require_once "resources/header.php";
// Cache-busting hashes for JS assets
$ws_client_hash = md5_file(__DIR__ . '/resources/javascript/websocket_client.js');
$lop_js_hash = md5_file(__DIR__ . '/resources/javascript/operator_panel.js');
?>
<script type="text/javascript">
// WebSocket configuration (server settings)
const ws_config = <?= json_encode($ws_settings, JSON_UNESCAPED_SLASHES) ?>;
// Theme colors and icons for connection status indicator
const status_colors = <?= json_encode($status_colors, JSON_UNESCAPED_SLASHES) ?>;
const status_icons = <?= json_encode($status_icons, JSON_UNESCAPED_SLASHES) ?>;
const status_tooltips = {
connected: <?= json_encode($text['status-connected'] ?? 'Connected') ?>,
warning: <?= json_encode($text['status-warning'] ?? 'Warning') ?>,
disconnected: <?= json_encode($text['status-disconnected'] ?? 'Disconnected') ?>,
connecting: <?= json_encode($text['status-connecting'] ?? 'Connecting') ?>
};
const status_show_icon = <?= json_encode($status_show_icon) ?>;
// Permissions passed from PHP
const permissions = <?= json_encode($perm, JSON_UNESCAPED_SLASHES) ?>;
// Translation strings
const text = <?= json_encode($text, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
// Domain context for this session
const domain_name = <?= json_encode($_SESSION['domain_name'] ?? '') ?>;
// User identity (for user_status action)
const user_uuid = <?= json_encode($_SESSION['user_uuid'] ?? '') ?>;
// User status options
const user_statuses = <?= json_encode($user_statuses) ?>;
// The logged-in user's own extension numbers — shown first / highlighted in the Extensions panel
const user_own_extensions = <?= json_encode($user_own_extensions, JSON_UNESCAPED_SLASHES) ?>;
// Theme extras
const button_icon_view = '<?= $settings->get('theme', 'button_icon_view') ?>';
// Group card label position (top, left, right, bottom, hidden)
const card_label_position = <?= json_encode($card_label_position) ?>;
// Optional registrations-state reconciliation polling
const registrations_reconcile_enabled = <?= json_encode($registrations_reconcile_enabled) ?>;
</script>
<script src="resources/javascript/websocket_client.js?v=<?= $ws_client_hash ?>"></script>
<script src="resources/javascript/operator_panel.js?v=<?= $lop_js_hash ?>"></script>
<script src="../../resources/sortablejs/sortable.min.js"></script>
<script type="text/javascript">
// Authentication token for WebSocket handshake
const token = {
name: <?= json_encode($token['name']) ?>,
hash: <?= json_encode($token['hash']) ?>
};
// Boot the panel after DOM is ready
document.addEventListener('DOMContentLoaded', function () {
connect_websocket();
});
</script>
<?php
// Page header bar
echo "<div class='action_bar' id='action_bar'>\n";
echo " <div class='heading'><b>" . $text['title-operator_panel'] . "</b>\n";
// Connection status indicator (icon + text)
echo "\t\t<span id='connection_status' class='badge ms-2' style='background-color:" . htmlspecialchars($status_colors['connecting']) . "; color:#fff;'"
. " title='" . htmlspecialchars($text['status-connecting'] ?? 'Connecting') . "'>";
if ($status_show_icon) {
echo "<i id='connection_status_icon' class='" . htmlspecialchars($status_icons['connecting']) . "' style='margin-right:5px;'></i>";
}
echo "<span id='connection_status_text'>" . htmlspecialchars($text['status-connecting'] ?? 'Connecting') . "</span>";
echo "</span>\n";
echo " </div>\n";
// My status buttons (matching the original design)
if ($perm['operator_panel_view']) {
$status_btn_colors = [
'Available' => '#28a745',
'Available (On Demand)'=> '#28a745',
'On Break' => '#b8860b',
'Do Not Disturb' => '#dc3545',
'Logged Out' => '#6c757d',
];
echo " <div class='actions' style='display:flex; align-items:center; gap:0;'>\n";
echo " <div id='user_status_buttons' style='display:inline-flex; gap:4px; margin-right:12px;'>\n";
foreach ($user_statuses as $s) {
$color = $status_btn_colors[$s] ?? '#6c757d';
$label = strtoupper(htmlspecialchars($s));
echo " <button type='button' class='op-status-btn' data-status='" . htmlspecialchars($s) . "'"
. " style='background-color:" . htmlspecialchars($color) . ";'"
. " onclick='select_user_status(this)'>" . $label . "</button>\n";
}
echo " </div>\n";
echo " </div>\n";
}
echo " <div style='clear:both;'></div>\n";
echo "</div>\n";
?>
<style>
/* Active Operator Panel — extension blocks */
.op-ext-grid {
display: flex;
flex-wrap: wrap;
gap: 0;
padding: 4px 0 12px;
}
/* Status buttons */
.op-status-btn {
border: 2px solid transparent;
border-radius: 4px;
padding: 3px 10px;
font-size: 11px;
font-weight: 700;
color: #fff;
cursor: pointer;
text-transform: uppercase;
letter-spacing: .5px;
line-height: 1.4;
transition: opacity .15s, border-color .15s;
opacity: 0.55;
}
.op-status-btn:hover { opacity: 0.8; }
.op-status-btn.active { opacity: 1; border-color: rgba(0,0,0,.35); }
/* Filter bar */
.op-filter-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0 10px;
flex-wrap: wrap;
}
.op-group-filters {
display: inline-flex;
gap: 4px;
flex-wrap: wrap;
}
.op-group-filter-btn {
border: none;
border-radius: 4px;
padding: 3px 10px;
font-size: 11px;
font-weight: 700;
color: #fff;
cursor: pointer;
text-transform: uppercase;
letter-spacing: .3px;
line-height: 1.4;
background-color: #4a8cdb;
transition: opacity .15s;
opacity: 0.55;
}
.op-group-filter-btn:hover { opacity: 0.8; }
.op-group-filter-btn.active { opacity: 1; background-color: #2a7fff; }
.op-text-filter {
border: 1px solid #ccc;
border-radius: 4px;
padding: 3px 8px;
font-size: 12px;
line-height: 1.4;
width: 130px;
outline: none;
}
.op-text-filter:focus { border-color: #80bdff; box-shadow: 0 0 0 2px rgba(0,123,255,.15); }
/* Edit mode button */
.op-edit-btn {
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
padding: 3px 8px;
font-size: 14px;
cursor: pointer;
color: #6c757d;
line-height: 1;
transition: background .15s, color .15s;
}
.op-edit-btn:hover { background: #e9ecef; }
.op-edit-btn.active { background: #0d6efd; color: #fff; border-color: #0d6efd; }
.op-ext-block {
display: flex;
width: 235px;
margin: 0 8px 8px 0;
border-style: solid;
border-width: 1px 3px;
border-radius: 5px;
border-color: #b9c5d8;
background-color: #e5eaf5;
box-shadow: 0 0 3px #c8cdd9;
position: relative;
overflow: hidden;
user-select: none;
cursor: default;
}
.op-ext-icon {
display: flex;
align-items: center;
justify-content: center;
min-width: 47px;
width: 47px;
background-color: #e5eaf5;
border-radius: 4px 0 0 4px;
color: #7a8499;
font-size: 26px;
padding: 4px 0;
}
.op-ext-status-icon {
font-size: 28px;
line-height: 1;
color: inherit;
}
.op-ext-info {
flex: 1;
padding: 5px 8px 5px 8px;
background: #fff;
border-radius: 0 3px 3px 0;
font-family: arial, sans-serif;
font-size: 10px;
min-width: 0;
position: relative;
min-height: 50px;
}
.op-ext-number { font-size: 12px; font-weight: bold; color: #3164AD; line-height: 1.4; }
.op-ext-name { font-size: 10px; color: #444; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.op-ext-state-info { font-size: 10px; color: #555; margin-top: 3px; }
.op-ext-info.op-has-live-call { padding-right: 78px; padding-bottom: 15px; box-sizing: border-box; }
.op-ext-info.op-has-live-call .op-ext-state-info { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.op-ext-mine-label { position: absolute; top: 2px; right: 4px; font-size: 9px; color: #0d6efd; font-weight: 600; }
.op-ext-dial-wrap { position: absolute; top: 22px; right: 3px; }
.op-ext-dial-toggle {
border: none;
background: transparent;
padding: 0;
margin: 0;
line-height: 0;
cursor: pointer;
}
.op-ext-dial-toggle img { display: block; }
.op-ext-dial-input {
position: absolute;
top: -1px;
right: 18px;
width: 100px;
min-width: 100px;
max-width: 100px;
height: 20px;
padding: 1px 6px;
font-size: 12px;
border: 1px solid #b9c5d8;
border-radius: 2px;
background-color: #fff;
text-align: center;
}
.op-ext-call-meta {
position: absolute;
top: 2px;
right: 20px;
display: flex;
align-items: center;
gap: 6px;
}
.op-ext-call-direction {
width: 12px;
height: 12px;
border: none;
}
.op-ext-call-duration {
font-size: 12px;
color: #4a4a4a;
line-height: 1;
}
.op-ext-call-actions {
position: absolute;
bottom: 2px;
right: 18px;
display: flex;
align-items: center;
gap: 5px;
}
.op-ext-action-icon {
width: 12px;
height: 12px;
border: none;
cursor: pointer;
}
body.op-dragging, body.op-dragging * {
cursor: none !important;
}
/* user status: available — green */
.op-ext-available { border-color: #28a745; background-color: #d4edda; }
.op-ext-available .op-ext-icon { background-color: #c3e6cb; }
.op-ext-available .op-ext-icon .op-ext-status-icon { color: #1e7e34; }
.op-ext-available .op-ext-info { background-color: #eaf6ec; }
/* user status: on break — gold */
.op-ext-on-break { border-color: #b8860b; background-color: #fdf3d7; }
.op-ext-on-break .op-ext-icon { background-color: #f5e6b8; }
.op-ext-on-break .op-ext-icon .op-ext-status-icon { color: #8a6508; }
.op-ext-on-break .op-ext-info { background-color: #fef9eb; }
/* user status: do not disturb — red */
.op-ext-dnd { border-color: #dc3545; background-color: #f8d7da; }
.op-ext-dnd .op-ext-icon { background-color: #f1b0b7; }
.op-ext-dnd .op-ext-icon .op-ext-status-icon { color: #a71d2a; }
.op-ext-dnd .op-ext-info { background-color: #fce4e7; }
/* registered (no explicit status) — green */
.op-ext-registered { border-color: #28a745; background-color: #d4edda; }
.op-ext-registered .op-ext-icon { background-color: #c3e6cb; }
.op-ext-registered .op-ext-icon .op-ext-status-icon { color: #1e7e34; }
.op-ext-registered .op-ext-info { background-color: #eaf6ec; }
/* user status: logged out — grey */
.op-ext-logged-out { border-color: #9da5ae; background-color: #e2e3e5; }
.op-ext-logged-out .op-ext-icon { background-color: #d6d8db; }
.op-ext-logged-out .op-ext-icon .op-ext-status-icon { color: #6c757d; }
.op-ext-logged-out .op-ext-info { background-color: #f0f1f2; }
.op-ext-logged-out .op-ext-number { color: #888; }
.op-ext-logged-out .op-ext-name { color: #999; }
/* unregistered — grey with muted content */
.op-ext-unregistered { border-color: #9da5ae; background-color: #e2e3e5; cursor: not-allowed; }
.op-ext-unregistered .op-ext-icon { background-color: #d6d8db; }
.op-ext-unregistered .op-ext-icon .op-ext-status-icon { color: #6c757d; opacity: .4; filter: grayscale(100%); }
.op-ext-unregistered .op-ext-info { background-color: #f0f1f2; color: #999; }
.op-ext-unregistered .op-ext-number { color: #999; }
.op-ext-unregistered .op-ext-name { color: #aaa; }
/* call state: ringing — blue */
.op-ext-ringing { border-color: #41b9eb; background-color: #a8dbf0; }
.op-ext-ringing .op-ext-icon { background-color: #a8dbf0; }
.op-ext-ringing .op-ext-icon .op-ext-status-icon { color: #0e6882; }
.op-ext-ringing .op-ext-info { background-color: #d1f1ff; }
/* call state: active (on call) — bright green */
.op-ext-active { border-color: #77d779; background-color: #baf4bb; }
.op-ext-active .op-ext-icon { background-color: #baf4bb; }
.op-ext-active .op-ext-icon .op-ext-status-icon { color: #2a7a2b; }
.op-ext-active .op-ext-info { background-color: #e1ffe2; }
/* call state: held — teal */
.op-ext-held { border-color: #5bbfd1; background-color: #b3e5ee; }
.op-ext-held .op-ext-icon { background-color: #b3e5ee; }
.op-ext-held .op-ext-icon .op-ext-status-icon { color: #1a6c7a; }
.op-ext-held .op-ext-info { background-color: #ddf4f8; }
/* mine highlight */
.op-ext-mine { border-width: 2px 3px !important; border-color: #0d6efd !important; }
/* drop target */
.op-ext-drop-over { box-shadow: 0 0 0 3px #0d6efd; }
.op-ext-drop-over .op-ext-info { background-color: #cfe2ff !important; }
/* section labels */
.op-ext-section-label { font-weight: 600; font-size: .85em; color: #6c757d; margin: 8px 0 4px; width: 100%; }
/* My Extensions container — own line above other groups */
#my_extensions_container:not(:empty) {
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #d0d8e5;
}
/* call group cards */
.op-group-card {
border: 1px solid #d0d8e5;
border-radius: 5px;
background-color: #fff;
box-shadow: 0 1px 3px #d0d8e5;
margin-bottom: 14px;
overflow: hidden;
display: inline-flex;
vertical-align: top;
margin-right: 14px;
}
.op-group-card.op-hidden { display: none; }
/* Card frame orientation by label position */
.op-group-card[data-position="left"] { flex-direction: row; }
.op-group-card[data-position="right"] { flex-direction: row-reverse; }
.op-group-card[data-position="top"] { flex-direction: column; }
.op-group-card[data-position="bottom"] { flex-direction: column-reverse; }
.op-group-card[data-position="hidden"] { flex-direction: row; }
/* Edit mode: cards grid container */
#extensions_container {
transition: background .2s;
}
#extensions_container.op-edit-mode .op-group-card {
cursor: grab;
border: 2px dashed #80bdff;
}
#extensions_container.op-edit-mode .op-group-card.sortable-ghost {
opacity: .4;
}
/* Card header - default/left side orientation with vertical text */
.op-group-card-header {
background-color: #e5e9f0;
padding: 8px 4px;
font-size: 12px;
font-weight: 600;
color: #444;
border-right: 1px solid #d0d8e5;
font-family: Calibri, Candara, Segoe, 'Segoe UI', Optima, Arial, sans-serif;
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
letter-spacing: .6px;
text-transform: uppercase;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
min-width: 34px;
}
/* Top position - horizontal text, border at bottom */
.op-group-card[data-position="top"] .op-group-card-header {
writing-mode: horizontal-tb;
transform: none;
border-right: none;
border-bottom: 1px solid #d0d8e5;
min-width: auto;
padding: 6px 12px;
}
/* Right position - vertical text, border at left */
.op-group-card[data-position="right"] .op-group-card-header {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(0deg);
border-right: none;
border-left: 1px solid #d0d8e5;
min-width: 34px;
}
/* Bottom position - horizontal text, border at top */
.op-group-card[data-position="bottom"] .op-group-card-header {
writing-mode: horizontal-tb;
transform: none;
border-right: none;
border-top: 1px solid #d0d8e5;
min-width: auto;
padding: 6px 12px;
}
/* Hidden position - no header visible */
.op-group-card[data-position="hidden"] .op-group-card-header {
display: none;
}
/* Tooltip on hover - show group name in title attribute */
.op-group-card:hover {
cursor: help;
}
/* Hide text for "My Extensions" but keep grey shading */
.op-group-card-header.op-hidden-text {
color: transparent;
text-shadow: none;
}
.op-group-card-body {
padding: 10px 8px 4px;
flex: 1;
}
</style>
<!-- Bootstrap tabs: Extensions | Calls | Conferences | Agents -->
<ul class="nav nav-tabs" id="lop_tabs" role="tablist" style="margin-bottom:16px;">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-extensions" data-bs-toggle="tab" data-bs-target="#panel-extensions"
type="button" role="tab" aria-controls="panel-extensions" aria-selected="true">
<?= htmlspecialchars($text['tab-extensions'] ?? 'Extensions') ?>
<span id="extensions_count" class="badge ms-1" style="background:#6c757d;color:#fff;">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-calls" data-bs-toggle="tab" data-bs-target="#panel-calls"
type="button" role="tab" aria-controls="panel-calls" aria-selected="false">
<?= htmlspecialchars($text['tab-calls'] ?? 'Calls') ?>
<span id="calls_count" class="badge ms-1" style="background:#6c757d;color:#fff;">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-conferences" data-bs-toggle="tab" data-bs-target="#panel-conferences"
type="button" role="tab" aria-controls="panel-conferences" aria-selected="false">
<?= htmlspecialchars($text['tab-conferences'] ?? 'Conferences') ?>
<span id="conferences_count" class="badge ms-1" style="background:#6c757d;color:#fff;">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-agents" data-bs-toggle="tab" data-bs-target="#panel-agents"
type="button" role="tab" aria-controls="panel-agents" aria-selected="false">
<?= htmlspecialchars($text['tab-agents'] ?? 'Agents') ?>
<span id="agents_count" class="badge ms-1" style="background:#6c757d;color:#fff;">0</span>
</button>
</li>
</ul>
<div class="tab-content" id="lop_tab_content">
<!-- EXTENSIONS TAB -->
<div class="tab-pane fade show active" id="panel-extensions" role="tabpanel" aria-labelledby="tab-extensions">
<!-- Group filter bar -->
<div id="extensions_filter_bar" class="op-filter-bar" style="display:none;">
<button type="button" class="op-edit-btn" id="edit_mode_btn" onclick="toggle_edit_mode()" title="<?= htmlspecialchars($text['label-edit_mode'] ?? 'Edit Mode') ?>">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<div id="group_filter_buttons" class="op-group-filters"></div>
<input type="text" id="extensions_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_extension_filters()">
</div>
<div id="my_extensions_container"></div>
<div id="extensions_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
</div>
<!-- CALLS TAB -->
<div class="tab-pane fade" id="panel-calls" role="tabpanel" aria-labelledby="tab-calls">
<div id="calls_filter_bar" class="op-filter-bar" style="display:none;">
<div id="group_filter_buttons_calls" class="op-group-filters"></div>
<input type="text" id="calls_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_calls_filters()">
</div>
<div id="calls_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
</div>
<!-- CONFERENCES TAB -->
<div class="tab-pane fade" id="panel-conferences" role="tabpanel" aria-labelledby="tab-conferences">
<div id="conferences_filter_bar" class="op-filter-bar" style="display:none;">
<div id="group_filter_buttons_conferences" class="op-group-filters"></div>
<input type="text" id="conferences_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_conferences_filters()">
</div>
<div id="conferences_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
</div>
<!-- AGENTS TAB -->
<div class="tab-pane fade" id="panel-agents" role="tabpanel" aria-labelledby="tab-agents">
<div id="agents_filter_bar" class="op-filter-bar" style="display:none;">
<div id="group_filter_buttons_agents" class="op-group-filters"></div>
<input type="text" id="agents_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_agents_filters()">
</div>
<div id="agents_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
</div>
</div>
<!-- Transfer modal -->
<div class="modal fade" id="transfer_modal" tabindex="-1" aria-labelledby="transfer_modal_label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background:var(--bs-body-bg);">
<div class="modal-header">
<h5 class="modal-title" id="transfer_modal_label"><?= htmlspecialchars($text['label-transfer'] ?? 'Transfer Call') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label for="transfer_destination" class="form-label" style="font-weight:600;">
<?= htmlspecialchars($text['label-destination'] ?? 'Destination') ?>
</label>
<input type="text" id="transfer_destination" class="form-control" placeholder="1001"
autocomplete="off" autofocus>
<input type="hidden" id="transfer_uuid">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
</button>
<button type="button" class="btn btn-primary" onclick="confirm_transfer()">
<?= htmlspecialchars($text['button-transfer'] ?? 'Transfer') ?>
</button>
</div>
</div>
</div>
</div>
<br><br>
<?php
require_once "resources/footer.php";
@@ -0,0 +1,112 @@
<?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>
*/
/**
* Role-aware filter for agent data payloads.
*
* Supervisors (those with the operator_panel_manage permission) receive the
* full agent list for all queues in their domain.
*
* Regular agents receive an aggregate-only view: caller counts per queue and
* their own individual stats — no peer details are included.
*
* This class does not implement the filter interface because agent stats are
* built as complete arrays rather than streamed key/value pairs. It is used
* directly by {@see operator_panel_service} to shape the payload before
* sending.
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class operator_panel_agent_filter {
/**
* Whether this subscriber is a supervisor (has operator_panel_manage).
*
* @var bool
*/
private $is_supervisor;
/**
* The agent_name value from v_call_center_agents for this user, or empty string.
*
* @var string
*/
private $agent_name;
/**
* @param bool $is_supervisor Whether the subscriber has supervisor privileges.
* @param string $agent_name The call-center agent name for this user (empty for non-agents).
*/
public function __construct(bool $is_supervisor, string $agent_name = '') {
$this->is_supervisor = $is_supervisor;
$this->agent_name = $agent_name;
}
/**
* Filter an array of agent rows for this subscriber.
*
* @param array $agents Full agent list: each element is an associative array with
* keys: agent_name, queue_name, status, state, calls_answered,
* talk_time, last_status_change, ready_time.
*
* @return array Filtered agent data appropriate for this subscriber.
*/
public function filter(array $agents): array {
if ($this->is_supervisor) {
// Supervisors see everything
return $agents;
}
// Regular agent: build aggregate queue counts + own row only
$result = [
'role' => 'agent',
'own_stats' => null,
'queue_counts' => [],
];
$queue_counts = [];
foreach ($agents as $agent) {
$queue = $agent['queue_name'] ?? '';
if (!isset($queue_counts[$queue])) {
$queue_counts[$queue] = ['queue_name' => $queue, 'agent_count' => 0, 'available_count' => 0];
}
$queue_counts[$queue]['agent_count']++;
if (($agent['status'] ?? '') === 'Available') {
$queue_counts[$queue]['available_count']++;
}
// Include own stats
if (!empty($this->agent_name) && ($agent['agent_name'] ?? '') === $this->agent_name) {
$result['own_stats'] = $agent;
}
}
$result['queue_counts'] = array_values($queue_counts);
return $result;
}
}
@@ -0,0 +1,70 @@
<?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>
*/
/**
* Filters call events to only include those belonging to the subscriber's domain.
*
* Checks the 'caller_context' field of each event key/value pair. When the
* context does not match any of the allowed domain names the entire message is
* dropped by returning null.
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class operator_panel_call_filter implements filter {
/**
* Allowed domain names keyed for fast lookup
*
* @var array
*/
private $domains;
/**
* @param array $domain_names Domain names this subscriber is allowed to see.
*/
public function __construct(array $domain_names) {
foreach ($domain_names as $name) {
$this->domains[$name] = true;
}
}
/**
* Called for each key/value pair in the event payload.
*
* @param string $key
* @param mixed $value
*
* @return bool|null true to keep, null to drop the entire message.
*/
public function __invoke($key, $value): ?bool {
if ($key !== 'caller_context') {
return true;
}
return isset($this->domains[$value]) ? true : null;
}
}
@@ -0,0 +1,82 @@
<?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>
*/
/**
* Filters conference events to only include keys relevant to the operator panel,
* and drops messages from domains other than the subscriber's.
*
* Passes only the keys listed in {@see operator_panel_service::conf_event_keys}
* and enforces domain isolation using caller_context.
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class operator_panel_conf_filter implements filter {
/**
* Allowed domain names keyed for fast lookup
*
* @var array
*/
private $domains;
/**
* Keys that are permitted through the filter
*
* @var array
*/
private $allowed_keys;
/**
* @param array $domain_names Domain names this subscriber is allowed to see.
* @param array $allowed_keys Event keys to include in the forwarded payload.
*/
public function __construct(array $domain_names, array $allowed_keys) {
foreach ($domain_names as $name) {
$this->domains[$name] = true;
}
$this->allowed_keys = array_flip($allowed_keys);
}
/**
* Called for each key/value pair in the event payload.
*
* @param string $key
* @param mixed $value
*
* @return bool|null true to keep, false to skip this key, null to drop the entire message.
*/
public function __invoke($key, $value): ?bool {
// Domain guard — drop whole message if context is wrong
if ($key === 'caller_context') {
return isset($this->domains[$value]) ? true : null;
}
// Key allow-list
return isset($this->allowed_keys[$key]) ? true : false;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Barge">
<defs>
<linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4a5568"/>
<stop offset="100%" stop-color="#2d3748"/>
</linearGradient>
<linearGradient id="g2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f6ad55"/>
<stop offset="100%" stop-color="#dd6b20"/>
</linearGradient>
</defs>
<rect x="6" y="26" width="10" height="12" rx="2" fill="url(#g1)"/>
<polygon points="16,22 38,14 38,50 16,42" fill="url(#g2)"/>
<rect x="38" y="14" width="6" height="36" rx="2" fill="#2d3748"/>
<path d="M46 24 C52 27, 52 37, 46 40" fill="none" stroke="#2d3748" stroke-width="3" stroke-linecap="round"/>
<path d="M50 19 C59 24, 59 40, 50 45" fill="none" stroke="#2d3748" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Whisper">
<defs>
<linearGradient id="skin" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f3c7a6"/>
<stop offset="100%" stop-color="#d9a47c"/>
</linearGradient>
<linearGradient id="hair" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#334155"/>
<stop offset="100%" stop-color="#0f172a"/>
</linearGradient>
<linearGradient id="shirt" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- Neck/shoulder -->
<path d="M17 48c4-6 10-9 18-9 5 0 8 2 10 4v7H17z" fill="url(#shirt)"/>
<!-- Side profile head -->
<path d="M20 29c0-8 6-14 13-14 7 0 12 5 12 11 0 3-1 6-3 8-1 1-1 2-1 3v2c0 1-1 2-2 2H30c-6 0-10-5-10-12z" fill="url(#skin)"/>
<!-- Hair cap -->
<path d="M21 27c1-7 7-12 14-12 6 0 10 3 12 8-2-2-5-3-8-3-4 0-7 2-9 5-1 1-2 3-2 5-3-1-5-2-7-3z" fill="url(#hair)"/>
<!-- Nose -->
<path d="M42 30c2 1 2 3 0 4" fill="none" stroke="#c48864" stroke-width="1.6" stroke-linecap="round"/>
<!-- Raised whisper hand near mouth -->
<path d="M37 34c4 1 7 4 8 7-3 1-7 0-10-2-1-1-1-3 0-4 0-1 1-1 2-1z" fill="url(#skin)"/>
<!-- Whisper accent lines -->
<path d="M49 29c2 0 3 1 4 2" fill="none" stroke="#64748b" stroke-width="1.8" stroke-linecap="round"/>
<path d="M50 33c2 0 3 1 4 2" fill="none" stroke="#64748b" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
class ws_client {
static REG_TRACE_PREFIX = '[OP_REG_TRACE]';
_is_debug_enabled() {
if (typeof window !== 'undefined' && window.OP_DEBUG === true) return true;
try {
if (typeof localStorage !== 'undefined' && localStorage.getItem('op_debug') === '1') return true;
} catch (err) {
// Ignore storage access errors (private mode / blocked storage).
}
return false;
}
_is_reg_trace_enabled() {
if (typeof window !== 'undefined' && window.OP_REG_TRACE_ENABLED === true) return true;
try {
if (typeof localStorage !== 'undefined' && localStorage.getItem('op_reg_trace') === '1') return true;
} catch (err) {
// Ignore storage access errors (private mode / blocked storage).
}
return false;
}
_debug(label, data) {
if (!this._is_debug_enabled()) return;
if (typeof data === 'undefined') {
console.debug(`[${this._now()}] ${label}`);
} else {
console.debug(`[${this._now()}] ${label}`, data);
}
}
_reg_trace(label, data) {
if (!this._is_reg_trace_enabled()) return;
if (typeof data === 'undefined') {
console.debug(`[${this._now()}] ${ws_client.REG_TRACE_PREFIX} ${label}`);
} else {
console.debug(`[${this._now()}] ${ws_client.REG_TRACE_PREFIX} ${label}`, data);
}
}
_now() {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
constructor(url, token) {
this.ws = new WebSocket(url);
this.ws.addEventListener('message', this._on_message.bind(this));
this._next_id = 1;
this._pending = new Map();
this._event_handlers = new Map();
// The token is submitted on every request
this.token = token;
}
authenticate() {
//
// Authentication is with websockets not the service, so we need to send a special
// request for authentication and specify the service that will be handling our
// future messages. This means the service is authentication and the topic is the
// service that will handle our future messages. This is a special case because we
// must authenticate with websockets, not the service. The service is only used to
// handle future messages.
//
// service = 'authentication'
// topic = operator_panel_service::get_service_name()
// payload = token
//
this.request('authentication', 'active.operator.panel', { token: this.token });
}
// internal message handler called when event occurs on the socket
_on_message(ev) {
let message;
let switch_event;
try {
this._debug('[WS][raw] message received', ev.data);
message = JSON.parse(ev.data);
if (message && message.topic === 'registration_change') {
this._reg_trace('[WS][raw] registration_change', message);
}
// check for authentication request
if (message.status_code === 407) {
console.log('Authentication Required');
this.authenticate();
return;
}
switch_event = message.payload;
if (message.topic === 'authenticated') {
console.log('Authenticated');
this._dispatch_event('active.operator.panel', {event_name: 'authenticated'});
return; // Don't process further after authenticated
}
} catch (err) {
console.error('Error parsing JSON data:', err);
return;
}
// Pull out the request_id first
const rid = message.request_id ?? null;
this._debug('[WS][route]', {
request_id: rid,
service_name: message.service_name || message.service || '',
topic: message.topic || '',
has_pending: rid ? this._pending.has(rid) : false,
});
// If this is the response to a pending request
if (rid && this._pending.has(rid)) {
const {
service_name = '',
topic = '',
status_string: status = 'ok',
status_code: code = 200,
payload = {}
} = message;
const {resolve, reject} = this._pending.get(rid);
this._pending.delete(rid);
if (status === 'ok' && code >= 200 && code < 300) {
this._debug('[WS][pending-response]', {service_name, topic, code});
resolve({service_name, topic, payload, code, message});
// Also dispatch as an event so handlers get notified
const event_data = (typeof switch_event === 'object' && switch_event !== null)
? { ...switch_event, event_name: switch_event.event_name || topic }
: { event_name: topic, data: switch_event };
this._dispatch_event(service_name, event_data);
} else {
this._debug('[WS][pending-error]', {service_name, topic, code, status});
const err = new Error(message || `Error ${code}`);
err.code = code;
reject(err);
}
return;
}
// Otherwise it's a server-pushed event
this._debug('[WS][push]', {
service_name: message.service_name || message.service || '',
topic: message.topic || '',
has_payload_object: (typeof switch_event === 'object' && switch_event !== null),
});
if (message.topic === 'registration_change') {
this._reg_trace('[WS][push] registration_change', {
service_name: message.service_name || message.service || '',
topic: message.topic || '',
has_payload_object: (typeof switch_event === 'object' && switch_event !== null),
});
}
// Use service_name, or fall back to service, or default to 'active.operator.panel'
const service = message.service_name || message.service || 'active.operator.panel';
// Ensure event has event_name set from topic if not in payload
const event_data = (typeof switch_event === 'object' && switch_event !== null)
? { ...switch_event, event_name: switch_event.event_name || message.topic, topic: message.topic }
: { event_name: message.topic, topic: message.topic, data: switch_event };
this._debug('[WS][dispatch]', {
service,
topic: event_data.topic || event_data.event_name || '',
});
this._dispatch_event(service, event_data);
}
// Send a request to the websocket server using JSON string
request(service, topic = null, payload = {}) {
const request_id = String(this._next_id++);
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});
});
}
subscribe(topic) {
return this.request('active.operator.panel', topic);
}
unsubscribe(topic) {
return this.request('active.operator.panel', topic);
}
// register a callback for server-pushes
on_event(topic, handler) {
console.log('registering event listener for ' + topic);
if (!this._event_handlers.has(topic)) {
this._event_handlers.set(topic, []);
}
this._event_handlers.get(topic).push(handler);
}
/**
* Dispatch a server-push event envelope to all registered handlers.
*/
_dispatch_event(service, env) {
this._debug('[WS][_dispatch_event] called', { service });
let event = (typeof env === 'string') ? JSON.parse(env) : env;
this._debug('[WS][_dispatch_event] handlers', Array.from(this._event_handlers.keys()));
if (service === 'active.operator.panel') {
// Prefer the envelope topic (always lowercase, set by the PHP service)
// and fall back to event_name lowercased (raw FreeSWITCH names are UPPER_CASE).
const topic = (event.topic || event.event_name || '').toLowerCase();
this._debug('[WS][_dispatch_event] topic', topic);
const handlers = this._event_handlers.get(topic) || [];
const wildcard_handlers = this._event_handlers.get('*') || [];
this._debug('[WS][_dispatch_event] handler counts', { topic, handlers: handlers.length, wildcard: wildcard_handlers.length });
for (const fn of handlers) {
try { fn(event); } catch (err) { console.error(`Error in handler for "${topic}":`, err); }
}
for (const fn of wildcard_handlers) {
try { fn(event); } catch (err) { console.error('Error in wildcard handler:', err); }
}
} else {
const handlers = this._event_handlers.get(service) || [];
for (const fn of handlers) {
try { fn(event.data, event); } catch (err) { console.error(`Error in handler for "${service}":`, err); }
}
}
}
}
@@ -0,0 +1,18 @@
[Unit]
Description=Operator Panel Websocket Service
[Service]
WorkingDirectory=/var/www/fusionpbx
ExecStart=/usr/bin/php /var/www/fusionpbx/app/operator_panel/resources/service/operator_panel.php
ExecReload=/bin/kill -SIGUSR1 $MAINPID
RuntimeDirectory=fusionpbx
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
User=www-data
Group=www-data
Restart=always
RestartSec=5
StartLimitInterval=0
[Install]
WantedBy=multi-user.target
+76
View File
@@ -0,0 +1,76 @@
#!/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>
*/
/**
* Debian/Ubuntu systemd service management:
* Copy the operator_panel.service file to /etc/systemd/system/ using the commands:
* sudo cp /path/to/operator_panel.service /etc/systemd/system/
* sudo systemctl daemon-reload
* Start/Stop:
* systemctl start operator_panel
* systemctl stop operator_panel
* systemctl restart operator_panel
* systemctl reload operator_panel
*
* Enable on boot:
* systemctl enable operator_panel
*
* Disable on boot:
* systemctl disable operator_panel
*
* Non-SystemD:
* Normal Daemon Start:
* ./operator_panel.php -u www-data -g www-data # run as www-data user and group as root is prohibited for security reasons
* Normal Daemon Stop:
* ./operator_panel.php -x
*
* Debug Mode (runs in foreground with debug output):
* ./operator_panel.php -x # exit first if already running the daemon
* ./operator_panel.php -d 7 -u www-data -g www-data # run in debug mode with log level 7 (debug)
*
* SystemD Log watching:
* journalctl -u operator_panel -f
*/
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', operator_panel_service::get_service_name());
try {
$service = operator_panel_service::create();
// Exit using whatever status run() returns
exit($service->run());
} catch (Exception $ex) {
echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage() . "\n";
exit($ex->getCode());
}
@@ -64,6 +64,15 @@ abstract class base_websocket_system_service extends service implements websocke
$this->timers[] = ['expire_time' => time() + $seconds, 'callable' => $callable];
}
/**
* Clear all active timers.
*
* @return void
*/
protected function clear_timers(): void {
$this->timers = [];
}
/**
* Append command options to set the websockets port and host address
*
@@ -200,7 +209,16 @@ abstract class base_websocket_system_service extends service implements websocke
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)) {
$select_result = @stream_select($read, $write, $except, 0, 333333);
if ($select_result === false) {
// A signal (for example SIGUSR1 during service reload) can interrupt stream_select().
// If this happens, keep running and let the loop continue with updated state.
$last_error = error_get_last();
$error_message = strtolower((string)($last_error['message'] ?? ''));
if (str_contains($error_message, 'interrupted system call')) {
continue;
}
// severe error encountered so exit
$this->running = false;
// Exit with non-zero exit code
@@ -159,8 +159,8 @@ class websocket_service extends service {
* @access protected
*/
protected static function set_command_options() {
//TODO: ip address
//TODO: port
// TODO: ip address
// TODO: port
}
/**
@@ -314,6 +314,15 @@ class websocket_service extends service {
$this->info("Subscriber $ste->subscriber_id token expired");
// Subscriber token has expired so disconnect them
$this->handle_disconnect($subscriber->socket_id());
} catch (subscriber_not_subscribed_exception $snse) {
// Subscriber can issue requests to a service without being subscribed
// to service broadcasts; skip and continue fan-out.
$this->debug("Skipping subscriber {$snse->subscriber_id}: not subscribed to service '{$message->service_name}'");
} catch (socket_disconnected_exception $sde) {
$this->info("Subscriber $sde->id disconnected during broadcast");
$this->handle_disconnect($subscriber->socket_id());
} catch (\Throwable $e) {
$this->warning("Broadcast send failed for subscriber {$subscriber->id}: " . $e->getMessage());
}
}
} // Route a specific request from a service back to a subscriber
@@ -498,29 +507,29 @@ class websocket_service extends service {
* @return void
*/
private function handle_client_message(subscriber $subscriber, websocket_message $message) {
//find the service with that name
// find the service with that name
foreach ($this->subscribers as $service) {
//when we find the service send the request
// when we find the service send the request
if ($service->service_equals($message->service_name())) {
//notify we found the service
// notify we found the service
$this->debug("Routing message to service '" . $message->service_name() . "' for topic '" . $message->topic() . "'");
//attach the current subscriber permissions so the service can verify
// attach the current subscriber permissions so the service can verify
$message->permissions($subscriber->get_permissions());
//attach the domain name
// attach the domain name
$message->domain_name($subscriber->get_domain_name());
//attach the domain uuid
// attach the domain uuid
$message->domain_uuid($subscriber->get_domain_uuid());
//attach the client id so we can track the request
// attach the client id so we can track the request
$message->resource_id = $subscriber->id;
//send the modified web socket message to the service
// send the modified web socket message to the service
$service->send((string)$message);
//continue searching for service providers
// continue searching for service providers
continue;
}
}
@@ -675,11 +684,11 @@ class websocket_service extends service {
* @override service
*/
public function __destruct() {
//disconnect all clients
// disconnect all clients
foreach ($this->clients as $socket) {
$this->disconnect_client($socket);
}
//finish destruct using the parent
// finish destruct using the parent
parent::__destruct();
}