From 7503d0eec6cc55aad199fd5c821dc22d4b7a5b18 Mon Sep 17 00:00:00 2001 From: frytimo Date: Wed, 25 Mar 2026 10:32:35 -0300 Subject: [PATCH] Operator panel using WebSocket (#7810) --- app/operator_panel/app_config.php | 231 ++ app/operator_panel/app_defaults.php | 31 + app/operator_panel/app_languages.php | 1784 +++++++++++++ app/operator_panel/app_menu.php | 61 + app/operator_panel/index.php | 692 +++++ .../classes/operator_panel_agent_filter.php | 112 + .../classes/operator_panel_call_filter.php | 70 + .../classes/operator_panel_conf_filter.php | 82 + .../classes/operator_panel_service.php | 1668 ++++++++++++ app/operator_panel/resources/images/barge.svg | 20 + .../resources/images/eavesdrop.png | Bin 0 -> 372 bytes .../resources/images/inbound.png | Bin 0 -> 298 bytes .../resources/images/keypad_call.png | Bin 0 -> 483 bytes .../resources/images/keypad_transfer.png | Bin 0 -> 448 bytes app/operator_panel/resources/images/kill.png | Bin 0 -> 807 bytes .../resources/images/outbound.png | Bin 0 -> 281 bytes .../resources/images/record.png | Bin 0 -> 813 bytes .../resources/images/recording.png | Bin 0 -> 830 bytes .../resources/images/refresh_active.gif | Bin 0 -> 1079 bytes .../resources/images/refresh_paused.png | Bin 0 -> 501 bytes .../resources/images/status_available.png | Bin 0 -> 1894 bytes .../images/status_available_on_demand.png | Bin 0 -> 1976 bytes .../images/status_do_not_disturb.png | Bin 0 -> 1934 bytes .../resources/images/status_logged_out.png | Bin 0 -> 1748 bytes .../resources/images/status_on_break.png | Bin 0 -> 1894 bytes .../resources/images/whisper.svg | 36 + .../resources/javascript/operator_panel.js | 2368 +++++++++++++++++ .../resources/javascript/websocket_client.js | 236 ++ .../service/debian-operator_panel.service | 18 + .../resources/service/operator_panel.php | 76 + .../classes/base_websocket_system_service.php | 20 +- .../resources/classes/websocket_service.php | 35 +- 32 files changed, 7526 insertions(+), 14 deletions(-) create mode 100644 app/operator_panel/app_config.php create mode 100644 app/operator_panel/app_defaults.php create mode 100644 app/operator_panel/app_languages.php create mode 100644 app/operator_panel/app_menu.php create mode 100644 app/operator_panel/index.php create mode 100644 app/operator_panel/resources/classes/operator_panel_agent_filter.php create mode 100644 app/operator_panel/resources/classes/operator_panel_call_filter.php create mode 100644 app/operator_panel/resources/classes/operator_panel_conf_filter.php create mode 100644 app/operator_panel/resources/classes/operator_panel_service.php create mode 100644 app/operator_panel/resources/images/barge.svg create mode 100644 app/operator_panel/resources/images/eavesdrop.png create mode 100644 app/operator_panel/resources/images/inbound.png create mode 100644 app/operator_panel/resources/images/keypad_call.png create mode 100644 app/operator_panel/resources/images/keypad_transfer.png create mode 100644 app/operator_panel/resources/images/kill.png create mode 100644 app/operator_panel/resources/images/outbound.png create mode 100644 app/operator_panel/resources/images/record.png create mode 100644 app/operator_panel/resources/images/recording.png create mode 100644 app/operator_panel/resources/images/refresh_active.gif create mode 100644 app/operator_panel/resources/images/refresh_paused.png create mode 100644 app/operator_panel/resources/images/status_available.png create mode 100644 app/operator_panel/resources/images/status_available_on_demand.png create mode 100644 app/operator_panel/resources/images/status_do_not_disturb.png create mode 100644 app/operator_panel/resources/images/status_logged_out.png create mode 100644 app/operator_panel/resources/images/status_on_break.png create mode 100644 app/operator_panel/resources/images/whisper.svg create mode 100644 app/operator_panel/resources/javascript/operator_panel.js create mode 100644 app/operator_panel/resources/javascript/websocket_client.js create mode 100644 app/operator_panel/resources/service/debian-operator_panel.service create mode 100755 app/operator_panel/resources/service/operator_panel.php diff --git a/app/operator_panel/app_config.php b/app/operator_panel/app_config.php new file mode 100644 index 000000000..eedffe1f7 --- /dev/null +++ b/app/operator_panel/app_config.php @@ -0,0 +1,231 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +//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."; diff --git a/app/operator_panel/app_defaults.php b/app/operator_panel/app_defaults.php new file mode 100644 index 000000000..4cf057f70 --- /dev/null +++ b/app/operator_panel/app_defaults.php @@ -0,0 +1,31 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + */ + +// Create default settings for operator panel call group card positions +if ($domains_processed == 1) { + +} diff --git a/app/operator_panel/app_languages.php b/app/operator_panel/app_languages.php new file mode 100644 index 000000000..ba54fc964 --- /dev/null +++ b/app/operator_panel/app_languages.php @@ -0,0 +1,1784 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +$text['title-operator_panel']['en-us'] = "Operator Panel"; +$text['title-operator_panel']['en-gb'] = "Operator Panel"; +$text['title-operator_panel']['ar-eg'] = "لوحة المشغل"; +$text['title-operator_panel']['de-at'] = "Bedienfeld"; +$text['title-operator_panel']['de-ch'] = "Bedienfeld"; +$text['title-operator_panel']['de-de'] = "Bedienfeld"; +$text['title-operator_panel']['el-gr'] = "Πίνακας χειριστή"; +$text['title-operator_panel']['es-cl'] = "Panel de Operador"; +$text['title-operator_panel']['es-mx'] = "Panel de Operador"; +$text['title-operator_panel']['fr-ca'] = "Panneau de commande"; +$text['title-operator_panel']['fr-fr'] = "Panneau de commande"; +$text['title-operator_panel']['he-il'] = "לוח מפעיל"; +$text['title-operator_panel']['it-it'] = "Pannello Operatore"; +$text['title-operator_panel']['ka-ge'] = "ოპერატორის პანელი"; +$text['title-operator_panel']['nl-nl'] = "Operator paneel"; +$text['title-operator_panel']['pl-pl'] = "Panel operatora"; +$text['title-operator_panel']['pt-br'] = "Painel do operador"; +$text['title-operator_panel']['pt-pt'] = "Painel do Operador"; +$text['title-operator_panel']['ro-ro'] = "Panoul operator"; +$text['title-operator_panel']['ru-ru'] = "Панель Оператора"; +$text['title-operator_panel']['sv-se'] = "Telefonist Panel"; +$text['title-operator_panel']['uk-ua'] = "Оператори"; +$text['title-operator_panel']['tr-tr'] = "Operatör Paneli"; +$text['title-operator_panel']['zh-cn'] = "操作面板"; +$text['title-operator_panel']['ja-jp'] = "オペレーターパネル"; +$text['title-operator_panel']['ko-kr'] = "운영자 패널"; + +$text['status-connected']['en-us'] = "Connected"; +$text['status-connected']['en-gb'] = "Connected"; +$text['status-connected']['ar-eg'] = "متصل"; +$text['status-connected']['de-at'] = "Verbunden"; +$text['status-connected']['de-ch'] = "Verbunden"; +$text['status-connected']['de-de'] = "Verbunden"; +$text['status-connected']['el-gr'] = "Συνδεδεμένος"; +$text['status-connected']['es-cl'] = "Conectado"; +$text['status-connected']['es-mx'] = "Conectado"; +$text['status-connected']['fr-ca'] = "Connecté"; +$text['status-connected']['fr-fr'] = "Connecté"; +$text['status-connected']['he-il'] = "מחובר"; +$text['status-connected']['it-it'] = "Connesso"; +$text['status-connected']['ka-ge'] = "დაკავშირებულია"; +$text['status-connected']['nl-nl'] = "Verbonden"; +$text['status-connected']['pl-pl'] = "Połączony"; +$text['status-connected']['pt-br'] = "Conectado"; +$text['status-connected']['pt-pt'] = "Conectado"; +$text['status-connected']['ro-ro'] = "Conectat"; +$text['status-connected']['ru-ru'] = "Подключено"; +$text['status-connected']['sv-se'] = "Ansluten"; +$text['status-connected']['uk-ua'] = "Підключено"; +$text['status-connected']['tr-tr'] = "Bağlı"; +$text['status-connected']['zh-cn'] = "已连接"; +$text['status-connected']['ja-jp'] = "接続済み"; +$text['status-connected']['ko-kr'] = "연결됨"; + +$text['status-warning']['en-us'] = "Warning"; +$text['status-warning']['en-gb'] = "Warning"; +$text['status-warning']['ar-eg'] = "تحذير"; +$text['status-warning']['de-at'] = "Warnung"; +$text['status-warning']['de-ch'] = "Warnung"; +$text['status-warning']['de-de'] = "Warnung"; +$text['status-warning']['el-gr'] = "Προειδοποίηση"; +$text['status-warning']['es-cl'] = "Advertencia"; +$text['status-warning']['es-mx'] = "Advertencia"; +$text['status-warning']['fr-ca'] = "Avertissement"; +$text['status-warning']['fr-fr'] = "Avertissement"; +$text['status-warning']['he-il'] = "אזהרה"; +$text['status-warning']['it-it'] = "Avviso"; +$text['status-warning']['ka-ge'] = "გაფრთხილება"; +$text['status-warning']['nl-nl'] = "Waarschuwing"; +$text['status-warning']['pl-pl'] = "Ostrzeżenie"; +$text['status-warning']['pt-br'] = "Aviso"; +$text['status-warning']['pt-pt'] = "Aviso"; +$text['status-warning']['ro-ro'] = "Avertisment"; +$text['status-warning']['ru-ru'] = "Предупреждение"; +$text['status-warning']['sv-se'] = "Varning"; +$text['status-warning']['uk-ua'] = "Попередження"; +$text['status-warning']['tr-tr'] = "Uyarı"; +$text['status-warning']['zh-cn'] = "警告"; +$text['status-warning']['ja-jp'] = "警告"; +$text['status-warning']['ko-kr'] = "경고"; + +$text['status-disconnected']['en-us'] = "Disconnected"; +$text['status-disconnected']['en-gb'] = "Disconnected"; +$text['status-disconnected']['ar-eg'] = "غير متصل"; +$text['status-disconnected']['de-at'] = "Getrennt"; +$text['status-disconnected']['de-ch'] = "Getrennt"; +$text['status-disconnected']['de-de'] = "Getrennt"; +$text['status-disconnected']['el-gr'] = "Αποσυνδεδεμένος"; +$text['status-disconnected']['es-cl'] = "Desconectado"; +$text['status-disconnected']['es-mx'] = "Desconectado"; +$text['status-disconnected']['fr-ca'] = "Déconnecté"; +$text['status-disconnected']['fr-fr'] = "Déconnecté"; +$text['status-disconnected']['he-il'] = "מנותק"; +$text['status-disconnected']['it-it'] = "Disconnesso"; +$text['status-disconnected']['ka-ge'] = "გათიშულია"; +$text['status-disconnected']['nl-nl'] = "Verbonden"; +$text['status-disconnected']['pl-pl'] = "Rozłączony"; +$text['status-disconnected']['pt-br'] = "Desconectado"; +$text['status-disconnected']['pt-pt'] = "Desconectado"; +$text['status-disconnected']['ro-ro'] = "Deconectat"; +$text['status-disconnected']['ru-ru'] = "Отключено"; +$text['status-disconnected']['sv-se'] = "Frånkopplad"; +$text['status-disconnected']['uk-ua'] = "Відключено"; +$text['status-disconnected']['tr-tr'] = "Bağlantı Kesildi"; +$text['status-disconnected']['zh-cn'] = "已断开连接"; +$text['status-disconnected']['ja-jp'] = "切断されました"; +$text['status-disconnected']['ko-kr'] = "연결 끊김"; + +$text['status-connecting']['en-us'] = "Connecting"; +$text['status-connecting']['en-gb'] = "Connecting"; +$text['status-connecting']['ar-eg'] = "جارٍ الاتصال"; +$text['status-connecting']['de-at'] = "Verbindung wird hergestellt"; +$text['status-connecting']['de-ch'] = "Verbindung wird hergestellt"; +$text['status-connecting']['de-de'] = "Verbindung wird hergestellt"; +$text['status-connecting']['el-gr'] = "Σύνδεση"; +$text['status-connecting']['es-cl'] = "Conectando"; +$text['status-connecting']['es-mx'] = "Conectando"; +$text['status-connecting']['fr-ca'] = "Connexion"; +$text['status-connecting']['fr-fr'] = "Connexion"; +$text['status-connecting']['he-il'] = "מתחבר"; +$text['status-connecting']['it-it'] = "Connessione in corso"; +$text['status-connecting']['ka-ge'] = "კავშირი"; +$text['status-connecting']['nl-nl'] = "Verbinden"; +$text['status-connecting']['pl-pl'] = "Łączenie"; +$text['status-connecting']['pt-br'] = "Conectando"; +$text['status-connecting']['pt-pt'] = "Conectando"; +$text['status-connecting']['ro-ro'] = "Se conectează"; +$text['status-connecting']['ru-ru'] = "Подключение"; +$text['status-connecting']['sv-se'] = "Ansluter"; +$text['status-connecting']['uk-ua'] = "Підключення"; +$text['status-connecting']['tr-tr'] = "Bağlanıyor"; +$text['status-connecting']['zh-cn'] = "正在连接"; +$text['status-connecting']['ja-jp'] = "接続中"; +$text['status-connecting']['ko-kr'] = "연결 중"; + +$text['label-tab_calls']['en-us'] = "Calls"; +$text['label-tab_calls']['en-gb'] = "Calls"; +$text['label-tab_calls']['ar-eg'] = "المكالمات"; +$text['label-tab_calls']['de-at'] = "Anrufe"; +$text['label-tab_calls']['de-ch'] = "Anrufe"; +$text['label-tab_calls']['de-de'] = "Anrufe"; +$text['label-tab_calls']['el-gr'] = "Κλήσεις"; +$text['label-tab_calls']['es-cl'] = "Llamadas"; +$text['label-tab_calls']['es-mx'] = "Llamadas"; +$text['label-tab_calls']['fr-ca'] = "Appels"; +$text['label-tab_calls']['fr-fr'] = "Appels"; +$text['label-tab_calls']['he-il'] = "שיחות"; +$text['label-tab_calls']['it-it'] = "Chiamate"; +$text['label-tab_calls']['ka-ge'] = "ზარები"; +$text['label-tab_calls']['nl-nl'] = "Oproepen"; +$text['label-tab_calls']['pl-pl'] = "Połączenia"; +$text['label-tab_calls']['pt-br'] = "Chamadas"; +$text['label-tab_calls']['pt-pt'] = "Chamadas"; +$text['label-tab_calls']['ro-ro'] = "Apeluri"; +$text['label-tab_calls']['ru-ru'] = "Вызовы"; +$text['label-tab_calls']['sv-se'] = "Samtal"; +$text['label-tab_calls']['uk-ua'] = "Дзвінки"; +$text['label-tab_calls']['tr-tr'] = "Aramalar"; +$text['label-tab_calls']['zh-cn'] = "通话"; +$text['label-tab_calls']['ja-jp'] = "通話"; +$text['label-tab_calls']['ko-kr'] = "통화"; + +$text['label-tab_conferences']['en-us'] = "Conferences"; +$text['label-tab_conferences']['en-gb'] = "Conferences"; +$text['label-tab_conferences']['ar-eg'] = "المؤتمرات"; +$text['label-tab_conferences']['de-at'] = "Konferenzen"; +$text['label-tab_conferences']['de-ch'] = "Konferenzen"; +$text['label-tab_conferences']['de-de'] = "Konferenzen"; +$text['label-tab_conferences']['el-gr'] = "Συνέδρια"; +$text['label-tab_conferences']['es-cl'] = "Conferencias"; +$text['label-tab_conferences']['es-mx'] = "Conferencias"; +$text['label-tab_conferences']['fr-ca'] = "Conférences"; +$text['label-tab_conferences']['fr-fr'] = "Conférences"; +$text['label-tab_conferences']['he-il'] = "כנסים"; +$text['label-tab_conferences']['it-it'] = "Conferenze"; +$text['label-tab_conferences']['ka-ge'] = "კონფერენციები"; +$text['label-tab_conferences']['nl-nl'] = "Conferenties"; +$text['label-tab_conferences']['pl-pl'] = "Konferencje"; +$text['label-tab_conferences']['pt-br'] = "Conferências"; +$text['label-tab_conferences']['pt-pt'] = "Conferências"; +$text['label-tab_conferences']['ro-ro'] = "Conferințe"; +$text['label-tab_conferences']['ru-ru'] = "Конференции"; +$text['label-tab_conferences']['sv-se'] = "Konferenser"; +$text['label-tab_conferences']['uk-ua'] = "Конференції"; +$text['label-tab_conferences']['tr-tr'] = "Konferanslar"; +$text['label-tab_conferences']['zh-cn'] = "会议"; +$text['label-tab_conferences']['ja-jp'] = "会議"; +$text['label-tab_conferences']['ko-kr'] = "회의"; + +$text['label-tab_agents']['en-us'] = "Agents"; +$text['label-tab_agents']['en-gb'] = "Agents"; +$text['label-tab_agents']['ar-eg'] = "وكلاء"; +$text['label-tab_agents']['de-at'] = "Agenten"; +$text['label-tab_agents']['de-ch'] = "Agenten"; +$text['label-tab_agents']['de-de'] = "Agenten"; +$text['label-tab_agents']['el-gr'] = "Πράκτορες"; +$text['label-tab_agents']['es-cl'] = "Agentes"; +$text['label-tab_agents']['es-mx'] = "Agentes"; +$text['label-tab_agents']['fr-ca'] = "Agents"; +$text['label-tab_agents']['fr-fr'] = "Agents"; +$text['label-tab_agents']['he-il'] = "סוכנים"; +$text['label-tab_agents']['it-it'] = "Agenti"; +$text['label-tab_agents']['ka-ge'] = "მომხსენებლები"; +$text['label-tab_agents']['nl-nl'] = "Agenten"; +$text['label-tab_agents']['pl-pl'] = "Agenci"; +$text['label-tab_agents']['pt-br'] = "Agentes"; +$text['label-tab_agents']['pt-pt'] = "Agentes"; +$text['label-tab_agents']['ro-ro'] = "Agenți"; +$text['label-tab_agents']['ru-ru'] = "Агенты"; +$text['label-tab_agents']['sv-se'] = "Agenter"; +$text['label-tab_agents']['uk-ua'] = "Агенти"; +$text['label-tab_agents']['tr-tr'] = "Ajanlar"; +$text['label-tab_agents']['zh-cn'] = "代理"; +$text['label-tab_agents']['ja-jp'] = "エージェント"; +$text['label-tab_agents']['ko-kr'] = "에이전트"; + +$text['label-filter']['en-us'] = "Search"; +$text['label-filter']['en-gb'] = "Search"; +$text['label-filter']['ar-eg'] = "بحث"; +$text['label-filter']['de-at'] = "Suche"; +$text['label-filter']['de-ch'] = "Suche"; +$text['label-filter']['de-de'] = "Suche"; +$text['label-filter']['el-gr'] = "Αναζήτηση"; +$text['label-filter']['es-cl'] = "Búsqueda"; +$text['label-filter']['es-mx'] = "Búsqueda"; +$text['label-filter']['fr-ca'] = "Recherche"; +$text['label-filter']['fr-fr'] = "Recherche"; +$text['label-filter']['he-il'] = "חיפוש"; +$text['label-filter']['it-it'] = "Ricerca"; +$text['label-filter']['ka-ge'] = "ძიება"; +$text['label-filter']['nl-nl'] = "Zoeken"; +$text['label-filter']['pl-pl'] = "Szukaj"; +$text['label-filter']['pt-br'] = "Busca"; +$text['label-filter']['pt-pt'] = "Pesquisa"; +$text['label-filter']['ro-ro'] = "Căutare"; +$text['label-filter']['ru-ru'] = "Поиск"; +$text['label-filter']['sv-se'] = "Sök"; +$text['label-filter']['uk-ua'] = "Пошук"; +$text['label-filter']['tr-tr'] = "Ara"; +$text['label-filter']['zh-cn'] = "搜索"; +$text['label-filter']['ja-jp'] = "検索"; +$text['label-filter']['ko-kr'] = "검색"; + +$text['label-call_group']['en-us'] = "Group"; +$text['label-call_group']['en-gb'] = "Group"; +$text['label-call_group']['ar-eg'] = "مجموعة"; +$text['label-call_group']['de-at'] = "Gruppe"; +$text['label-call_group']['de-ch'] = "Gruppe"; +$text['label-call_group']['de-de'] = "Gruppe"; +$text['label-call_group']['el-gr'] = "Ομάδα"; +$text['label-call_group']['es-cl'] = "Grupo"; +$text['label-call_group']['es-mx'] = "Grupo"; +$text['label-call_group']['fr-ca'] = "Groupe"; +$text['label-call_group']['fr-fr'] = "Groupe"; +$text['label-call_group']['he-il'] = "קבוצה"; +$text['label-call_group']['it-it'] = "Gruppo"; +$text['label-call_group']['ka-ge'] = "ჯგუფი"; +$text['label-call_group']['nl-nl'] = "Groep"; +$text['label-call_group']['pl-pl'] = "Grupa"; +$text['label-call_group']['pt-br'] = "Grupo"; +$text['label-call_group']['pt-pt'] = "Grupo"; +$text['label-call_group']['ro-ro'] = "Grup"; +$text['label-call_group']['ru-ru'] = "Группа"; +$text['label-call_group']['sv-se'] = "Grupp"; +$text['label-call_group']['uk-ua'] = "Група"; +$text['label-call_group']['tr-tr'] = "Grup"; +$text['label-call_group']['zh-cn'] = "组"; +$text['label-call_group']['ja-jp'] = "グループ"; +$text['label-call_group']['ko-kr'] = "그룹"; + +$text['label-ungrouped']['en-us'] = "Ungrouped"; +$text['label-ungrouped']['en-gb'] = "Ungrouped"; +$text['label-ungrouped']['ar-eg'] = "غير مجمّع"; +$text['label-ungrouped']['de-at'] = "Ohne Gruppe"; +$text['label-ungrouped']['de-ch'] = "Ohne Gruppe"; +$text['label-ungrouped']['de-de'] = "Ohne Gruppe"; +$text['label-ungrouped']['el-gr'] = "Χωρίς ομάδα"; +$text['label-ungrouped']['es-cl'] = "Sin grupo"; +$text['label-ungrouped']['es-mx'] = "Sin grupo"; +$text['label-ungrouped']['fr-ca'] = "Non groupé"; +$text['label-ungrouped']['fr-fr'] = "Non groupé"; +$text['label-ungrouped']['he-il'] = "ללא קבוצה"; +$text['label-ungrouped']['it-it'] = "Senza gruppo"; +$text['label-ungrouped']['ka-ge'] = "დაუჯგუფებელი"; +$text['label-ungrouped']['nl-nl'] = "Ongegroepeerd"; +$text['label-ungrouped']['pl-pl'] = "Bez grupy"; +$text['label-ungrouped']['pt-br'] = "Sem grupo"; +$text['label-ungrouped']['pt-pt'] = "Sem grupo"; +$text['label-ungrouped']['ro-ro'] = "Negrupat"; +$text['label-ungrouped']['ru-ru'] = "Без группы"; +$text['label-ungrouped']['sv-se'] = "Ogrupperad"; +$text['label-ungrouped']['uk-ua'] = "Без групи"; +$text['label-ungrouped']['tr-tr'] = "Grupsuz"; +$text['label-ungrouped']['zh-cn'] = "未分组"; +$text['label-ungrouped']['ja-jp'] = "未グループ"; +$text['label-ungrouped']['ko-kr'] = "미분류"; + +$text['label-edit_mode']['en-us'] = "Edit Mode"; +$text['label-edit_mode']['en-gb'] = "Edit Mode"; +$text['label-edit_mode']['ar-eg'] = "وضع التحرير"; +$text['label-edit_mode']['de-at'] = "Bearbeitungsmodus"; +$text['label-edit_mode']['de-ch'] = "Bearbeitungsmodus"; +$text['label-edit_mode']['de-de'] = "Bearbeitungsmodus"; +$text['label-edit_mode']['el-gr'] = "Λειτουργία επεξεργασίας"; +$text['label-edit_mode']['es-cl'] = "Modo edición"; +$text['label-edit_mode']['es-mx'] = "Modo edición"; +$text['label-edit_mode']['fr-ca'] = "Mode édition"; +$text['label-edit_mode']['fr-fr'] = "Mode édition"; +$text['label-edit_mode']['he-il'] = "מצב עריכה"; +$text['label-edit_mode']['it-it'] = "Modalità modifica"; +$text['label-edit_mode']['ka-ge'] = "რედაქტირების რეჟიმი"; +$text['label-edit_mode']['nl-nl'] = "Bewerkingsmodus"; +$text['label-edit_mode']['pl-pl'] = "Tryb edycji"; +$text['label-edit_mode']['pt-br'] = "Modo edição"; +$text['label-edit_mode']['pt-pt'] = "Modo edição"; +$text['label-edit_mode']['ro-ro'] = "Mod editare"; +$text['label-edit_mode']['ru-ru'] = "Режим редактирования"; +$text['label-edit_mode']['sv-se'] = "Redigeringsläge"; +$text['label-edit_mode']['uk-ua'] = "Режим редагування"; +$text['label-edit_mode']['tr-tr'] = "Düzenleme modu"; +$text['label-edit_mode']['zh-cn'] = "编辑模式"; +$text['label-edit_mode']['ja-jp'] = "編集モード"; +$text['label-edit_mode']['ko-kr'] = "편집 모드"; + +$text['button-all']['en-us'] = "All"; +$text['button-all']['en-gb'] = "All"; +$text['button-all']['ar-eg'] = "الكل"; +$text['button-all']['de-at'] = "Alle"; +$text['button-all']['de-ch'] = "Alle"; +$text['button-all']['de-de'] = "Alle"; +$text['button-all']['el-gr'] = "Όλα"; +$text['button-all']['es-cl'] = "Todos"; +$text['button-all']['es-mx'] = "Todos"; +$text['button-all']['fr-ca'] = "Tous"; +$text['button-all']['fr-fr'] = "Tous"; +$text['button-all']['he-il'] = "הכל"; +$text['button-all']['it-it'] = "Tutti"; +$text['button-all']['ka-ge'] = "ყველა"; +$text['button-all']['nl-nl'] = "Alle"; +$text['button-all']['pl-pl'] = "Wszystkie"; +$text['button-all']['pt-br'] = "Todos"; +$text['button-all']['pt-pt'] = "Todos"; +$text['button-all']['ro-ro'] = "Toate"; +$text['button-all']['ru-ru'] = "Все"; +$text['button-all']['sv-se'] = "Alla"; +$text['button-all']['uk-ua'] = "Усі"; +$text['button-all']['tr-tr'] = "Tümü"; +$text['button-all']['zh-cn'] = "全部"; +$text['button-all']['ja-jp'] = "すべて"; +$text['button-all']['ko-kr'] = "모두"; + +$text['button-reset']['en-us'] = "Reset"; +$text['button-reset']['en-gb'] = "Reset"; +$text['button-reset']['ar-eg'] = "إعادة تعيين"; +$text['button-reset']['de-at'] = "Zurücksetzen"; +$text['button-reset']['de-ch'] = "Zurücksetzen"; +$text['button-reset']['de-de'] = "Zurücksetzen"; +$text['button-reset']['el-gr'] = "Επαναφορά"; +$text['button-reset']['es-cl'] = "Restablecer"; +$text['button-reset']['es-mx'] = "Restablecer"; +$text['button-reset']['fr-ca'] = "Réinitialiser"; +$text['button-reset']['fr-fr'] = "Réinitialiser"; +$text['button-reset']['he-il'] = "איפוס"; +$text['button-reset']['it-it'] = "Reimposta"; +$text['button-reset']['ka-ge'] = "გადაყენება"; +$text['button-reset']['nl-nl'] = "Reset"; +$text['button-reset']['pl-pl'] = "Resetuj"; +$text['button-reset']['pt-br'] = "Redefinir"; +$text['button-reset']['pt-pt'] = "Repor"; +$text['button-reset']['ro-ro'] = "Resetează"; +$text['button-reset']['ru-ru'] = "Сброс"; +$text['button-reset']['sv-se'] = "Återställ"; +$text['button-reset']['uk-ua'] = "Скинути"; +$text['button-reset']['tr-tr'] = "Sıfırla"; +$text['button-reset']['zh-cn'] = "重置"; +$text['button-reset']['ja-jp'] = "リセット"; +$text['button-reset']['ko-kr'] = "재설정"; + +$text['label-call_direction']['en-us'] = "Call Direction"; +$text['label-call_direction']['en-gb'] = "Call Direction"; +$text['label-call_direction']['ar-eg'] = "اتجاه المكالمة"; +$text['label-call_direction']['de-at'] = "Anrufrichtung"; +$text['label-call_direction']['de-ch'] = "Anrufrichtung"; +$text['label-call_direction']['de-de'] = "Anrufrichtung"; +$text['label-call_direction']['el-gr'] = "Κατεύθυνση κλήσης"; +$text['label-call_direction']['es-cl'] = "Dirección de la llamada"; +$text['label-call_direction']['es-mx'] = "Dirección de la llamada"; +$text['label-call_direction']['fr-ca'] = "Direction de l'appel"; +$text['label-call_direction']['fr-fr'] = "Direction de l'appel"; +$text['label-call_direction']['he-il'] = "כיוון השיחה"; +$text['label-call_direction']['it-it'] = "Direzione chiamata"; +$text['label-call_direction']['ka-ge'] = "ზარის მიმართულება"; +$text['label-call_direction']['nl-nl'] = "Gespreksrichting"; +$text['label-call_direction']['pl-pl'] = "Kierunek połączenia"; +$text['label-call_direction']['pt-br'] = "Direção da chamada"; +$text['label-call_direction']['pt-pt'] = "Direção da chamada"; +$text['label-call_direction']['ro-ro'] = "Direcția apelului"; +$text['label-call_direction']['ru-ru'] = "Направление вызова"; +$text['label-call_direction']['sv-se'] = "Samtalsriktning"; +$text['label-call_direction']['uk-ua'] = "Напрямок виклику"; +$text['label-call_direction']['tr-tr'] = "Arama Yönü"; +$text['label-call_direction']['zh-cn'] = "通话方向"; +$text['label-call_direction']['ja-jp'] = "通話方向"; +$text['label-call_direction']['ko-kr'] = "통화 방향"; + +$text['label-parked_calls']['en-us'] = "Parked Calls"; +$text['label-parked_calls']['en-gb'] = "Parked Calls"; +$text['label-parked_calls']['ar-eg'] = "المكالمات المركونة"; +$text['label-parked_calls']['de-at'] = "Geparkte Anrufe"; +$text['label-parked_calls']['de-ch'] = "Geparkte Anrufe"; +$text['label-parked_calls']['de-de'] = "Geparkte Anrufe"; +$text['label-parked_calls']['el-gr'] = "Σταθμευμένες κλήσεις"; +$text['label-parked_calls']['es-cl'] = "Llamadas aparcadas"; +$text['label-parked_calls']['es-mx'] = "Llamadas aparcadas"; +$text['label-parked_calls']['fr-ca'] = "Appels parqués"; +$text['label-parked_calls']['fr-fr'] = "Appels parqués"; +$text['label-parked_calls']['he-il'] = "שיחות מושהות"; +$text['label-parked_calls']['it-it'] = "Chiamate parcheggiate"; +$text['label-parked_calls']['ka-ge'] = "დაპარკებული ზარები"; +$text['label-parked_calls']['nl-nl'] = "Geparkeerde oproepen"; +$text['label-parked_calls']['pl-pl'] = "Zaparkowane połączenia"; +$text['label-parked_calls']['pt-br'] = "Chamadas estacionadas"; +$text['label-parked_calls']['pt-pt'] = "Chamadas estacionadas"; +$text['label-parked_calls']['ro-ro'] = "Apeluri parcate"; +$text['label-parked_calls']['ru-ru'] = "Припаркоованные вызовы"; +$text['label-parked_calls']['sv-se'] = "Parkerade samtal"; +$text['label-parked_calls']['uk-ua'] = "Припарковані дзвінки"; +$text['label-parked_calls']['tr-tr'] = "Park edilmiş aramalar"; +$text['label-parked_calls']['zh-cn'] = "已停泊通话"; +$text['label-parked_calls']['ja-jp'] = "保留中の通話"; +$text['label-parked_calls']['ko-kr'] = "주차된 통화"; + +$text['label-no_parked_calls']['en-us'] = "No parked calls"; +$text['label-no_parked_calls']['en-gb'] = "No parked calls"; +$text['label-no_parked_calls']['ar-eg'] = "لا توجد مكالمات مركونة"; +$text['label-no_parked_calls']['de-at'] = "Keine geparkten Anrufe"; +$text['label-no_parked_calls']['de-ch'] = "Keine geparkten Anrufe"; +$text['label-no_parked_calls']['de-de'] = "Keine geparkten Anrufe"; +$text['label-no_parked_calls']['el-gr'] = "Δεν υπάρχουν σταθμευμένες κλήσεις"; +$text['label-no_parked_calls']['es-cl'] = "No hay llamadas aparcadas"; +$text['label-no_parked_calls']['es-mx'] = "No hay llamadas aparcadas"; +$text['label-no_parked_calls']['fr-ca'] = "Aucun appel parqué"; +$text['label-no_parked_calls']['fr-fr'] = "Aucun appel parqué"; +$text['label-no_parked_calls']['he-il'] = "אין שיחות מושהות"; +$text['label-no_parked_calls']['it-it'] = "Nessuna chiamata parcheggiata"; +$text['label-no_parked_calls']['ka-ge'] = "დაპარკებული ზარები არ არის"; +$text['label-no_parked_calls']['nl-nl'] = "Geen geparkeerde oproepen"; +$text['label-no_parked_calls']['pl-pl'] = "Brak zaparkowanych połączeń"; +$text['label-no_parked_calls']['pt-br'] = "Nenhuma chamada estacionada"; +$text['label-no_parked_calls']['pt-pt'] = "Nenhuma chamada estacionada"; +$text['label-no_parked_calls']['ro-ro'] = "Nu există apeluri parcate"; +$text['label-no_parked_calls']['ru-ru'] = "Нет припаркованных вызовов"; +$text['label-no_parked_calls']['sv-se'] = "Inga parkerade samtal"; +$text['label-no_parked_calls']['uk-ua'] = "Немає припаркованих дзвінків"; +$text['label-no_parked_calls']['tr-tr'] = "Park edilmiş arama yok"; +$text['label-no_parked_calls']['zh-cn'] = "没有已停泊通话"; +$text['label-no_parked_calls']['ja-jp'] = "保留中の通話はありません"; +$text['label-no_parked_calls']['ko-kr'] = "주차된 통화 없음"; + +$text['label-recording']['en-us'] = "Recording"; +$text['label-recording']['en-gb'] = "Recording"; +$text['label-recording']['ar-eg'] = "تسجيل"; +$text['label-recording']['de-at'] = "Aufnahme"; +$text['label-recording']['de-ch'] = "Aufnahme"; +$text['label-recording']['de-de'] = "Aufnahme"; +$text['label-recording']['el-gr'] = "Εγγραφή"; +$text['label-recording']['es-cl'] = "Grabación"; +$text['label-recording']['es-mx'] = "Grabación"; +$text['label-recording']['fr-ca'] = "Enregistrement"; +$text['label-recording']['fr-fr'] = "Enregistrement"; +$text['label-recording']['he-il'] = "הקלטה"; +$text['label-recording']['it-it'] = "Registrazione"; +$text['label-recording']['ka-ge'] = "ჩანაწერი"; +$text['label-recording']['nl-nl'] = "Opname"; +$text['label-recording']['pl-pl'] = "Nagranie"; +$text['label-recording']['pt-br'] = "Gravação"; +$text['label-recording']['pt-pt'] = "Gravação"; +$text['label-recording']['ro-ro'] = "Înregistrare"; +$text['label-recording']['ru-ru'] = "Запись"; +$text['label-recording']['sv-se'] = "Inspelning"; +$text['label-recording']['uk-ua'] = "Запис"; +$text['label-recording']['tr-tr'] = "Kayıt"; +$text['label-recording']['zh-cn'] = "记录"; +$text['label-recording']['ja-jp'] = "録音"; +$text['label-recording']['ko-kr'] = "녹음"; + +$text['label-record']['en-us'] = "Record Call"; +$text['label-record']['en-gb'] = "Record Call"; +$text['label-record']['ar-eg'] = "تسجيل المكالمة"; +$text['label-record']['de-at'] = "Anruf aufzeichnen"; +$text['label-record']['de-ch'] = "Anruf aufzeichnen"; +$text['label-record']['de-de'] = "Anruf aufzeichnen"; +$text['label-record']['el-gr'] = "Εγγραφή κλήσης"; +$text['label-record']['es-cl'] = "Grabar llamada"; +$text['label-record']['es-mx'] = "Grabar llamada"; +$text['label-record']['fr-ca'] = "Enregistrer l'appel"; +$text['label-record']['fr-fr'] = "Enregistrer l'appel"; +$text['label-record']['he-il'] = "הקלט שיחה"; +$text['label-record']['it-it'] = "Registra chiamata"; +$text['label-record']['ka-ge'] = "ზარის ჩაწერა"; +$text['label-record']['nl-nl'] = "Gesprek opnemen"; +$text['label-record']['pl-pl'] = "Nagraj połączenie"; +$text['label-record']['pt-br'] = "Gravar chamada"; +$text['label-record']['pt-pt'] = "Gravar chamada"; +$text['label-record']['ro-ro'] = "Înregistrează apelul"; +$text['label-record']['ru-ru'] = "Записать вызов"; +$text['label-record']['sv-se'] = "Spela in samtal"; +$text['label-record']['uk-ua'] = "Записати дзвінок"; +$text['label-record']['tr-tr'] = "Aramayı kaydet"; +$text['label-record']['zh-cn'] = "录制通话"; +$text['label-record']['ja-jp'] = "通話を録音"; +$text['label-record']['ko-kr'] = "통화 녹음"; + +$text['label-eavesdrop']['en-us'] = "Listen"; +$text['label-eavesdrop']['en-gb'] = "Listen"; +$text['label-eavesdrop']['ar-eg'] = "استماع"; +$text['label-eavesdrop']['de-at'] = "Mithören"; +$text['label-eavesdrop']['de-ch'] = "Mithören"; +$text['label-eavesdrop']['de-de'] = "Mithören"; +$text['label-eavesdrop']['el-gr'] = "Ακρόαση"; +$text['label-eavesdrop']['es-cl'] = "Escuchar"; +$text['label-eavesdrop']['es-mx'] = "Escuchar"; +$text['label-eavesdrop']['fr-ca'] = "Écouter"; +$text['label-eavesdrop']['fr-fr'] = "Écouter"; +$text['label-eavesdrop']['he-il'] = "האזן"; +$text['label-eavesdrop']['it-it'] = "Ascolta"; +$text['label-eavesdrop']['ka-ge'] = "მოსმენა"; +$text['label-eavesdrop']['nl-nl'] = "Luisteren"; +$text['label-eavesdrop']['pl-pl'] = "Podsłuch"; +$text['label-eavesdrop']['pt-br'] = "Ouvir"; +$text['label-eavesdrop']['pt-pt'] = "Ouvir"; +$text['label-eavesdrop']['ro-ro'] = "Ascultă"; +$text['label-eavesdrop']['ru-ru'] = "Слушать"; +$text['label-eavesdrop']['sv-se'] = "Lyssna"; +$text['label-eavesdrop']['uk-ua'] = "Слухати"; +$text['label-eavesdrop']['tr-tr'] = "Dinle"; +$text['label-eavesdrop']['zh-cn'] = "监听"; +$text['label-eavesdrop']['ja-jp'] = "聞く"; +$text['label-eavesdrop']['ko-kr'] = "듣기"; + +$text['label-hangup']['en-us'] = "Hangup"; +$text['label-hangup']['en-gb'] = "Hangup"; +$text['label-hangup']['ar-eg'] = "إنهاء"; +$text['label-hangup']['de-at'] = "Auflegen"; +$text['label-hangup']['de-ch'] = "Auflegen"; +$text['label-hangup']['de-de'] = "Auflegen"; +$text['label-hangup']['el-gr'] = "Τερματισμός"; +$text['label-hangup']['es-cl'] = "Colgar"; +$text['label-hangup']['es-mx'] = "Colgar"; +$text['label-hangup']['fr-ca'] = "Raccrocher"; +$text['label-hangup']['fr-fr'] = "Raccrocher"; +$text['label-hangup']['he-il'] = "נתק"; +$text['label-hangup']['it-it'] = "Riaggancia"; +$text['label-hangup']['ka-ge'] = "გათიშვა"; +$text['label-hangup']['nl-nl'] = "Ophangen"; +$text['label-hangup']['pl-pl'] = "Rozłącz"; +$text['label-hangup']['pt-br'] = "Desligar"; +$text['label-hangup']['pt-pt'] = "Desligar"; +$text['label-hangup']['ro-ro'] = "Închide"; +$text['label-hangup']['ru-ru'] = "Повесить трубку"; +$text['label-hangup']['sv-se'] = "Lägg på"; +$text['label-hangup']['uk-ua'] = "Покласти слухавку"; +$text['label-hangup']['tr-tr'] = "Kapat"; +$text['label-hangup']['zh-cn'] = "挂断"; +$text['label-hangup']['ja-jp'] = "切断"; +$text['label-hangup']['ko-kr'] = "끊기"; + +$text['label-transfer']['en-us'] = "Transfer"; +$text['label-transfer']['en-gb'] = "Transfer"; +$text['label-transfer']['ar-eg'] = "تحويل"; +$text['label-transfer']['de-at'] = "Weiterleiten"; +$text['label-transfer']['de-ch'] = "Weiterleiten"; +$text['label-transfer']['de-de'] = "Weiterleiten"; +$text['label-transfer']['el-gr'] = "Μεταφορά"; +$text['label-transfer']['es-cl'] = "Transferir"; +$text['label-transfer']['es-mx'] = "Transferir"; +$text['label-transfer']['fr-ca'] = "Transférer"; +$text['label-transfer']['fr-fr'] = "Transférer"; +$text['label-transfer']['he-il'] = "העבר"; +$text['label-transfer']['it-it'] = "Trasferisci"; +$text['label-transfer']['ka-ge'] = "გადამისამართება"; +$text['label-transfer']['nl-nl'] = "Doorverbinden"; +$text['label-transfer']['pl-pl'] = "Przenieś"; +$text['label-transfer']['pt-br'] = "Transferir"; +$text['label-transfer']['pt-pt'] = "Transferir"; +$text['label-transfer']['ro-ro'] = "Transferă"; +$text['label-transfer']['ru-ru'] = "Перевод"; +$text['label-transfer']['sv-se'] = "Överför"; +$text['label-transfer']['uk-ua'] = "Переадресувати"; +$text['label-transfer']['tr-tr'] = "Aktar"; +$text['label-transfer']['zh-cn'] = "转接"; +$text['label-transfer']['ja-jp'] = "転送"; +$text['label-transfer']['ko-kr'] = "전환"; + +$text['label-call']['en-us'] = "Call"; +$text['label-call']['en-gb'] = "Call"; +$text['label-call']['ar-eg'] = "اتصال"; +$text['label-call']['de-at'] = "Anruf"; +$text['label-call']['de-ch'] = "Anruf"; +$text['label-call']['de-de'] = "Anruf"; +$text['label-call']['el-gr'] = "Κλήση"; +$text['label-call']['es-cl'] = "Llamar"; +$text['label-call']['es-mx'] = "Llamar"; +$text['label-call']['fr-ca'] = "Appel"; +$text['label-call']['fr-fr'] = "Appel"; +$text['label-call']['he-il'] = "התקשר"; +$text['label-call']['it-it'] = "Chiama"; +$text['label-call']['ka-ge'] = "დარეკვა"; +$text['label-call']['nl-nl'] = "Bellen"; +$text['label-call']['pl-pl'] = "Zadzwoń"; +$text['label-call']['pt-br'] = "Chamar"; +$text['label-call']['pt-pt'] = "Chamar"; +$text['label-call']['ro-ro'] = "Apelează"; +$text['label-call']['ru-ru'] = "Позвонить"; +$text['label-call']['sv-se'] = "Ring"; +$text['label-call']['uk-ua'] = "Подзвонити"; +$text['label-call']['tr-tr'] = "Ara"; +$text['label-call']['zh-cn'] = "呼叫"; +$text['label-call']['ja-jp'] = "発信"; +$text['label-call']['ko-kr'] = "통화"; + +$text['label-destination']['en-us'] = "Destination"; +$text['label-destination']['en-gb'] = "Destination"; +$text['label-destination']['ar-eg'] = "الوجهة"; +$text['label-destination']['de-at'] = "Ziel"; +$text['label-destination']['de-ch'] = "Ziel"; +$text['label-destination']['de-de'] = "Ziel"; +$text['label-destination']['el-gr'] = "Προορισμός"; +$text['label-destination']['es-cl'] = "Destino"; +$text['label-destination']['es-mx'] = "Destino"; +$text['label-destination']['fr-ca'] = "Destination"; +$text['label-destination']['fr-fr'] = "Destination"; +$text['label-destination']['he-il'] = "יעד"; +$text['label-destination']['it-it'] = "Destinazione"; +$text['label-destination']['ka-ge'] = "მიმართულება"; +$text['label-destination']['nl-nl'] = "Bestemming"; +$text['label-destination']['pl-pl'] = "Cel"; +$text['label-destination']['pt-br'] = "Destino"; +$text['label-destination']['pt-pt'] = "Destino"; +$text['label-destination']['ro-ro'] = "Destinație"; +$text['label-destination']['ru-ru'] = "Назначение"; +$text['label-destination']['sv-se'] = "Destination"; +$text['label-destination']['uk-ua'] = "Призначення"; +$text['label-destination']['tr-tr'] = "Hedef"; +$text['label-destination']['zh-cn'] = "目标"; +$text['label-destination']['ja-jp'] = "宛先"; +$text['label-destination']['ko-kr'] = "대상"; + +$text['description-eavesdrop_destination']['en-us'] = "Eavesdrop Destination"; +$text['description-eavesdrop_destination']['en-gb'] = "Eavesdrop Destination"; +$text['description-eavesdrop_destination']['ar-eg'] = "وجهة التنصت"; +$text['description-eavesdrop_destination']['de-at'] = "Mithörziel"; +$text['description-eavesdrop_destination']['de-ch'] = "Mithörziel"; +$text['description-eavesdrop_destination']['de-de'] = "Mithörziel"; +$text['description-eavesdrop_destination']['el-gr'] = "Προορισμός ακρόασης"; +$text['description-eavesdrop_destination']['es-cl'] = "Destino de escucha"; +$text['description-eavesdrop_destination']['es-mx'] = "Destino de escucha"; +$text['description-eavesdrop_destination']['fr-ca'] = "Destination d'écoute"; +$text['description-eavesdrop_destination']['fr-fr'] = "Destination d'écoute"; +$text['description-eavesdrop_destination']['he-il'] = "יעד האזנה"; +$text['description-eavesdrop_destination']['it-it'] = "Destinazione ascolto"; +$text['description-eavesdrop_destination']['ka-ge'] = "მოსმენის დანიშნულება"; +$text['description-eavesdrop_destination']['nl-nl'] = "Luisterbestemming"; +$text['description-eavesdrop_destination']['pl-pl'] = "Cel podsłuchu"; +$text['description-eavesdrop_destination']['pt-br'] = "Destino de escuta"; +$text['description-eavesdrop_destination']['pt-pt'] = "Destino de escuta"; +$text['description-eavesdrop_destination']['ro-ro'] = "Destinație ascultare"; +$text['description-eavesdrop_destination']['ru-ru'] = "Назначение прослушивания"; +$text['description-eavesdrop_destination']['sv-se'] = "Avlyssningsdestination"; +$text['description-eavesdrop_destination']['uk-ua'] = "Призначення прослуховування"; +$text['description-eavesdrop_destination']['tr-tr'] = "Dinleme hedefi"; +$text['description-eavesdrop_destination']['zh-cn'] = "监听目标"; +$text['description-eavesdrop_destination']['ja-jp'] = "盗聴先"; +$text['description-eavesdrop_destination']['ko-kr'] = "도청 대상"; + +$text['label-status_available']['en-us'] = "Available"; +$text['label-status_available']['en-gb'] = "Available"; +$text['label-status_available']['ar-eg'] = "متاح"; +$text['label-status_available']['de-at'] = "Verfügbar"; +$text['label-status_available']['de-ch'] = "Verfügbar"; +$text['label-status_available']['de-de'] = "Verfügbar"; +$text['label-status_available']['el-gr'] = "Διαθέσιμος"; +$text['label-status_available']['es-cl'] = "Disponible"; +$text['label-status_available']['es-mx'] = "Disponible"; +$text['label-status_available']['fr-ca'] = "Disponible"; +$text['label-status_available']['fr-fr'] = "Disponible"; +$text['label-status_available']['he-il'] = "זמין"; +$text['label-status_available']['it-it'] = "Disponibile"; +$text['label-status_available']['ka-ge'] = "ხელმისაწვდომი"; +$text['label-status_available']['nl-nl'] = "Beschikbaar"; +$text['label-status_available']['pl-pl'] = "Dostępny"; +$text['label-status_available']['pt-br'] = "Disponível"; +$text['label-status_available']['pt-pt'] = "Disponível"; +$text['label-status_available']['ro-ro'] = "Disponibil"; +$text['label-status_available']['ru-ru'] = "Доступен"; +$text['label-status_available']['sv-se'] = "Tillgänglig"; +$text['label-status_available']['uk-ua'] = "Доступний"; +$text['label-status_available']['tr-tr'] = "Müsait"; +$text['label-status_available']['zh-cn'] = "可用"; +$text['label-status_available']['ja-jp'] = "利用可能"; +$text['label-status_available']['ko-kr'] = "사용 가능"; + +$text['label-status_available_on_demand']['en-us'] = "On Demand"; +$text['label-status_available_on_demand']['en-gb'] = "On Demand"; +$text['label-status_available_on_demand']['ar-eg'] = "عند الطلب"; +$text['label-status_available_on_demand']['de-at'] = "Bei Bedarf"; +$text['label-status_available_on_demand']['de-ch'] = "Bei Bedarf"; +$text['label-status_available_on_demand']['de-de'] = "Bei Bedarf"; +$text['label-status_available_on_demand']['el-gr'] = "Κατ' απαίτηση"; +$text['label-status_available_on_demand']['es-cl'] = "Bajo demanda"; +$text['label-status_available_on_demand']['es-mx'] = "Bajo demanda"; +$text['label-status_available_on_demand']['fr-ca'] = "À la demande"; +$text['label-status_available_on_demand']['fr-fr'] = "À la demande"; +$text['label-status_available_on_demand']['he-il'] = "לפי דרישה"; +$text['label-status_available_on_demand']['it-it'] = "Su richiesta"; +$text['label-status_available_on_demand']['ka-ge'] = "მოთხოვნით"; +$text['label-status_available_on_demand']['nl-nl'] = "Op aanvraag"; +$text['label-status_available_on_demand']['pl-pl'] = "Na żądanie"; +$text['label-status_available_on_demand']['pt-br'] = "Sob demanda"; +$text['label-status_available_on_demand']['pt-pt'] = "A pedido"; +$text['label-status_available_on_demand']['ro-ro'] = "La cerere"; +$text['label-status_available_on_demand']['ru-ru'] = "По запросу"; +$text['label-status_available_on_demand']['sv-se'] = "På begäran"; +$text['label-status_available_on_demand']['uk-ua'] = "На вимогу"; +$text['label-status_available_on_demand']['tr-tr'] = "İsteğe bağlı"; +$text['label-status_available_on_demand']['zh-cn'] = "按需"; +$text['label-status_available_on_demand']['ja-jp'] = "オンデマンド"; +$text['label-status_available_on_demand']['ko-kr'] = "온디맨드"; + +$text['label-status_on_break']['en-us'] = "On Break"; +$text['label-status_on_break']['en-gb'] = "On Break"; +$text['label-status_on_break']['ar-eg'] = "في استراحة"; +$text['label-status_on_break']['de-at'] = "In Pause"; +$text['label-status_on_break']['de-ch'] = "In Pause"; +$text['label-status_on_break']['de-de'] = "In Pause"; +$text['label-status_on_break']['el-gr'] = "Σε διάλειμμα"; +$text['label-status_on_break']['es-cl'] = "En descanso"; +$text['label-status_on_break']['es-mx'] = "En descanso"; +$text['label-status_on_break']['fr-ca'] = "En pause"; +$text['label-status_on_break']['fr-fr'] = "En pause"; +$text['label-status_on_break']['he-il'] = "בהפסקה"; +$text['label-status_on_break']['it-it'] = "In pausa"; +$text['label-status_on_break']['ka-ge'] = "შესვენებაზე"; +$text['label-status_on_break']['nl-nl'] = "Met pauze"; +$text['label-status_on_break']['pl-pl'] = "Na przerwie"; +$text['label-status_on_break']['pt-br'] = "Em pausa"; +$text['label-status_on_break']['pt-pt'] = "Em pausa"; +$text['label-status_on_break']['ro-ro'] = "În pauză"; +$text['label-status_on_break']['ru-ru'] = "На перерыве"; +$text['label-status_on_break']['sv-se'] = "På paus"; +$text['label-status_on_break']['uk-ua'] = "На перерві"; +$text['label-status_on_break']['tr-tr'] = "Molada"; +$text['label-status_on_break']['zh-cn'] = "休息中"; +$text['label-status_on_break']['ja-jp'] = "休憩中"; +$text['label-status_on_break']['ko-kr'] = "휴식 중"; + +$text['label-status_do_not_disturb']['en-us'] = "Do Not Disturb"; +$text['label-status_do_not_disturb']['en-gb'] = "Do Not Disturb"; +$text['label-status_do_not_disturb']['ar-eg'] = "عدم الإزعاج"; +$text['label-status_do_not_disturb']['de-at'] = "Nicht stören"; +$text['label-status_do_not_disturb']['de-ch'] = "Nicht stören"; +$text['label-status_do_not_disturb']['de-de'] = "Nicht stören"; +$text['label-status_do_not_disturb']['el-gr'] = "Μην ενοχλείτε"; +$text['label-status_do_not_disturb']['es-cl'] = "No molestar"; +$text['label-status_do_not_disturb']['es-mx'] = "No molestar"; +$text['label-status_do_not_disturb']['fr-ca'] = "Ne pas déranger"; +$text['label-status_do_not_disturb']['fr-fr'] = "Ne pas déranger"; +$text['label-status_do_not_disturb']['he-il'] = "נא לא להפריע"; +$text['label-status_do_not_disturb']['it-it'] = "Non disturbare"; +$text['label-status_do_not_disturb']['ka-ge'] = "არ შემაწუხოთ"; +$text['label-status_do_not_disturb']['nl-nl'] = "Niet storen"; +$text['label-status_do_not_disturb']['pl-pl'] = "Nie przeszkadzać"; +$text['label-status_do_not_disturb']['pt-br'] = "Não perturbe"; +$text['label-status_do_not_disturb']['pt-pt'] = "Não incomodar"; +$text['label-status_do_not_disturb']['ro-ro'] = "Nu deranja"; +$text['label-status_do_not_disturb']['ru-ru'] = "Не беспокоить"; +$text['label-status_do_not_disturb']['sv-se'] = "Stör ej"; +$text['label-status_do_not_disturb']['uk-ua'] = "Не турбувати"; +$text['label-status_do_not_disturb']['tr-tr'] = "Rahatsız Etmeyin"; +$text['label-status_do_not_disturb']['zh-cn'] = "请勿打扰"; +$text['label-status_do_not_disturb']['ja-jp'] = "取り込み中"; +$text['label-status_do_not_disturb']['ko-kr'] = "방해 금지"; + +$text['label-status_logged_out']['en-us'] = "Logged Out"; +$text['label-status_logged_out']['en-gb'] = "Logged Out"; +$text['label-status_logged_out']['ar-eg'] = "تم تسجيل الخروج"; +$text['label-status_logged_out']['de-at'] = "Abgemeldet"; +$text['label-status_logged_out']['de-ch'] = "Abgemeldet"; +$text['label-status_logged_out']['de-de'] = "Abgemeldet"; +$text['label-status_logged_out']['el-gr'] = "Αποσυνδεδεμένος"; +$text['label-status_logged_out']['es-cl'] = "Desconectado"; +$text['label-status_logged_out']['es-mx'] = "Desconectado"; +$text['label-status_logged_out']['fr-ca'] = "Déconnecté"; +$text['label-status_logged_out']['fr-fr'] = "Déconnecté"; +$text['label-status_logged_out']['he-il'] = "מנותק"; +$text['label-status_logged_out']['it-it'] = "Disconnesso"; +$text['label-status_logged_out']['ka-ge'] = "გასულია"; +$text['label-status_logged_out']['nl-nl'] = "Afgemeld"; +$text['label-status_logged_out']['pl-pl'] = "Wylogowany"; +$text['label-status_logged_out']['pt-br'] = "Desconectado"; +$text['label-status_logged_out']['pt-pt'] = "Desligado"; +$text['label-status_logged_out']['ro-ro'] = "Deconectat"; +$text['label-status_logged_out']['ru-ru'] = "Вышел"; +$text['label-status_logged_out']['sv-se'] = "Utloggad"; +$text['label-status_logged_out']['uk-ua'] = "Вийшов"; +$text['label-status_logged_out']['tr-tr'] = "Çıkış yapıldı"; +$text['label-status_logged_out']['zh-cn'] = "已注销"; +$text['label-status_logged_out']['ja-jp'] = "ログアウト"; +$text['label-status_logged_out']['ko-kr'] = "로그아웃"; + +$text['label-status_logged_out_or_unknown']['en-us'] = "Logged Out / Unknown"; +$text['label-status_logged_out_or_unknown']['en-gb'] = "Logged Out / Unknown"; +$text['label-status_logged_out_or_unknown']['ar-eg'] = "تم تسجيل الخروج / غير معروف"; +$text['label-status_logged_out_or_unknown']['de-at'] = "Abgemeldet / Unbekannt"; +$text['label-status_logged_out_or_unknown']['de-ch'] = "Abgemeldet / Unbekannt"; +$text['label-status_logged_out_or_unknown']['de-de'] = "Abgemeldet / Unbekannt"; +$text['label-status_logged_out_or_unknown']['el-gr'] = "Αποσυνδεδεμένος / Άγνωστο"; +$text['label-status_logged_out_or_unknown']['es-cl'] = "Desconectado / Desconocido"; +$text['label-status_logged_out_or_unknown']['es-mx'] = "Desconectado / Desconocido"; +$text['label-status_logged_out_or_unknown']['fr-ca'] = "Déconnecté / Inconnu"; +$text['label-status_logged_out_or_unknown']['fr-fr'] = "Déconnecté / Inconnu"; +$text['label-status_logged_out_or_unknown']['he-il'] = "מנותק / לא ידוע"; +$text['label-status_logged_out_or_unknown']['it-it'] = "Disconnesso / Sconosciuto"; +$text['label-status_logged_out_or_unknown']['ka-ge'] = "გასულია / უცნობია"; +$text['label-status_logged_out_or_unknown']['nl-nl'] = "Afgemeld / Onbekend"; +$text['label-status_logged_out_or_unknown']['pl-pl'] = "Wylogowany / Nieznany"; +$text['label-status_logged_out_or_unknown']['pt-br'] = "Desconectado / Desconhecido"; +$text['label-status_logged_out_or_unknown']['pt-pt'] = "Desligado / Desconhecido"; +$text['label-status_logged_out_or_unknown']['ro-ro'] = "Deconectat / Necunoscut"; +$text['label-status_logged_out_or_unknown']['ru-ru'] = "Вышел / Неизвестно"; +$text['label-status_logged_out_or_unknown']['sv-se'] = "Utloggad / Okänd"; +$text['label-status_logged_out_or_unknown']['uk-ua'] = "Вийшов / Невідомо"; +$text['label-status_logged_out_or_unknown']['tr-tr'] = "Çıkış yapıldı / Bilinmiyor"; +$text['label-status_logged_out_or_unknown']['zh-cn'] = "已注销 / 未知"; +$text['label-status_logged_out_or_unknown']['ja-jp'] = "ログアウト / 不明"; +$text['label-status_logged_out_or_unknown']['ko-kr'] = "로그아웃 / 알 수 없음"; + +$text['label-status_on_demand']['en-us'] = "Available (On Demand)"; +$text['label-status_on_demand']['en-gb'] = "Available (On Demand)"; +$text['label-status_on_demand']['ar-eg'] = "متاح (عند الطلب)"; +$text['label-status_on_demand']['de-at'] = "Verfügbar (bei Bedarf)"; +$text['label-status_on_demand']['de-ch'] = "Verfügbar (bei Bedarf)"; +$text['label-status_on_demand']['de-de'] = "Verfügbar (bei Bedarf)"; +$text['label-status_on_demand']['el-gr'] = "Διαθέσιμος (κατ' απαίτηση)"; +$text['label-status_on_demand']['es-cl'] = "Disponible (bajo demanda)"; +$text['label-status_on_demand']['es-mx'] = "Disponible (bajo demanda)"; +$text['label-status_on_demand']['fr-ca'] = "Disponible (à la demande)"; +$text['label-status_on_demand']['fr-fr'] = "Disponible (à la demande)"; +$text['label-status_on_demand']['he-il'] = "זמין (לפי דרישה)"; +$text['label-status_on_demand']['it-it'] = "Disponibile (su richiesta)"; +$text['label-status_on_demand']['ka-ge'] = "ხელმისაწვდომი (მოთხოვნით)"; +$text['label-status_on_demand']['nl-nl'] = "Beschikbaar (op aanvraag)"; +$text['label-status_on_demand']['pl-pl'] = "Dostępny (na żądanie)"; +$text['label-status_on_demand']['pt-br'] = "Disponível (sob demanda)"; +$text['label-status_on_demand']['pt-pt'] = "Disponível (a pedido)"; +$text['label-status_on_demand']['ro-ro'] = "Disponibil (la cerere)"; +$text['label-status_on_demand']['ru-ru'] = "Доступен (по запросу)"; +$text['label-status_on_demand']['sv-se'] = "Tillgänglig (på begäran)"; +$text['label-status_on_demand']['uk-ua'] = "Доступний (на вимогу)"; +$text['label-status_on_demand']['tr-tr'] = "Müsait (isteğe bağlı)"; +$text['label-status_on_demand']['zh-cn'] = "可用(按需)"; +$text['label-status_on_demand']['ja-jp'] = "利用可能(オンデマンド)"; +$text['label-status_on_demand']['ko-kr'] = "사용 가능(온디맨드)"; + +$text['label-refresh_pause']['en-us'] = "Pause"; +$text['label-refresh_pause']['en-gb'] = "Pause"; +$text['label-refresh_pause']['ar-eg'] = "إيقاف مؤقت"; +$text['label-refresh_pause']['de-at'] = "Pause"; +$text['label-refresh_pause']['de-ch'] = "Pause"; +$text['label-refresh_pause']['de-de'] = "Pause"; +$text['label-refresh_pause']['el-gr'] = "Παύση"; +$text['label-refresh_pause']['es-cl'] = "Pausa"; +$text['label-refresh_pause']['es-mx'] = "Pausa"; +$text['label-refresh_pause']['fr-ca'] = "Pause"; +$text['label-refresh_pause']['fr-fr'] = "Pause"; +$text['label-refresh_pause']['he-il'] = "השהיה"; +$text['label-refresh_pause']['it-it'] = "Pausa"; +$text['label-refresh_pause']['ka-ge'] = "პაუზა"; +$text['label-refresh_pause']['nl-nl'] = "Pauze"; +$text['label-refresh_pause']['pl-pl'] = "Pauza"; +$text['label-refresh_pause']['pt-br'] = "Pausa"; +$text['label-refresh_pause']['pt-pt'] = "Pausa"; +$text['label-refresh_pause']['ro-ro'] = "Pauză"; +$text['label-refresh_pause']['ru-ru'] = "Пауза"; +$text['label-refresh_pause']['sv-se'] = "Paus"; +$text['label-refresh_pause']['uk-ua'] = "Пауза"; +$text['label-refresh_pause']['tr-tr'] = "Duraklat"; +$text['label-refresh_pause']['zh-cn'] = "暂停"; +$text['label-refresh_pause']['ja-jp'] = "一時停止"; +$text['label-refresh_pause']['ko-kr'] = "일시 중지"; + +$text['label-name']['en-us'] = "Name"; +$text['label-name']['en-gb'] = "Name"; +$text['label-name']['ar-eg'] = "الاسم"; +$text['label-name']['de-at'] = "Name"; +$text['label-name']['de-ch'] = "Name"; +$text['label-name']['de-de'] = "Name"; +$text['label-name']['el-gr'] = "Όνομα"; +$text['label-name']['es-cl'] = "Nombre"; +$text['label-name']['es-mx'] = "Nombre"; +$text['label-name']['fr-ca'] = "Nom"; +$text['label-name']['fr-fr'] = "Nom"; +$text['label-name']['he-il'] = "שם"; +$text['label-name']['it-it'] = "Nome"; +$text['label-name']['ka-ge'] = "სახელი"; +$text['label-name']['nl-nl'] = "Naam"; +$text['label-name']['pl-pl'] = "Nazwa"; +$text['label-name']['pt-br'] = "Nome"; +$text['label-name']['pt-pt'] = "Nome"; +$text['label-name']['ro-ro'] = "Nume"; +$text['label-name']['ru-ru'] = "Имя"; +$text['label-name']['sv-se'] = "Namn"; +$text['label-name']['uk-ua'] = "Назва"; +$text['label-name']['tr-tr'] = "Ad"; +$text['label-name']['zh-cn'] = "名称"; +$text['label-name']['ja-jp'] = "名前"; +$text['label-name']['ko-kr'] = "이름"; + +$text['label-extension']['en-us'] = "Extension"; +$text['label-extension']['en-gb'] = "Extension"; +$text['label-extension']['ar-eg'] = "الامتداد"; +$text['label-extension']['de-at'] = "Durchwahl"; +$text['label-extension']['de-ch'] = "Durchwahl"; +$text['label-extension']['de-de'] = "Durchwahl"; +$text['label-extension']['el-gr'] = "Επέκταση"; +$text['label-extension']['es-cl'] = "Extensión"; +$text['label-extension']['es-mx'] = "Extensión"; +$text['label-extension']['fr-ca'] = "Extension"; +$text['label-extension']['fr-fr'] = "Extension"; +$text['label-extension']['he-il'] = "שלוחה"; +$text['label-extension']['it-it'] = "Interno"; +$text['label-extension']['ka-ge'] = "გაგრძელება"; +$text['label-extension']['nl-nl'] = "Extensie"; +$text['label-extension']['pl-pl'] = "Numer wewnętrzny"; +$text['label-extension']['pt-br'] = "Ramal"; +$text['label-extension']['pt-pt'] = "Extensão"; +$text['label-extension']['ro-ro'] = "Extensie"; +$text['label-extension']['ru-ru'] = "Внутренний номер"; +$text['label-extension']['sv-se'] = "Anknytning"; +$text['label-extension']['uk-ua'] = "Розширення"; +$text['label-extension']['tr-tr'] = "Dahili"; +$text['label-extension']['zh-cn'] = "分机"; +$text['label-extension']['ja-jp'] = "内線"; +$text['label-extension']['ko-kr'] = "내선"; + +$text['label-participant-pin']['en-us'] = "PIN"; +$text['label-participant-pin']['en-gb'] = "PIN"; +$text['label-participant-pin']['ar-eg'] = "PIN"; +$text['label-participant-pin']['de-at'] = "PIN"; +$text['label-participant-pin']['de-ch'] = "PIN"; +$text['label-participant-pin']['de-de'] = "PIN"; +$text['label-participant-pin']['el-gr'] = "PIN"; +$text['label-participant-pin']['es-cl'] = "PIN"; +$text['label-participant-pin']['es-mx'] = "PIN"; +$text['label-participant-pin']['fr-ca'] = "PIN"; +$text['label-participant-pin']['fr-fr'] = "PIN"; +$text['label-participant-pin']['he-il'] = "PIN"; +$text['label-participant-pin']['it-it'] = "PIN"; +$text['label-participant-pin']['ka-ge'] = "PIN"; +$text['label-participant-pin']['nl-nl'] = "PIN"; +$text['label-participant-pin']['pl-pl'] = "PIN"; +$text['label-participant-pin']['pt-br'] = "PIN"; +$text['label-participant-pin']['pt-pt'] = "PIN"; +$text['label-participant-pin']['ro-ro'] = "PIN"; +$text['label-participant-pin']['ru-ru'] = "PIN"; +$text['label-participant-pin']['sv-se'] = "PIN"; +$text['label-participant-pin']['uk-ua'] = "PIN"; +$text['label-participant-pin']['tr-tr'] = "PIN"; +$text['label-participant-pin']['zh-cn'] = "PIN"; +$text['label-participant-pin']['ja-jp'] = "PIN"; +$text['label-participant-pin']['ko-kr'] = "PIN"; + +$text['label-member-count']['en-us'] = "Members"; +$text['label-member-count']['en-gb'] = "Members"; +$text['label-member-count']['ar-eg'] = "عدد الأعضاء"; +$text['label-member-count']['de-at'] = "Anzahl Teilnehmer"; +$text['label-member-count']['de-ch'] = "Anzahl Teilnehmer"; +$text['label-member-count']['de-de'] = "Anzahl Teilnehmer"; +$text['label-member-count']['el-gr'] = "Πλήθος μελών"; +$text['label-member-count']['es-cl'] = "Conteo de Miembros"; +$text['label-member-count']['es-mx'] = "Conteo de Miembros"; +$text['label-member-count']['fr-ca'] = "Décompte des participants"; +$text['label-member-count']['fr-fr'] = "Décompte des participants"; +$text['label-member-count']['he-il'] = "סופר מספר"; +$text['label-member-count']['it-it'] = "Conto Membri"; +$text['label-member-count']['ka-ge'] = "მონაწილეების რაოდენობა"; +$text['label-member-count']['nl-nl'] = "Aantal deelnemers"; +$text['label-member-count']['pl-pl'] = "Liczba uczestników"; +$text['label-member-count']['pt-br'] = "Total de membros"; +$text['label-member-count']['pt-pt'] = "Total de membros"; +$text['label-member-count']['ro-ro'] = "Număr de membri"; +$text['label-member-count']['ru-ru'] = "Количество участников"; +$text['label-member-count']['sv-se'] = "Antal Deltagare"; +$text['label-member-count']['uk-ua'] = "Кількість учасиків"; +$text['label-member-count']['tr-tr'] = "Üye Sayısı"; +$text['label-member-count']['zh-cn'] = "会员人数"; +$text['label-member-count']['ja-jp'] = "メンバー数"; +$text['label-member-count']['ko-kr'] = "회원 수"; + +$text['button-view']['en-us'] = "View"; +$text['button-view']['en-gb'] = "View"; +$text['button-view']['ar-eg'] = "رأي"; +$text['button-view']['de-at'] = "Anzeigen"; +$text['button-view']['de-ch'] = "Anzeigen"; +$text['button-view']['de-de'] = "Anzeigen"; +$text['button-view']['el-gr'] = "Θέα"; +$text['button-view']['es-cl'] = "Ver"; +$text['button-view']['es-mx'] = "Ver"; +$text['button-view']['fr-ca'] = "Vue"; +$text['button-view']['fr-fr'] = "Vue"; +$text['button-view']['he-il'] = "תצוגה"; +$text['button-view']['it-it'] = "Vista"; +$text['button-view']['ka-ge'] = "ხედი"; +$text['button-view']['nl-nl'] = "Tonen"; +$text['button-view']['pl-pl'] = "Podgląd"; +$text['button-view']['pt-br'] = "Visualização"; +$text['button-view']['pt-pt'] = "Visualização"; +$text['button-view']['ro-ro'] = "Vedere"; +$text['button-view']['ru-ru'] = "Просмотр"; +$text['button-view']['sv-se'] = "Visa"; +$text['button-view']['uk-ua'] = "Переглянути"; +$text['button-view']['tr-tr'] = "Görüş"; +$text['button-view']['zh-cn'] = "看法"; +$text['button-view']['ja-jp'] = "意見"; +$text['button-view']['ko-kr'] = "보다"; + + +$text['label-no_conferences']['en-us'] = "No active conferences"; +$text['label-no_conferences']['en-gb'] = "No active conferences"; +$text['label-no_conferences']['ar-eg'] = "لا توجد مؤتمرات نشطة"; +$text['label-no_conferences']['de-at'] = "Keine aktiven Konferenzen"; +$text['label-no_conferences']['de-ch'] = "Keine aktiven Konferenzen"; +$text['label-no_conferences']['de-de'] = "Keine aktiven Konferenzen"; +$text['label-no_conferences']['el-gr'] = "Δεν υπάρχουν ενεργά συνέδρια"; +$text['label-no_conferences']['es-cl'] = "Sin conferencias activas"; +$text['label-no_conferences']['es-mx'] = "Sin conferencias activas"; +$text['label-no_conferences']['fr-ca'] = "Pas de conférences actives"; +$text['label-no_conferences']['fr-fr'] = "Pas de conférences actives"; +$text['label-no_conferences']['he-il'] = "אין כנסים פעילים"; +$text['label-no_conferences']['it-it'] = "Nessuna conferenza attiva"; +$text['label-no_conferences']['ka-ge'] = "აქტიური კონფერენციები არ არის"; +$text['label-no_conferences']['nl-nl'] = "Geen actieve conferenties"; +$text['label-no_conferences']['pl-pl'] = "Brak aktywnych konferencji"; +$text['label-no_conferences']['pt-br'] = "Sem conferências ativas"; +$text['label-no_conferences']['pt-pt'] = "Sem conferências activas"; +$text['label-no_conferences']['ro-ro'] = "Nicio conferință activă"; +$text['label-no_conferences']['ru-ru'] = "Нет активных конференций"; +$text['label-no_conferences']['sv-se'] = "Inga aktiva konferenser"; +$text['label-no_conferences']['uk-ua'] = "Немає активних конференцій"; +$text['label-no_conferences']['tr-tr'] = "Etkin konferans yok"; +$text['label-no_conferences']['zh-cn'] = "无活动会议"; +$text['label-no_conferences']['ja-jp'] = "アクティブな会議がない"; +$text['label-no_conferences']['ko-kr'] = "활성 회의 없음"; + +$text['label-queue']['en-us'] = "Queue"; +$text['label-queue']['en-gb'] = "Queue"; +$text['label-queue']['ar-eg'] = "طابور"; +$text['label-queue']['de-at'] = "Warteschlange"; +$text['label-queue']['de-ch'] = "Warteschlange"; +$text['label-queue']['de-de'] = "Warteschlange"; +$text['label-queue']['el-gr'] = "Ουρά"; +$text['label-queue']['es-cl'] = "Cola"; +$text['label-queue']['es-mx'] = "Cola"; +$text['label-queue']['fr-ca'] = "File"; +$text['label-queue']['fr-fr'] = "File"; +$text['label-queue']['he-il'] = "תור"; +$text['label-queue']['it-it'] = "Code"; +$text['label-queue']['ka-ge'] = "რიგი"; +$text['label-queue']['nl-nl'] = "Wachtrij"; +$text['label-queue']['pl-pl'] = "Kolejka"; +$text['label-queue']['pt-br'] = "Fila"; +$text['label-queue']['pt-pt'] = "Fila"; +$text['label-queue']['ro-ro'] = "Coadă"; +$text['label-queue']['ru-ru'] = "Очередь"; +$text['label-queue']['sv-se'] = "Grupp"; +$text['label-queue']['uk-ua'] = "Черга"; +$text['label-queue']['tr-tr'] = "Queue"; +$text['label-queue']['zh-cn'] = "队列"; +$text['label-queue']['ja-jp'] = "列"; +$text['label-queue']['ko-kr'] = "대기줄"; + +$text['label-agent']['en-us'] = "Agent"; +$text['label-agent']['en-gb'] = "Agent"; +$text['label-agent']['ar-eg'] = "عامل"; +$text['label-agent']['de-at'] = "Agent"; +$text['label-agent']['de-ch'] = "Agent"; +$text['label-agent']['de-de'] = "Agent"; +$text['label-agent']['el-gr'] = "Μέσο"; +$text['label-agent']['es-cl'] = "Agente"; +$text['label-agent']['es-mx'] = "Agente"; +$text['label-agent']['fr-ca'] = "Agent"; +$text['label-agent']['fr-fr'] = "Agent"; +$text['label-agent']['he-il'] = "סוֹכֵן"; +$text['label-agent']['it-it'] = "Agente"; +$text['label-agent']['ka-ge'] = "ოპერატორი"; +$text['label-agent']['nl-nl'] = "Agent"; +$text['label-agent']['pl-pl'] = "Agent"; +$text['label-agent']['pt-br'] = "Agente"; +$text['label-agent']['pt-pt'] = "Agente"; +$text['label-agent']['ro-ro'] = "Agent"; +$text['label-agent']['ru-ru'] = "Оператор"; +$text['label-agent']['sv-se'] = "Agent"; +$text['label-agent']['uk-ua'] = "Оператор"; +$text['label-agent']['tr-tr'] = "Agent"; +$text['label-agent']['zh-cn'] = "代理人"; +$text['label-agent']['ja-jp'] = "エージェント"; +$text['label-agent']['ko-kr'] = "대리인"; + +$text['label-agent_status']['en-us'] = "Status"; +$text['label-agent_status']['en-gb'] = "Status"; +$text['label-agent_status']['ar-eg'] = "حالة"; +$text['label-agent_status']['de-at'] = "Status"; +$text['label-agent_status']['de-ch'] = "Status"; +$text['label-agent_status']['de-de'] = "Status"; +$text['label-agent_status']['el-gr'] = "Κατάσταση"; +$text['label-agent_status']['es-cl'] = "Estado"; +$text['label-agent_status']['es-mx'] = "Estado"; +$text['label-agent_status']['fr-ca'] = "Etat"; +$text['label-agent_status']['fr-fr'] = "Etat"; +$text['label-agent_status']['he-il'] = "סטטוס"; +$text['label-agent_status']['it-it'] = "Stato"; +$text['label-agent_status']['ka-ge'] = "სტატუსი"; +$text['label-agent_status']['nl-nl'] = "Status"; +$text['label-agent_status']['pl-pl'] = "Status"; +$text['label-agent_status']['pt-br'] = "Estado"; +$text['label-agent_status']['pt-pt'] = "Estado"; +$text['label-agent_status']['ro-ro'] = "stare"; +$text['label-agent_status']['ru-ru'] = "Статус"; +$text['label-agent_status']['sv-se'] = "Status"; +$text['label-agent_status']['uk-ua'] = "Статус"; +$text['label-agent_status']['tr-tr'] = "Durum"; +$text['label-agent_status']['zh-cn'] = "状态"; +$text['label-agent_status']['ja-jp'] = "ステータス"; +$text['label-agent_status']['ko-kr'] = "상태"; + +$text['label-agent_state']['en-us'] = "State"; +$text['label-agent_state']['en-gb'] = "State"; +$text['label-agent_state']['ar-eg'] = "حالة"; +$text['label-agent_state']['de-at'] = "Status"; +$text['label-agent_state']['de-ch'] = "Status"; +$text['label-agent_state']['de-de'] = "Status"; +$text['label-agent_state']['el-gr'] = "Κατάσταση"; +$text['label-agent_state']['es-cl'] = "Estado"; +$text['label-agent_state']['es-mx'] = "Estado"; +$text['label-agent_state']['fr-ca'] = "État"; +$text['label-agent_state']['fr-fr'] = "État"; +$text['label-agent_state']['he-il'] = "מצב"; +$text['label-agent_state']['it-it'] = "Stato"; +$text['label-agent_state']['ka-ge'] = "მდგომარეობა"; +$text['label-agent_state']['nl-nl'] = "Status"; +$text['label-agent_state']['pl-pl'] = "Stan"; +$text['label-agent_state']['pt-br'] = "Estado"; +$text['label-agent_state']['pt-pt'] = "Estado"; +$text['label-agent_state']['ro-ro'] = "Stare"; +$text['label-agent_state']['ru-ru'] = "Состояние"; +$text['label-agent_state']['sv-se'] = "Status"; +$text['label-agent_state']['uk-ua'] = "Стан"; +$text['label-agent_state']['tr-tr'] = "Durum"; +$text['label-agent_state']['zh-cn'] = "状态"; +$text['label-agent_state']['ja-jp'] = "状態"; +$text['label-agent_state']['ko-kr'] = "상태"; + +$text['label-calls_answered']['en-us'] = "Calls Answered"; +$text['label-calls_answered']['en-gb'] = "Calls Answered"; +$text['label-calls_answered']['ar-eg'] = "المكالمات المجابة"; +$text['label-calls_answered']['de-at'] = "Angenommene Anrufe"; +$text['label-calls_answered']['de-ch'] = "Angenommene Anrufe"; +$text['label-calls_answered']['de-de'] = "Angenommene Anrufe"; +$text['label-calls_answered']['el-gr'] = "Κλήσεις που απάντησαν"; +$text['label-calls_answered']['es-cl'] = "Llamadas Respondidas"; +$text['label-calls_answered']['es-mx'] = "Llamadas Respondidas"; +$text['label-calls_answered']['fr-ca'] = "Appels Répondus"; +$text['label-calls_answered']['fr-fr'] = "Appels Répondus"; +$text['label-calls_answered']['he-il'] = "שיחות שנענו"; +$text['label-calls_answered']['it-it'] = "Chiamate Risposte"; +$text['label-calls_answered']['ka-ge'] = "პასუხი გაცემული ზარები"; +$text['label-calls_answered']['nl-nl'] = "Beantwoorde Oproepen"; +$text['label-calls_answered']['pl-pl'] = "Odebranych Połączeń"; +$text['label-calls_answered']['pt-br'] = "Chamadas Atendidas"; +$text['label-calls_answered']['pt-pt'] = "Chamadas Atendidas"; +$text['label-calls_answered']['ro-ro'] = "Apeluri Răspunse"; +$text['label-calls_answered']['ru-ru'] = "Ответленные Вызовы"; +$text['label-calls_answered']['sv-se'] = "Besvarade Samtal"; +$text['label-calls_answered']['uk-ua'] = "Відповіді Дзвінків"; +$text['label-calls_answered']['tr-tr'] = "Yanıtlanan Aramalar"; +$text['label-calls_answered']['zh-cn'] = "应答的通话"; +$text['label-calls_answered']['ja-jp'] = "応答された通話"; +$text['label-calls_answered']['ko-kr'] = "응답된 통화"; + +$text['label-talk_time']['en-us'] = "Talk Time"; +$text['label-talk_time']['en-gb'] = "Talk Time"; +$text['label-talk_time']['ar-eg'] = "وقت المحادثة"; +$text['label-talk_time']['de-at'] = "Sprechzeit"; +$text['label-talk_time']['de-ch'] = "Sprechzeit"; +$text['label-talk_time']['de-de'] = "Sprechzeit"; +$text['label-talk_time']['el-gr'] = "Χρόνος Συνομιλίας"; +$text['label-talk_time']['es-cl'] = "Tiempo de Conversación"; +$text['label-talk_time']['es-mx'] = "Tiempo de Conversación"; +$text['label-talk_time']['fr-ca'] = "Temps de Conversation"; +$text['label-talk_time']['fr-fr'] = "Temps de Conversation"; +$text['label-talk_time']['he-il'] = "זמן שיחה"; +$text['label-talk_time']['it-it'] = "Tempo di Conversazione"; +$text['label-talk_time']['ka-ge'] = "საუბრის დრო"; +$text['label-talk_time']['nl-nl'] = "Spreektijd"; +$text['label-talk_time']['pl-pl'] = "Czas Rozmowy"; +$text['label-talk_time']['pt-br'] = "Tempo de Conversa"; +$text['label-talk_time']['pt-pt'] = "Tempo de Conversação"; +$text['label-talk_time']['ro-ro'] = "Timp de Vorbire"; +$text['label-talk_time']['ru-ru'] = "Время Разговора"; +$text['label-talk_time']['sv-se'] = "Samtalstid"; +$text['label-talk_time']['uk-ua'] = "Час Розмови"; +$text['label-talk_time']['tr-tr'] = "Konuşma Süresi"; +$text['label-talk_time']['zh-cn'] = "通话时长"; +$text['label-talk_time']['ja-jp'] = "通話時間"; +$text['label-talk_time']['ko-kr'] = "통화 시간"; + +$text['label-last_status_change']['en-us'] = "Last Status Change"; +$text['label-last_status_change']['en-gb'] = "Last Status Change"; +$text['label-last_status_change']['ar-eg'] = "آخر تغيير الحالة"; +$text['label-last_status_change']['de-at'] = "Letzter Statuswechsel"; +$text['label-last_status_change']['de-ch'] = "Letzter Statuswechsel"; +$text['label-last_status_change']['de-de'] = "Letzter Statuswechsel"; +$text['label-last_status_change']['el-gr'] = "Τελευταία Αλλαγή Κατάστασης"; +$text['label-last_status_change']['es-cl'] = "Ultimo Cambio de Estado"; +$text['label-last_status_change']['es-mx'] = "Ultimo Cambio de Estado"; +$text['label-last_status_change']['fr-ca'] = "Dernier Changement d'État"; +$text['label-last_status_change']['fr-fr'] = "Dernier Changement d'État"; +$text['label-last_status_change']['he-il'] = "שינוי המצב האחרון"; +$text['label-last_status_change']['it-it'] = "Ultimo Cambiamento di Stato"; +$text['label-last_status_change']['ka-ge'] = "ბოლო სტატუსის ცვლილება"; +$text['label-last_status_change']['nl-nl'] = "Laatste Statuswijziging"; +$text['label-last_status_change']['pl-pl'] = "Ostatnia Zmiana Stanu"; +$text['label-last_status_change']['pt-br'] = "Última Mudança de Estado"; +$text['label-last_status_change']['pt-pt'] = "Última Mudança de Estado"; +$text['label-last_status_change']['ro-ro'] = "Ultima Schimbare de Stare"; +$text['label-last_status_change']['ru-ru'] = "Последнее Изменение Статуса"; +$text['label-last_status_change']['sv-se'] = "Senaste Statusändring"; +$text['label-last_status_change']['uk-ua'] = "Остання Зміна Статусу"; +$text['label-last_status_change']['tr-tr'] = "Son Durum Değişikliği"; +$text['label-last_status_change']['zh-cn'] = "最后状态变化"; +$text['label-last_status_change']['ja-jp'] = "最後のステータス変更"; +$text['label-last_status_change']['ko-kr'] = "마지막 상태 변경"; + +$text['label-no_agents']['en-us'] = "No agents found"; +$text['label-no_agents']['en-gb'] = "No agents found"; +$text['label-no_agents']['ar-eg'] = "لم يتم العثور على وكلاء"; +$text['label-no_agents']['de-at'] = "Keine Agenten gefunden"; +$text['label-no_agents']['de-ch'] = "Keine Agenten gefunden"; +$text['label-no_agents']['de-de'] = "Keine Agenten gefunden"; +$text['label-no_agents']['el-gr'] = "Δεν βρέθηκαν πράκτορες"; +$text['label-no_agents']['es-cl'] = "No se encontraron agentes"; +$text['label-no_agents']['es-mx'] = "No se encontraron agentes"; +$text['label-no_agents']['fr-ca'] = "Aucun agent trouvé"; +$text['label-no_agents']['fr-fr'] = "Aucun agent trouvé"; +$text['label-no_agents']['he-il'] = "לא נמצאו סוכנים"; +$text['label-no_agents']['it-it'] = "Nessun agente trovato"; +$text['label-no_agents']['ka-ge'] = "მომხმარებელი ვერ მოიძებნა"; +$text['label-no_agents']['nl-nl'] = "Geen agenten gevonden"; +$text['label-no_agents']['pl-pl'] = "Nie znaleziono agentów"; +$text['label-no_agents']['pt-br'] = "Nenhum agente encontrado"; +$text['label-no_agents']['pt-pt'] = "Nenhum agente encontrado"; +$text['label-no_agents']['ro-ro'] = "Nu s-au găsit agenți"; +$text['label-no_agents']['ru-ru'] = "Агенты не найдены"; +$text['label-no_agents']['sv-se'] = "Inga agenter hittades"; +$text['label-no_agents']['uk-ua'] = "Агенти не знайдені"; +$text['label-no_agents']['tr-tr'] = "Ajan bulunamadı"; +$text['label-no_agents']['zh-cn'] = "未找到代理"; +$text['label-no_agents']['ja-jp'] = "エージェントが見つかりません"; +$text['label-no_agents']['ko-kr'] = "에이전트를 찾을 수 없습니다"; + +$text['label-supervisor_view']['en-us'] = "Supervisor View"; +$text['label-supervisor_view']['en-gb'] = "Supervisor View"; +$text['label-supervisor_view']['ar-eg'] = "عرض المشرف"; +$text['label-supervisor_view']['de-at'] = "Supervisor-Ansicht"; +$text['label-supervisor_view']['de-ch'] = "Supervisor-Ansicht"; +$text['label-supervisor_view']['de-de'] = "Supervisor-Ansicht"; +$text['label-supervisor_view']['el-gr'] = "Προβολή Επιστάτη"; +$text['label-supervisor_view']['es-cl'] = "Vista del Supervisor"; +$text['label-supervisor_view']['es-mx'] = "Vista del Supervisor"; +$text['label-supervisor_view']['fr-ca'] = "Affichage du Superviseur"; +$text['label-supervisor_view']['fr-fr'] = "Affichage du Superviseur"; +$text['label-supervisor_view']['he-il'] = "תצוגת מפקח"; +$text['label-supervisor_view']['it-it'] = "Vista Supervisore"; +$text['label-supervisor_view']['ka-ge'] = "ხელმძღვანელის ხედი"; +$text['label-supervisor_view']['nl-nl'] = "Supervisorweergave"; +$text['label-supervisor_view']['pl-pl'] = "Widok Nadzorcy"; +$text['label-supervisor_view']['pt-br'] = "Visão do Supervisor"; +$text['label-supervisor_view']['pt-pt'] = "Visão do Supervisor"; +$text['label-supervisor_view']['ro-ro'] = "Vizualizare Supervizor"; +$text['label-supervisor_view']['ru-ru'] = "Вид Супервизора"; +$text['label-supervisor_view']['sv-se'] = "Supervisorvyn"; +$text['label-supervisor_view']['uk-ua'] = "Перегляд Супервізора"; +$text['label-supervisor_view']['tr-tr'] = "Denetmen Görünümü"; +$text['label-supervisor_view']['zh-cn'] = "监督视图"; +$text['label-supervisor_view']['ja-jp'] = "スーパーバイザービュー"; +$text['label-supervisor_view']['ko-kr'] = "감독자 보기"; + +$text['label-agent_view']['en-us'] = "Agent View"; +$text['label-agent_view']['en-gb'] = "Agent View"; +$text['label-agent_view']['ar-eg'] = "عرض الوكيل"; +$text['label-agent_view']['de-at'] = "Agentenansicht"; +$text['label-agent_view']['de-ch'] = "Agentenansicht"; +$text['label-agent_view']['de-de'] = "Agentenansicht"; +$text['label-agent_view']['el-gr'] = "Προβολή Αντιπροσώπου"; +$text['label-agent_view']['es-cl'] = "Vista del Agente"; +$text['label-agent_view']['es-mx'] = "Vista del Agente"; +$text['label-agent_view']['fr-ca'] = "Vue de l'Agent"; +$text['label-agent_view']['fr-fr'] = "Vue de l'Agent"; +$text['label-agent_view']['he-il'] = "תצוגת סוכן"; +$text['label-agent_view']['it-it'] = "Vista Agente"; +$text['label-agent_view']['ka-ge'] = "Agent View"; +$text['label-agent_view']['nl-nl'] = "Agentweergave"; +$text['label-agent_view']['pl-pl'] = "Widok Agenta"; +$text['label-agent_view']['pt-br'] = "Vista do Agente"; +$text['label-agent_view']['pt-pt'] = "Vista do Agente"; +$text['label-agent_view']['ro-ro'] = "Vizualizare Agent"; +$text['label-agent_view']['ru-ru'] = "Вид Агента"; +$text['label-agent_view']['sv-se'] = "Agentvy"; +$text['label-agent_view']['uk-ua'] = "Перегляд Агента"; +$text['label-agent_view']['tr-tr'] = "Temsilci Görünümü"; +$text['label-agent_view']['zh-cn'] = "代理视图"; +$text['label-agent_view']['ja-jp'] = "エージェントビュー"; +$text['label-agent_view']['ko-kr'] = "에이전트 보기"; + +$text['label-queue_count']['en-us'] = "Callers in Queue"; +$text['label-queue_count']['en-gb'] = "Callers in Queue"; +$text['label-queue_count']['ar-eg'] = "المتصلون في الطابور"; +$text['label-queue_count']['de-at'] = "Anrufe in Warteschlange"; +$text['label-queue_count']['de-ch'] = "Anrufe in Warteschlange"; +$text['label-queue_count']['de-de'] = "Anrufe in Warteschlange"; +$text['label-queue_count']['el-gr'] = "Καλούντες σε ουρά"; +$text['label-queue_count']['es-cl'] = "Llamantes en Cola"; +$text['label-queue_count']['es-mx'] = "Llamantes en Cola"; +$text['label-queue_count']['fr-ca'] = "Appelants en file"; +$text['label-queue_count']['fr-fr'] = "Appelants en file"; +$text['label-queue_count']['he-il'] = "מתקשרים בתור"; +$text['label-queue_count']['it-it'] = "Chiamanti in coda"; +$text['label-queue_count']['ka-ge'] = "მომხმარებელი რიგში"; +$text['label-queue_count']['nl-nl'] = "Bellers in Wachtrij"; +$text['label-queue_count']['pl-pl'] = "Dzwoniący w kolejce"; +$text['label-queue_count']['pt-br'] = "Chamadores na Fila"; +$text['label-queue_count']['pt-pt'] = "Chamadores na Fila"; +$text['label-queue_count']['ro-ro'] = "Apelanti în Coadă"; +$text['label-queue_count']['ru-ru'] = "Звонящие в очереди"; +$text['label-queue_count']['sv-se'] = "Anropare i Kö"; +$text['label-queue_count']['uk-ua'] = "Абоненти у черзі"; +$text['label-queue_count']['tr-tr'] = "Sıradaki Arayanlar"; +$text['label-queue_count']['zh-cn'] = "队列中的来电"; +$text['label-queue_count']['ja-jp'] = "キュー内の通話者"; +$text['label-queue_count']['ko-kr'] = "대기열의 발신자"; + + +$text['label-all_queues']['en-us'] = "All Queues"; +$text['label-all_queues']['en-gb'] = "All Queues"; +$text['label-all_queues']['ar-eg'] = "جميع الطوابير"; +$text['label-all_queues']['de-at'] = "Alle Warteschlangen"; +$text['label-all_queues']['de-ch'] = "Alle Warteschlangen"; +$text['label-all_queues']['de-de'] = "Alle Warteschlangen"; +$text['label-all_queues']['el-gr'] = "Όλες οι ουρές"; +$text['label-all_queues']['es-cl'] = "Todas las colas"; +$text['label-all_queues']['es-mx'] = "Todas las colas"; +$text['label-all_queues']['fr-ca'] = "Toutes les files"; +$text['label-all_queues']['fr-fr'] = "Toutes les files"; +$text['label-all_queues']['he-il'] = "כל התורים"; +$text['label-all_queues']['it-it'] = "Tutti i codici"; +$text['label-all_queues']['ka-ge'] = "ყველა რიგი"; +$text['label-all_queues']['nl-nl'] = "Alle wachtrijen"; +$text['label-all_queues']['pl-pl'] = "Wszystkie kolejki"; +$text['label-all_queues']['pt-br'] = "Todas as filas"; +$text['label-all_queues']['pt-pt'] = "Todas as filas"; +$text['label-all_queues']['ro-ro'] = "Toate cozile"; +$text['label-all_queues']['ru-ru'] = "Все очереди"; +$text['label-all_queues']['sv-se'] = "Alla köer"; +$text['label-all_queues']['uk-ua'] = "Всі черги"; +$text['label-all_queues']['tr-tr'] = "Tüm Sıralar"; +$text['label-all_queues']['zh-cn'] = "所有队列"; +$text['label-all_queues']['ja-jp'] = "すべてのキュー"; +$text['label-all_queues']['ko-kr'] = "모든 대기열"; + +$text['label-my_stats']['en-us'] = "My Stats"; +$text['label-my_stats']['en-gb'] = "My Stats"; +$text['label-my_stats']['ar-eg'] = "إحصائياتي"; +$text['label-my_stats']['de-at'] = "Meine Statistiken"; +$text['label-my_stats']['de-ch'] = "Meine Statistiken"; +$text['label-my_stats']['de-de'] = "Meine Statistiken"; +$text['label-my_stats']['el-gr'] = "Οι στατιστικές μου"; +$text['label-my_stats']['es-cl'] = "Mis estadísticas"; +$text['label-my_stats']['es-mx'] = "Mis estadísticas"; +$text['label-my_stats']['fr-ca'] = "Mes statistiques"; +$text['label-my_stats']['fr-fr'] = "Mes statistiques"; +$text['label-my_stats']['he-il'] = "הנתונים שלי"; +$text['label-my_stats']['it-it'] = "Le mie statistiche"; +$text['label-my_stats']['ka-ge'] = "ჩემი სტატისტიკა"; +$text['label-my_stats']['nl-nl'] = "Mijn statistieken"; +$text['label-my_stats']['pl-pl'] = "Moje statystyki"; +$text['label-my_stats']['pt-br'] = "Minhas estatísticas"; +$text['label-my_stats']['pt-pt'] = "Minhas estatísticas"; +$text['label-my_stats']['ro-ro'] = "Statisticile mele"; +$text['label-my_stats']['ru-ru'] = "Мои статистики"; +$text['label-my_stats']['sv-se'] = "Mina statistik"; +$text['label-my_stats']['uk-ua'] = "Моя статистика"; +$text['label-my_stats']['tr-tr'] = "Benim istatistiklerim"; +$text['label-my_stats']['zh-cn'] = "我的统计"; +$text['label-my_stats']['ja-jp'] = "私の統計"; +$text['label-my_stats']['ko-kr'] = "내 통계"; + +$text['agent_status-available']['en-us'] = "Available"; +$text['agent_status-available']['en-gb'] = "Available"; +$text['agent_status-available']['ar-eg'] = "متوفر"; +$text['agent_status-available']['de-at'] = "Verfügbar"; +$text['agent_status-available']['de-ch'] = "Verfügbar"; +$text['agent_status-available']['de-de'] = "Verfügbar"; +$text['agent_status-available']['el-gr'] = "Διαθέσιμο"; +$text['agent_status-available']['es-cl'] = "Disponible"; +$text['agent_status-available']['es-mx'] = "Disponible"; +$text['agent_status-available']['fr-ca'] = "Disponible"; +$text['agent_status-available']['fr-fr'] = "Disponible"; +$text['agent_status-available']['he-il'] = "זמין"; +$text['agent_status-available']['it-it'] = "Disponibile"; +$text['agent_status-available']['ka-ge'] = "ხელმანია"; +$text['agent_status-available']['nl-nl'] = "Beschikbaar"; +$text['agent_status-available']['pl-pl'] = "Dostępny"; +$text['agent_status-available']['pt-br'] = "Disponível"; +$text['agent_status-available']['pt-pt'] = "Disponível"; +$text['agent_status-available']['ro-ro'] = "Disponibil"; +$text['agent_status-available']['ru-ru'] = "Доступно"; +$text['agent_status-available']['sv-se'] = "Tillgänglig"; +$text['agent_status-available']['uk-ua'] = "Доступний"; +$text['agent_status-available']['tr-tr'] = "Kullanılabilir"; +$text['agent_status-available']['zh-cn'] = "可用"; +$text['agent_status-available']['ja-jp'] = "利用可能"; +$text['agent_status-available']['ko-kr'] = "사용 가능"; + +$text['agent_status-available_on_demand']['en-us'] = "Available (On Demand)"; +$text['agent_status-available_on_demand']['en-gb'] = "Available (On Demand)"; +$text['agent_status-available_on_demand']['ar-eg'] = "متوفر (عند الطلب)"; +$text['agent_status-available_on_demand']['de-at'] = "Verfügbar (auf Abruf)"; +$text['agent_status-available_on_demand']['de-ch'] = "Verfügbar (auf Abruf)"; +$text['agent_status-available_on_demand']['de-de'] = "Verfügbar (auf Abruf)"; +$text['agent_status-available_on_demand']['el-gr'] = "Διαθέσιμο (κατά παραγγελία)"; +$text['agent_status-available_on_demand']['es-cl'] = "Disponible (a demanda)"; +$text['agent_status-available_on_demand']['es-mx'] = "Disponible (a demanda)"; +$text['agent_status-available_on_demand']['fr-ca'] = "Disponible (à la demande)"; +$text['agent_status-available_on_demand']['fr-fr'] = "Disponible (à la demande)"; +$text['agent_status-available_on_demand']['he-il'] = "זמין (על דרישה)"; +$text['agent_status-available_on_demand']['it-it'] = "Disponibile (su richiesta)"; +$text['agent_status-available_on_demand']['ka-ge'] = "ხელმანია (მოწვევის შემთხვევაში)"; +$text['agent_status-available_on_demand']['nl-nl'] = "Beschikbaar (op aanvraag)"; +$text['agent_status-available_on_demand']['pl-pl'] = "Dostępny (na żądanie)"; +$text['agent_status-available_on_demand']['pt-br'] = "Disponível (sob demanda)"; +$text['agent_status-available_on_demand']['pt-pt'] = "Disponível (sob demanda)"; +$text['agent_status-available_on_demand']['ro-ro'] = "Disponibil (la cerere)"; +$text['agent_status-available_on_demand']['ru-ru'] = "Доступно (по запросу)"; +$text['agent_status-available_on_demand']['sv-se'] = "Tillgänglig (på begäran)"; +$text['agent_status-available_on_demand']['uk-ua'] = "Доступний (за запитом)"; +$text['agent_status-available_on_demand']['tr-tr'] = "Kullanılabilir (talep üzerine)"; +$text['agent_status-available_on_demand']['zh-cn'] = "可用(按需)"; +$text['agent_status-available_on_demand']['ja-jp'] = "利用可能(オンデマンド)"; +$text['agent_status-available_on_demand']['ko-kr'] = "사용 가능(온디맨드)"; + +$text['agent_status-on_break']['en-us'] = "On Break"; +$text['agent_status-on_break']['en-gb'] = "On Break"; +$text['agent_status-on_break']['ar-eg'] = "في الإسترخاء"; +$text['agent_status-on_break']['de-at'] = "Pausa"; +$text['agent_status-on_break']['de-ch'] = "Pause"; +$text['agent_status-on_break']['de-de'] = "Pause"; +$text['agent_status-on_break']['el-gr'] = "Παύση"; +$text['agent_status-on_break']['es-cl'] = "En pausa"; +$text['agent_status-on_break']['es-mx'] = "En pausa"; +$text['agent_status-on_break']['fr-ca'] = "Pause"; +$text['agent_status-on_break']['fr-fr'] = "Pause"; +$text['agent_status-on_break']['he-il'] = "במנוחה"; +$text['agent_status-on_break']['it-it'] = "In pausa"; +$text['agent_status-on_break']['ka-ge'] = "კავშირება პაუზაში"; +$text['agent_status-on_break']['nl-nl'] = "Pauze"; +$text['agent_status-on_break']['pl-pl'] = "Na przerwie"; +$text['agent_status-on_break']['pt-br'] = "Em pausa"; +$text['agent_status-on_break']['pt-pt'] = "Em pausa"; +$text['agent_status-on_break']['ro-ro'] = "În pauză"; +$text['agent_status-on_break']['ru-ru'] = "Перерыв"; +$text['agent_status-on_break']['sv-se'] = "Paus"; +$text['agent_status-on_break']['uk-ua'] = "В паузі"; +$text['agent_status-on_break']['tr-tr'] = "Mola"; +$text['agent_status-on_break']['zh-cn'] = "休息中"; +$text['agent_status-on_break']['ja-jp'] = "休憩中"; +$text['agent_status-on_break']['ko-kr'] = "쉬는 시간"; + +$text['agent_status-do_not_disturb']['en-us'] = "Do Not Disturb"; +$text['agent_status-do_not_disturb']['en-gb'] = "Do Not Disturb"; +$text['agent_status-do_not_disturb']['ar-eg'] = "لا تغضب"; +$text['agent_status-do_not_disturb']['de-at'] = "Nicht stören"; +$text['agent_status-do_not_disturb']['de-ch'] = "Nicht stören"; +$text['agent_status-do_not_disturb']['de-de'] = "Nicht stören"; +$text['agent_status-do_not_disturb']['el-gr'] = "Μην ενοχλείτε"; +$text['agent_status-do_not_disturb']['es-cl'] = "No te molestes"; +$text['agent_status-do_not_disturb']['es-mx'] = "No te molestes"; +$text['agent_status-do_not_disturb']['fr-ca'] = "Ne pas déranger"; +$text['agent_status-do_not_disturb']['fr-fr'] = "Ne pas déranger"; +$text['agent_status-do_not_disturb']['he-il'] = "אל תתבלבל"; +$text['agent_status-do_not_disturb']['it-it'] = "Non disturbare"; +$text['agent_status-do_not_disturb']['ka-ge'] = "არ შემაწუხოთ(DND)"; +$text['agent_status-do_not_disturb']['nl-nl'] = "Niet storen"; +$text['agent_status-do_not_disturb']['pl-pl'] = "Nie przeszkadzać"; +$text['agent_status-do_not_disturb']['pt-br'] = "Não Perturbe"; +$text['agent_status-do_not_disturb']['pt-pt'] = "Não perturbar"; +$text['agent_status-do_not_disturb']['ro-ro'] = "Nu deranjați"; +$text['agent_status-do_not_disturb']['ru-ru'] = "Не беспокоить"; +$text['agent_status-do_not_disturb']['sv-se'] = "Stör ej"; +$text['agent_status-do_not_disturb']['uk-ua'] = "Не турбувати"; +$text['agent_status-do_not_disturb']['tr-tr'] = "Rahatsız Etmeyin"; +$text['agent_status-do_not_disturb']['zh-cn'] = "请勿打扰"; +$text['agent_status-do_not_disturb']['ja-jp'] = "邪魔しないでください"; +$text['agent_status-do_not_disturb']['ko-kr'] = "방해하지 마"; + +$text['agent_status-logged_out']['en-us'] = "Logged Out"; +$text['agent_status-logged_out']['en-gb'] = "Logged Out"; +$text['agent_status-logged_out']['ar-eg'] = "تسجيل خروج"; +$text['agent_status-logged_out']['de-at'] = "Abgemeldet"; +$text['agent_status-logged_out']['de-ch'] = "Abgemeldet"; +$text['agent_status-logged_out']['de-de'] = "Abgemeldet"; +$text['agent_status-logged_out']['el-gr'] = "Αποσυνδεδεμένος"; +$text['agent_status-logged_out']['es-cl'] = "Desconectado"; +$text['agent_status-logged_out']['es-mx'] = "Desconectado"; +$text['agent_status-logged_out']['fr-ca'] = "Déconnecté"; +$text['agent_status-logged_out']['fr-fr'] = "Déconnecté"; +$text['agent_status-logged_out']['he-il'] = "התנתק"; +$text['agent_status-logged_out']['it-it'] = "Disconnesso"; +$text['agent_status-logged_out']['ka-ge'] = "გამოსვლილი"; +$text['agent_status-logged_out']['nl-nl'] = "Afgemeld"; +$text['agent_status-logged_out']['pl-pl'] = "Wylogowany"; +$text['agent_status-logged_out']['pt-br'] = "Desconectado"; +$text['agent_status-logged_out']['pt-pt'] = "Desconectado"; +$text['agent_status-logged_out']['ro-ro'] = "Deconectat"; +$text['agent_status-logged_out']['ru-ru'] = "Вышел из системы"; +$text['agent_status-logged_out']['sv-se'] = "Utloggad"; +$text['agent_status-logged_out']['uk-ua'] = "Вийшов з системи"; +$text['agent_status-logged_out']['tr-tr'] = "Çıkış Yapıldı"; +$text['agent_status-logged_out']['zh-cn'] = "已注销"; +$text['agent_status-logged_out']['ja-jp'] = "ログアウト"; +$text['agent_status-logged_out']['ko-kr'] = "로그아웃됨"; + +$text['agent_state-waiting']['en-us'] = "Waiting"; +$text['agent_state-waiting']['en-gb'] = "Waiting"; +$text['agent_state-waiting']['ar-eg'] = "انتظار"; +$text['agent_state-waiting']['de-at'] = "Warten"; +$text['agent_state-waiting']['de-ch'] = "Warten"; +$text['agent_state-waiting']['de-de'] = "Warten"; +$text['agent_state-waiting']['el-gr'] = "Αναμονή"; +$text['agent_state-waiting']['es-cl'] = "Esperando"; +$text['agent_state-waiting']['es-mx'] = "Esperando"; +$text['agent_state-waiting']['fr-ca'] = "d'attente"; +$text['agent_state-waiting']['fr-fr'] = "d'attente"; +$text['agent_state-waiting']['he-il'] = "הַמתָנָה"; +$text['agent_state-waiting']['it-it'] = "Attesa"; +$text['agent_state-waiting']['ka-ge'] = "მოლოდინი"; +$text['agent_state-waiting']['nl-nl'] = "Wachten"; +$text['agent_state-waiting']['pl-pl'] = "Oczekiwanie"; +$text['agent_state-waiting']['pt-br'] = "Esperar"; +$text['agent_state-waiting']['pt-pt'] = "Esperando"; +$text['agent_state-waiting']['ro-ro'] = "Aşteptare"; +$text['agent_state-waiting']['ru-ru'] = "Ожидание"; +$text['agent_state-waiting']['sv-se'] = "Väntar"; +$text['agent_state-waiting']['uk-ua'] = "Очікування"; +$text['agent_state-waiting']['tr-tr'] = "Beklemek"; +$text['agent_state-waiting']['zh-cn'] = "等待"; +$text['agent_state-waiting']['ja-jp'] = "待っている"; +$text['agent_state-waiting']['ko-kr'] = "대기 중"; + +$text['agent_state-receiving']['en-us'] = "Receiving"; +$text['agent_state-receiving']['en-gb'] = "Receiving"; +$text['agent_state-receiving']['ar-eg'] = "استقبال"; +$text['agent_state-receiving']['de-at'] = "Empfang"; +$text['agent_state-receiving']['de-ch'] = "Empfang"; +$text['agent_state-receiving']['de-de'] = "Empfang"; +$text['agent_state-receiving']['el-gr'] = "Λήψη"; +$text['agent_state-receiving']['es-cl'] = "Recibiendo"; +$text['agent_state-receiving']['es-mx'] = "Recibiendo"; +$text['agent_state-receiving']['fr-ca'] = "Réception"; +$text['agent_state-receiving']['fr-fr'] = "Réception"; +$text['agent_state-receiving']['he-il'] = "מקבל"; +$text['agent_state-receiving']['it-it'] = "Ricezione"; +$text['agent_state-receiving']['ka-ge'] = "მიღება"; +$text['agent_state-receiving']['nl-nl'] = "Ontvangen"; +$text['agent_state-receiving']['pl-pl'] = "Odbieranie"; +$text['agent_state-receiving']['pt-br'] = "Recebendo"; +$text['agent_state-receiving']['pt-pt'] = "A receber"; +$text['agent_state-receiving']['ro-ro'] = "Primire"; +$text['agent_state-receiving']['ru-ru'] = "Принимает"; +$text['agent_state-receiving']['sv-se'] = "Tar emot"; +$text['agent_state-receiving']['uk-ua'] = "Приймає"; +$text['agent_state-receiving']['tr-tr'] = "Alıyor"; +$text['agent_state-receiving']['zh-cn'] = "接听中"; +$text['agent_state-receiving']['ja-jp'] = "受信中"; +$text['agent_state-receiving']['ko-kr'] = "수신 중"; + +$text['agent_state-in_a_queue_call']['en-us'] = "In a Queue Call"; +$text['agent_state-in_a_queue_call']['en-gb'] = "In a Queue Call"; +$text['agent_state-in_a_queue_call']['ar-eg'] = "في مكالمة في قائمة الانتظار"; +$text['agent_state-in_a_queue_call']['de-at'] = "In einem Warteschlangen-Anruf"; +$text['agent_state-in_a_queue_call']['de-ch'] = "In einem Warteschlangen-Anruf"; +$text['agent_state-in_a_queue_call']['de-de'] = "In einem Warteschlangen-Anruf"; +$text['agent_state-in_a_queue_call']['el-gr'] = "Σε κλήση ουράς"; +$text['agent_state-in_a_queue_call']['es-cl'] = "En una llamada de cola"; +$text['agent_state-in_a_queue_call']['es-mx'] = "En una llamada de cola"; +$text['agent_state-in_a_queue_call']['fr-ca'] = "Dans un appel de file d'attente"; +$text['agent_state-in_a_queue_call']['fr-fr'] = "Dans un appel de file d'attente"; +$text['agent_state-in_a_queue_call']['he-il'] = "בשיחת תור"; +$text['agent_state-in_a_queue_call']['it-it'] = "In una chiamata in coda"; +$text['agent_state-in_a_queue_call']['ka-ge'] = "რიგის ზარში"; +$text['agent_state-in_a_queue_call']['nl-nl'] = "In een wachtrijgesprek"; +$text['agent_state-in_a_queue_call']['pl-pl'] = "W połączeniu kolejki"; +$text['agent_state-in_a_queue_call']['pt-br'] = "Em uma chamada de fila"; +$text['agent_state-in_a_queue_call']['pt-pt'] = "Numa chamada de fila"; +$text['agent_state-in_a_queue_call']['ro-ro'] = "Într-un apel de coadă"; +$text['agent_state-in_a_queue_call']['ru-ru'] = "В звонке очереди"; +$text['agent_state-in_a_queue_call']['sv-se'] = "I ett köanrop"; +$text['agent_state-in_a_queue_call']['uk-ua'] = "У виклику черги"; +$text['agent_state-in_a_queue_call']['tr-tr'] = "Kuyruk çağrısında"; +$text['agent_state-in_a_queue_call']['zh-cn'] = "队列通话中"; +$text['agent_state-in_a_queue_call']['ja-jp'] = "キュー通話中"; +$text['agent_state-in_a_queue_call']['ko-kr'] = "대기열 통화 중"; + +$text['agent_state-wrap_up_time']['en-us'] = "Wrap-Up"; +$text['agent_state-wrap_up_time']['en-gb'] = "Wrap-Up"; +$text['agent_state-wrap_up_time']['ar-eg'] = "إنهاء"; +$text['agent_state-wrap_up_time']['de-at'] = "Nachbereitung"; +$text['agent_state-wrap_up_time']['de-ch'] = "Nachbereitung"; +$text['agent_state-wrap_up_time']['de-de'] = "Nachbereitung"; +$text['agent_state-wrap_up_time']['el-gr'] = "Ολοκλήρωση"; +$text['agent_state-wrap_up_time']['es-cl'] = "Cierre"; +$text['agent_state-wrap_up_time']['es-mx'] = "Cierre"; +$text['agent_state-wrap_up_time']['fr-ca'] = "Clôture"; +$text['agent_state-wrap_up_time']['fr-fr'] = "Clôture"; +$text['agent_state-wrap_up_time']['he-il'] = "סיכום"; +$text['agent_state-wrap_up_time']['it-it'] = "Chiusura"; +$text['agent_state-wrap_up_time']['ka-ge'] = "დასრულება"; +$text['agent_state-wrap_up_time']['nl-nl'] = "Afronden"; +$text['agent_state-wrap_up_time']['pl-pl'] = "Podsumowanie"; +$text['agent_state-wrap_up_time']['pt-br'] = "Finalização"; +$text['agent_state-wrap_up_time']['pt-pt'] = "Finalização"; +$text['agent_state-wrap_up_time']['ro-ro'] = "Închidere"; +$text['agent_state-wrap_up_time']['ru-ru'] = "Завершение"; +$text['agent_state-wrap_up_time']['sv-se'] = "Avslut"; +$text['agent_state-wrap_up_time']['uk-ua'] = "Завершення"; +$text['agent_state-wrap_up_time']['tr-tr'] = "Tamamlama"; +$text['agent_state-wrap_up_time']['zh-cn'] = "收尾"; +$text['agent_state-wrap_up_time']['ja-jp'] = "後処理"; +$text['agent_state-wrap_up_time']['ko-kr'] = "마무리"; + +$text['agent_state-idle']['en-us'] = "Idle"; +$text['agent_state-idle']['en-gb'] = "Idle"; +$text['agent_state-idle']['ar-eg'] = "خامل"; +$text['agent_state-idle']['de-at'] = "Leerlauf"; +$text['agent_state-idle']['de-ch'] = "Leerlauf"; +$text['agent_state-idle']['de-de'] = "Leerlauf"; +$text['agent_state-idle']['el-gr'] = "Αδρανής"; +$text['agent_state-idle']['es-cl'] = "Inactivo"; +$text['agent_state-idle']['es-mx'] = "Inactivo"; +$text['agent_state-idle']['fr-ca'] = "Inactif"; +$text['agent_state-idle']['fr-fr'] = "Inactif"; +$text['agent_state-idle']['he-il'] = "לא פעיל"; +$text['agent_state-idle']['it-it'] = "Inattivo"; +$text['agent_state-idle']['ka-ge'] = "უმოქმედო"; +$text['agent_state-idle']['nl-nl'] = "Inactief"; +$text['agent_state-idle']['pl-pl'] = "Bezczynny"; +$text['agent_state-idle']['pt-br'] = "Inativo"; +$text['agent_state-idle']['pt-pt'] = "Inativo"; +$text['agent_state-idle']['ro-ro'] = "Inactiv"; +$text['agent_state-idle']['ru-ru'] = "Неактивен"; +$text['agent_state-idle']['sv-se'] = "Inaktiv"; +$text['agent_state-idle']['uk-ua'] = "Неактивний"; +$text['agent_state-idle']['tr-tr'] = "Boşta"; +$text['agent_state-idle']['zh-cn'] = "空闲"; +$text['agent_state-idle']['ja-jp'] = "待機"; +$text['agent_state-idle']['ko-kr'] = "유휴"; + +$text['message-action_success']['en-us'] = "Action completed successfully"; +$text['message-action_success']['en-gb'] = "Action completed successfully"; +$text['message-action_success']['ar-eg'] = "اكتمل الإجراء بنجاح"; +$text['message-action_success']['de-at'] = "Aktion erfolgreich abgeschlossen"; +$text['message-action_success']['de-ch'] = "Aktion erfolgreich abgeschlossen"; +$text['message-action_success']['de-de'] = "Aktion erfolgreich abgeschlossen"; +$text['message-action_success']['el-gr'] = "Η ενέργεια ολοκληρώθηκε με επιτυχία"; +$text['message-action_success']['es-cl'] = "Acción completada con éxito"; +$text['message-action_success']['es-mx'] = "Acción completada con éxito"; +$text['message-action_success']['fr-ca'] = "Action terminée avec succès"; +$text['message-action_success']['fr-fr'] = "Action terminée avec succès"; +$text['message-action_success']['he-il'] = "הפעולה הושלמה בהצלחה"; +$text['message-action_success']['it-it'] = "Azione completata con successo"; +$text['message-action_success']['ka-ge'] = "მოქმედება წარმატებით შესრულდა"; +$text['message-action_success']['nl-nl'] = "Actie succesvol voltooid"; +$text['message-action_success']['pl-pl'] = "Akcja zakończona pomyślnie"; +$text['message-action_success']['pt-br'] = "Ação concluída com sucesso"; +$text['message-action_success']['pt-pt'] = "Ação concluída com sucesso"; +$text['message-action_success']['ro-ro'] = "Acțiune finalizată cu succes"; +$text['message-action_success']['ru-ru'] = "Действие успешно выполнено"; +$text['message-action_success']['sv-se'] = "Åtgärden slutfördes"; +$text['message-action_success']['uk-ua'] = "Дію успішно виконано"; +$text['message-action_success']['tr-tr'] = "İşlem başarıyla tamamlandı"; +$text['message-action_success']['zh-cn'] = "操作成功完成"; +$text['message-action_success']['ja-jp'] = "操作が正常に完了しました"; +$text['message-action_success']['ko-kr'] = "작업이 성공적으로 완료되었습니다"; + +$text['message-action_failed']['en-us'] = "Action failed"; +$text['message-action_failed']['en-gb'] = "Action failed"; +$text['message-action_failed']['ar-eg'] = "فشل الإجراء"; +$text['message-action_failed']['de-at'] = "Aktion fehlgeschlagen"; +$text['message-action_failed']['de-ch'] = "Aktion fehlgeschlagen"; +$text['message-action_failed']['de-de'] = "Aktion fehlgeschlagen"; +$text['message-action_failed']['el-gr'] = "Η ενέργεια απέτυχε"; +$text['message-action_failed']['es-cl'] = "La acción falló"; +$text['message-action_failed']['es-mx'] = "La acción falló"; +$text['message-action_failed']['fr-ca'] = "Échec de l'action"; +$text['message-action_failed']['fr-fr'] = "Échec de l'action"; +$text['message-action_failed']['he-il'] = "הפעולה נכשלה"; +$text['message-action_failed']['it-it'] = "Azione non riuscita"; +$text['message-action_failed']['ka-ge'] = "მოქმედება ვერ შესრულდა"; +$text['message-action_failed']['nl-nl'] = "Actie mislukt"; +$text['message-action_failed']['pl-pl'] = "Akcja nie powiodła się"; +$text['message-action_failed']['pt-br'] = "A ação falhou"; +$text['message-action_failed']['pt-pt'] = "A ação falhou"; +$text['message-action_failed']['ro-ro'] = "Acțiune eșuată"; +$text['message-action_failed']['ru-ru'] = "Не удалось выполнить действие"; +$text['message-action_failed']['sv-se'] = "Åtgärden misslyckades"; +$text['message-action_failed']['uk-ua'] = "Не вдалося виконати дію"; +$text['message-action_failed']['tr-tr'] = "İşlem başarısız oldu"; +$text['message-action_failed']['zh-cn'] = "操作失败"; +$text['message-action_failed']['ja-jp'] = "操作に失敗しました"; +$text['message-action_failed']['ko-kr'] = "작업에 실패했습니다"; + +$text['message-permission_denied']['en-us'] = "Permission denied"; +$text['message-permission_denied']['en-gb'] = "Permission denied"; +$text['message-permission_denied']['ar-eg'] = "تم رفض الإذن"; +$text['message-permission_denied']['de-at'] = "Zugriff verweigert"; +$text['message-permission_denied']['de-ch'] = "Zugriff verweigert"; +$text['message-permission_denied']['de-de'] = "Zugriff verweigert"; +$text['message-permission_denied']['el-gr'] = "Άρνηση πρόσβασης"; +$text['message-permission_denied']['es-cl'] = "Permiso denegado"; +$text['message-permission_denied']['es-mx'] = "Permiso denegado"; +$text['message-permission_denied']['fr-ca'] = "Permission refusée"; +$text['message-permission_denied']['fr-fr'] = "Permission refusée"; +$text['message-permission_denied']['he-il'] = "ההרשאה נדחתה"; +$text['message-permission_denied']['it-it'] = "Permesso negato"; +$text['message-permission_denied']['ka-ge'] = "წვდომა უარყოფილია"; +$text['message-permission_denied']['nl-nl'] = "Toegang geweigerd"; +$text['message-permission_denied']['pl-pl'] = "Brak uprawnień"; +$text['message-permission_denied']['pt-br'] = "Permissão negada"; +$text['message-permission_denied']['pt-pt'] = "Permissão negada"; +$text['message-permission_denied']['ro-ro'] = "Permisiune refuzată"; +$text['message-permission_denied']['ru-ru'] = "Доступ запрещен"; +$text['message-permission_denied']['sv-se'] = "Åtkomst nekad"; +$text['message-permission_denied']['uk-ua'] = "Доступ заборонено"; +$text['message-permission_denied']['tr-tr'] = "İzin reddedildi"; +$text['message-permission_denied']['zh-cn'] = "权限被拒绝"; +$text['message-permission_denied']['ja-jp'] = "権限が拒否されました"; +$text['message-permission_denied']['ko-kr'] = "권한이 거부되었습니다"; diff --git a/app/operator_panel/app_menu.php b/app/operator_panel/app_menu.php new file mode 100644 index 000000000..bf49b5cc8 --- /dev/null +++ b/app/operator_panel/app_menu.php @@ -0,0 +1,61 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + + // $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"; diff --git a/app/operator_panel/index.php b/app/operator_panel/index.php new file mode 100644 index 000000000..081e04b40 --- /dev/null +++ b/app/operator_panel/index.php @@ -0,0 +1,692 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +// 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'); + +?> + + + + + + + + + +\n"; + echo "
" . $text['title-operator_panel'] . "\n"; + + // Connection status indicator (icon + text) + echo "\t\t"; + if ($status_show_icon) { + echo ""; + } + echo "" . htmlspecialchars($text['status-connecting'] ?? 'Connecting') . ""; + echo "\n"; + + echo "
\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 "
\n"; + echo "
\n"; + foreach ($user_statuses as $s) { + $color = $status_btn_colors[$s] ?? '#6c757d'; + $label = strtoupper(htmlspecialchars($s)); + echo " \n"; + } + echo "
\n"; + echo "
\n"; + } + + echo "
\n"; + echo "\n"; + +?> + + + + + + +
+ + +
+ + +
+
+

+
+
+ + +
+ +
+

+
+
+ + +
+ +
+

+
+
+ + +
+ +
+

+
+
+ +
+ + + + +

+ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +/** + * 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 + */ +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; + } +} diff --git a/app/operator_panel/resources/classes/operator_panel_call_filter.php b/app/operator_panel/resources/classes/operator_panel_call_filter.php new file mode 100644 index 000000000..f36a9f902 --- /dev/null +++ b/app/operator_panel/resources/classes/operator_panel_call_filter.php @@ -0,0 +1,70 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +/** + * 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 + */ +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; + } +} diff --git a/app/operator_panel/resources/classes/operator_panel_conf_filter.php b/app/operator_panel/resources/classes/operator_panel_conf_filter.php new file mode 100644 index 000000000..4d2836dfd --- /dev/null +++ b/app/operator_panel/resources/classes/operator_panel_conf_filter.php @@ -0,0 +1,82 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +/** + * 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 + */ +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; + } +} diff --git a/app/operator_panel/resources/classes/operator_panel_service.php b/app/operator_panel/resources/classes/operator_panel_service.php new file mode 100644 index 000000000..7a20c9546 --- /dev/null +++ b/app/operator_panel/resources/classes/operator_panel_service.php @@ -0,0 +1,1668 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +/** + * Live Operator Panel WebSocket Service + * + * Unified backend service for the live operator panel. Handles three real-time + * data streams: + * + * 1. Calls / Extensions — FreeSWITCH channel events (CHANNEL_*, CALL_UPDATE, + * valet_parking::info) forwarded as-received so the UI can update extension + * blocks in under one second without any page refresh or AJAX. + * + * 2. Conference rooms — conference::maintenance events forwarded the same way + * the active_conferences service does, reusing the same enrichment helpers. + * + * 3. Agent stats — polled from the FreeSWITCH call-center module on a + * configurable timer (default 10 s) and broadcast to all connected subscribers. + * The timer self-reschedules by returning the interval value from the callback, + * which is the pattern supported by {@see base_websocket_system_service::set_timer()}. + * + * ALL client-initiated mutations (hangup, transfer, eavesdrop, record, user status, + * agent status) are handled through the 'action' WebSocket topic — no AJAX is used. + * + * @author Tim Fry + * @version 1.0.0 + */ +class operator_panel_service extends base_websocket_system_service implements websocket_service_interface { + + /** + * FreeSWITCH event subscriptions for this service. + * + * Covers calls, conferences, call-center agent events, and the heartbeat + * used to keep the event-socket connection alive. + * + * @var array + */ + const switch_events = [ + // Call / channel events + ['Event-Name' => 'CHANNEL_CREATE'], + ['Event-Name' => 'CHANNEL_CALLSTATE'], + ['Event-Name' => 'CALL_UPDATE'], + ['Event-Name' => 'CHANNEL_DESTROY'], + ['Event-Name' => 'CHANNEL_PARK'], + ['Event-Name' => 'CHANNEL_UNPARK'], + ['Event-Subclass' => 'valet_parking::info'], + // Conference events + ['API-Command' => 'conference'], + ['Event-Subclass' => 'conference::maintenance'], + // Call-center agent events + ['Event-Subclass' => 'callcenter::maintenance'], + // Registration events + ['Event-Subclass' => 'sofia::register'], + ['Event-Subclass' => 'sofia::unregister'], + // Keep-alive + ['Event-Name' => 'HEARTBEAT'], + ]; + + /** + * Call channel event keys forwarded to subscribers. + * + * @var array + */ + const call_event_keys = [ + 'event_name', + 'unique_id', + 'caller_context', + 'channel_presence_id', + 'answer_state', + 'channel_call_state', + 'caller_channel_created_time', + 'channel_read_codec_name', + 'channel_write_codec_name', + 'channel_read_codec_rate', + 'channel_write_codec_rate', + 'caller_channel_name', + 'caller_caller_id_name', + 'caller_caller_id_number', + 'caller_destination_number', + 'secure', + 'application', + 'application_data', + 'variable_current_application', + 'call_direction', + 'variable_call_direction', + 'other_leg_unique_id', + 'other_leg_rdnis', + 'variable_bridge_uuid', + // Valet parking + 'valet_extension', + 'action', + 'variable_referred_by_user', + 'variable_pre_transfer_caller_id_name', + 'variable_valet_parking_timeout', + ]; + + /** + * Conference event keys forwarded to subscribers. + * + * @var array + */ + const conf_event_keys = [ + 'event_name', + 'unique_id', + 'caller_context', + 'channel_presence_id', + 'answer_state', + 'channel_call_state', + 'caller_channel_created_time', + 'channel_read_codec_name', + 'channel_write_codec_name', + 'caller_caller_id_name', + 'caller_caller_id_number', + 'caller_destination_number', + 'conference_name', + 'conference_uuid', + 'conference_size', + 'conference_profile_name', + 'action', + 'floor', + 'video', + 'hear', + 'see', + 'speak', + 'talking', + 'mute_detect', + 'hold', + 'member_id', + 'member_type', + 'member_ghost', + 'energy_level', + 'current_energy', + 'new_id', + 'caller_id_name', + 'caller_id_number', + 'api_command_argument', + ]; + + /** + * Map of operator-panel action names to required permissions. + * + * Used by handle_action() to validate that the requesting subscriber + * has the necessary permission before executing the FreeSWITCH command. + * + * @var array + */ + const permission_map = [ + // Call actions + 'hangup' => 'operator_panel_hangup', + 'transfer' => 'operator_panel_manage', + 'eavesdrop' => 'operator_panel_eavesdrop', + 'whisper' => 'operator_panel_coach', + 'barge' => 'operator_panel_coach', + 'record' => 'operator_panel_record', + 'recording_state' => 'operator_panel_record', + 'registrations_state' => 'operator_panel_view', + 'originate' => 'operator_panel_originate', + // User presence status (own status only, enforced inside handler) + 'user_status' => 'operator_panel_view', + // Call-center agent status (supervisor action) + 'agent_status' => 'operator_panel_manage', + ]; + + /** @var resource|null Raw TCP socket to FreeSWITCH event socket */ + protected $switch_socket; + + /** @var event_socket|null High-level wrapper around $switch_socket */ + protected $event_socket; + + /** @var array Conference name → display name lookup cache */ + protected $conference_name_cache = []; + + /** @var int Seconds between agent-stats broadcasts */ + protected $agent_stats_interval; + + /** @var string Debug permissions mode: 'off', 'bytes', or 'full' */ + protected $debug_show_permissions_mode; + + /** @var bool Whether to log raw switch events */ + protected $debug_show_switch_event; + + /** + * Service name used for WebSocket subscription routing. + * + * @return string + */ + public static function get_service_name(): string { + return 'active.operator.panel'; + } + + /** + * Build a filter chain for the given subscriber. + * + * Domain isolation is applied to call events. Conference event keys are + * narrowed to conf_event_keys. Both filters share the same domain guard. + * + * @param subscriber $subscriber + * + * @return filter|null + */ + public static function create_filter_chain_for(subscriber $subscriber): ?filter { + if ($subscriber->has_permission('operator_panel_view')) { + // Accept both the domain-name context (used by sofia/SIP calls) and + // 'default' (used by feature codes and outbound routes on most installs). + return filter_chain::and_link([ + new caller_context_filter([$subscriber->get_domain_name(), 'default']), + ]); + } + return null; + } + + /** @override */ + protected static function display_version(): void { + echo "Live Operator Panel Service 1.0\n"; + } + + /** + * Called once by run() after connecting to the WebSocket server. + * + * Registers all topic handlers, loads settings, connects to FreeSWITCH, and + * starts the agent-stats broadcast timer. + * + * @return void + */ + protected function register_topics(): void { + // Snapshot requests + $this->on_topic('extensions_active', [$this, 'request_extensions_active']); + $this->on_topic('calls_active', [$this, 'request_calls_active']); + $this->on_topic('conferences_active', [$this, 'request_conferences_active']); + $this->on_topic('agents_active', [$this, 'request_agents_active']); + // Action handler (all mutations) + $this->on_topic('action', [$this, 'handle_action']); + // Keep-alive + $this->on_topic('ping', [$this, 'handle_ping']); + // Debug wildcard + $this->on_topic('*', [$this, 'subscribe_all']); + + $this->reload_settings(); + } + + /** + * Re-read configuration and reconnect to FreeSWITCH if needed. + * + * @return void + */ + protected function reload_settings(): void { + // Ensure reload is idempotent by removing stale listener/timers before re-registering. + if (!empty($this->switch_socket)) { + $this->remove_listener($this->switch_socket); + } + $this->clear_timers(); + + parent::$config->read(); + + $database = database::new(['config' => parent::$config]); + $settings = new settings(['database' => $database]); + + $this->agent_stats_interval = (int)$settings->get('operator_panel', 'agent_stats_interval', 10); + $this->debug_show_permissions_mode = $settings->get('operator_panel', 'debug_show_permissions_mode', 'off'); + $this->debug_show_switch_event = $settings->get('operator_panel', 'debug_show_switch_event', false) === true; + + $this->connect_to_ws_server(); + + if ($this->connect_to_switch_server()) { + $this->register_event_socket_filters(); + } else { + $this->warning('Failed to connect to switch server — real-time events will not be received'); + } + + if (!empty($this->switch_socket)) { + $this->add_listener($this->switch_socket, [$this, 'handle_switch_events']); + } + + // Start the self-rescheduling agent-stats broadcast timer + if ($this->agent_stats_interval > 0) { + $this->set_timer($this->agent_stats_interval, [$this, 'broadcast_agent_stats']); + } + } + + /** + * Connect to the FreeSWITCH event socket, blocking until success. + * + * @return bool + */ + protected function connect_to_switch_server(): bool { + $host = parent::$config->get('switch.event_socket.host', '127.0.0.1'); + $port = (int)parent::$config->get('switch.event_socket.port', 8021); + $password = parent::$config->get('switch.event_socket.password', 'ClueCon'); + + try { + while (true) { + $this->switch_socket = stream_socket_client("tcp://$host:$port", $errno, $errstr, 5); + if ($this->switch_socket) { + $this->notice("Connected to switch server at $host:$port"); + break; + } + sleep(3); + } + } catch (\RuntimeException $re) { + $this->warning('Unable to connect to event socket: ' . $re->getMessage()); + return false; + } + + stream_set_blocking($this->switch_socket, true); + $this->event_socket = new event_socket($this->switch_socket); + $this->event_socket->connect(null, null, $password); + stream_set_blocking($this->switch_socket, false); + + return $this->event_socket->is_connected(); + } + + /** + * Register FreeSWITCH event filters so only relevant events are streamed. + * + * @return void + */ + protected function register_event_socket_filters(): void { + $this->event_socket->request('event plain all'); + + foreach (self::switch_events as $events) { + foreach ($events as $event_key => $event_name) { + $this->debug("Requesting event filter [$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->info("Filter registered: $response"); + } + } + } + + /** + * Log when a WebSocket client first makes a connection + * + * @override + */ + protected function on_ws_connected(): void { + parent::on_ws_connected(); + if ($this->ws_client->is_connected()) { + $this->info('Registered: ' . self::get_service_name()); + } + } + + /** + * Show a notice when a WebSocket client successfully authenticates + * + * @override + */ + protected function on_ws_authenticated(websocket_message $websocket_message): void { + $this->notice('WebSocket client authenticated successfully'); + } + + /** + * Called by the main run() loop when data arrives on the FreeSWITCH socket. + * + * @return void + */ + protected function handle_switch_events(): void { + $event = $this->event_socket->read_event(); + + if ($event === false || ($this->event_socket === null && $this->switch_socket !== null)) { + $this->warning('Lost connection to switch server'); + $this->remove_listener($this->switch_socket); + $this->switch_socket = null; + $this->event_socket = null; + $this->reload_settings(); + return; + } + + if ($this->debug_show_switch_event) { + $this->debug('Switch event received'); + } + + // Build a filtered event_message using the call-event key list by default + $call_filter = filter_chain::and_link([new event_key_filter(self::call_event_keys)]); + $event_message = event_message::create_from_switch_event($event, $call_filter); + + // Route by subclass for CUSTOM events (e.g. sofia::register), + // otherwise fall back to Event-Name / API-Command. + $event_name = strtolower((string)($event['Event-Name'] ?? $event_message->event_name ?? '')); + $event_subclass = strtolower((string)($event['Event-Subclass'] ?? '')); + $api_command = strtolower((string)($event['API-Command'] ?? '')); + $topic = $event_subclass !== '' + ? $event_subclass + : ($api_command !== '' ? $api_command : $event_name); + $event_message->topic = $topic; + if ($topic === 'sofia::register' || $topic === 'sofia::unregister' || $event_name === 'custom') { + $this->debug('Registration trace [route]: ' + . 'event_name=' . $event_name + . ' subclass=' . $event_subclass + . ' api_command=' . $api_command + . ' resolved_topic=' . $topic); + } + + switch ($topic) { + // Call / Channel events + case 'channel_create': + case 'channel_callstate': + case 'call_update': + case 'channel_destroy': + case 'channel_park': + case 'channel_unpark': + $this->broadcast_call_event($event_message, $topic); + break; + + case 'valet_parking': + $this->broadcast_call_event($event_message, 'valet_info'); + break; + + // Conference events (re-filter with conf keys) + case 'conference': + case 'conference::maintenance': + $conf_filter = filter_chain::and_link([new event_key_filter(self::conf_event_keys)]); + $conf_message = event_message::create_from_switch_event($event, $conf_filter); + $this->on_conference_maintenance($conf_message); + break; + + // Call-center agent events + case 'callcenter::maintenance': + $this->on_callcenter_maintenance($event_message); + break; + + // Registration events + case 'sofia::register': + case 'sofia::unregister': + $reg_message = event_message::create_from_switch_event($event, null); + $this->on_registration_event($reg_message, $topic); + break; + + case 'heartbeat': + $this->debug('HEARTBEAT'); + break; + + default: + $this->debug("Unhandled switch event: $topic"); + break; + } + } + + /** + * Respond to an extensions_active snapshot request. + * + * Returns all enabled extensions for the subscriber's domain including + * their current SIP registration status. Call state (idle/ringing/active/ + * held) is intentionally omitted here because the JS client derives it + * incrementally from the CHANNEL_* events already flowing over the socket. + * + * @param websocket_message $message + * + * @return void + */ + protected function request_extensions_active(websocket_message $message): void { + // Debug information for request handling + $this->debug('extensions_active snapshot requested'); + $t0 = microtime(true); + $this->debug('extensions_active trace [step1] begin'); + + // Validate input and permissions + $payload = $message->payload(); + $domain_name = $payload['domain_name'] ?? ''; + + $extensions = []; + + // Query all enabled extensions for this domain + try { + $t_db0 = microtime(true); + $database = database::new(['config' => parent::$config]); + + $sql = "SELECT e.extension_uuid, e.extension, e.effective_caller_id_name, e.effective_caller_id_number, " + . "e.description, e.call_group, e.do_not_disturb, e.user_context, e.enabled, " + . "COALESCE(vm.voicemail_enabled, 'false') AS voicemail_enabled " + . "FROM v_extensions AS e " + . "LEFT JOIN v_domains AS d ON e.domain_uuid = d.domain_uuid " + . "LEFT JOIN v_voicemails AS vm ON vm.domain_uuid = e.domain_uuid " + . "AND vm.voicemail_id = e.extension " + . "WHERE d.domain_name = :domain_name AND e.enabled = 'true' " + . "ORDER BY e.extension::integer NULLS LAST, e.extension ASC"; + + $extensions = $database->select($sql, [':domain_name' => $domain_name], 'all') ?? []; + $this->debug('extensions_active trace [step2] extensions query done: rows=' . count($extensions) + . ' elapsed_ms=' . round((microtime(true) - $t_db0) * 1000, 1)); + } catch (\Exception $e) { + $this->error('Failed to query extensions: ' . $e->getMessage()); + } + + // Best-effort: attach user_uuid and user_status where schema supports extension-user mappings + // If these tables/views are unavailable on an installation, keep extensions visible + $user_status_map = []; + try { + $t_us0 = microtime(true); + $sql = "SELECT e.extension, eu.user_uuid, COALESCE(u.user_status, 'Logged Out') AS user_status " + . "FROM v_extensions AS e " + . "LEFT JOIN v_domains AS d ON e.domain_uuid = d.domain_uuid " + . "LEFT JOIN (" + . "SELECT extension_uuid, MIN(user_uuid) AS user_uuid " + . "FROM v_extension_users GROUP BY extension_uuid" + . ") AS eu ON eu.extension_uuid = e.extension_uuid " + . "LEFT JOIN v_users AS u ON u.user_uuid = eu.user_uuid " + . "WHERE d.domain_name = :domain_name AND e.enabled = 'true'"; + + $rows = $database->select($sql, [':domain_name' => $domain_name], 'all'); + + // Check for unexpected query result formats and coerce to empty array if needed + if (!is_array($rows)) { + $this->debug('extensions_active trace [step3] user-status query returned non-array; coercing to empty array'); + $rows = []; + } + + // Some extensions may not have a user mapping; skip those rather than dropping the entire list + foreach ($rows as $row) { + $ext_num = $row['extension'] ?? ''; + if ($ext_num === '') continue; + $user_status_map[$ext_num] = [ + 'user_uuid' => $row['user_uuid'] ?? null, + 'user_status' => $row['user_status'] ?? 'Logged Out', + ]; + } + + // Debug logging for user status mapping + $this->debug('extensions_active trace [step3] user-status query done: rows=' . count($rows) + . ' mapped=' . count($user_status_map) + . ' elapsed_ms=' . round((microtime(true) - $t_us0) * 1000, 1)); + } catch (\Exception $e) { + $this->debug('Could not fetch extension user status mappings: ' . $e->getMessage()); + } + + // Fetch live registration status for all extensions in this domain + $registered_map = []; + try { + $t_reg0 = microtime(true); + $this->debug('extensions_active trace [step4] fetching registrations via show registrations as json'); + $reg_json = trim(event_socket::api('show registrations as json')); + $this->debug('extensions_active trace [step4] registrations api returned: bytes=' . strlen($reg_json) + . ' elapsed_ms=' . round((microtime(true) - $t_reg0) * 1000, 1)); + $t_parse0 = microtime(true); + $reg_data = json_decode($reg_json, true); + if (is_array($reg_data) && !empty($reg_data['rows'])) { + foreach ($reg_data['rows'] as $row) { + $ext_num = $row['reg_user'] ?? ''; + $reg_domain = $row['realm'] ?? ''; + if (!empty($ext_num) && $reg_domain === $domain_name) { + $registered_map[$ext_num] = ($registered_map[$ext_num] ?? 0) + 1; + } + } + } + $this->debug('extensions_active trace [step5] registrations parsed: rows=' . (is_array($reg_data['rows'] ?? null) ? count($reg_data['rows']) : 0) + . ' matched_domain=' . count($registered_map) + . ' parse_elapsed_ms=' . round((microtime(true) - $t_parse0) * 1000, 1)); + } catch (\Exception $e) { + $this->debug('Could not fetch registrations: ' . $e->getMessage()); + } + + // Annotate each extension with its registration status + foreach ($extensions as &$ext) { + $ext_num = $ext['extension'] ?? ''; + $ext['registered'] = isset($registered_map[$ext_num]); + $ext['registration_count'] = $registered_map[$ext_num] ?? 0; + $ext['user_uuid'] = $user_status_map[$ext_num]['user_uuid'] ?? null; + $ext['user_status'] = $user_status_map[$ext_num]['user_status'] ?? 'Logged Out'; + } + unset($ext); + $this->debug('extensions_active trace [step6] annotation complete: extensions=' . count($extensions)); + + // Create the response message + $response = new websocket_message(); + $response + ->payload($extensions) + ->service_name(self::get_service_name()) + ->topic('extensions_active') + ->status_string('ok') + ->status_code(200) + ->request_id($message->request_id()) // Echo back the request_id so the client can correlate in the browser + ->resource_id($message->resource_id()) // Echo back the resource_id so the client can correlate in the browser + ; + + // Send the response back to the requesting subscriber + websocket_client::send($this->ws_client->socket(), $response); + + // Debug logging for response payload and timing + $this->debug('extensions_active trace [step7] response sent: rows=' . count($extensions) + . ' total_elapsed_ms=' . round((microtime(true) - $t0) * 1000, 1)); + } + + /** + * Respond to a calls_active snapshot request. + * + * Returns all current channels from FreeSWITCH as a JSON array. + * + * @param websocket_message $message + * + * @return void + */ + protected function request_calls_active(websocket_message $message): void { + $this->debug('calls_active snapshot requested'); + + $payload = $message->payload(); + $domain = $payload['domain_name'] ?? ''; + + $json_str = trim(event_socket::api('show channels as json')); + $channels_raw = json_decode($json_str, true); + if (is_array($channels_raw) && isset($channels_raw['rows']) && is_array($channels_raw['rows'])) { + $rows = $channels_raw['rows']; + } elseif (is_array($channels_raw)) { + $rows = $channels_raw; + } else { + $rows = []; + } + + // Map "show channels as json" short field names to the event-style + // long names that the client JavaScript expects. This mirrors the + // mapping performed in active_calls_service::get_active_calls(). + $channels = []; + foreach ($rows as $call) { + $mapped = []; + $mapped['event_name'] = 'CHANNEL_CALLSTATE'; + $mapped['channel_call_state'] = $call['callstate'] ?? 'ACTIVE'; + // Derive answer_state from the actual callstate + $cs = strtoupper($call['callstate'] ?? ''); + if ($cs === 'RINGING' || $cs === 'EARLY') { + $mapped['answer_state'] = 'ringing'; + } elseif ($cs === 'HELD') { + $mapped['answer_state'] = 'held'; + } else { + $mapped['answer_state'] = 'answered'; + } + $mapped['unique_id'] = $call['uuid'] ?? ''; + $mapped['call_direction'] = $call['direction'] ?? ''; + $mapped['caller_channel_created_time'] = strval(intval($call['created_epoch'] ?? 0) * 1000000); + $mapped['channel_read_codec_name'] = $call['read_codec'] ?? ''; + $mapped['channel_read_codec_rate'] = $call['read_rate'] ?? ''; + $mapped['channel_write_codec_name'] = $call['write_codec'] ?? ''; + $mapped['channel_write_codec_rate'] = $call['write_rate'] ?? ''; + $mapped['caller_channel_name'] = $call['name'] ?? ''; + $mapped['caller_context'] = $call['context'] ?? ''; + $mapped['channel_presence_id'] = $call['presence_id'] ?? ''; + $mapped['caller_caller_id_name'] = $call['initial_cid_name'] ?? ($call['cid_name'] ?? ''); + $mapped['caller_caller_id_number'] = $call['initial_cid_num'] ?? ($call['cid_num'] ?? ''); + $mapped['caller_destination_number'] = $call['initial_dest'] ?? ($call['dest'] ?? ''); + $mapped['application'] = $call['application'] ?? ''; + $mapped['secure'] = $call['secure'] ?? ''; + $channels[] = $mapped; + } + + // Filter to requesting subscriber's domain when provided. + // Accept both the FQDN context (sofia/SIP calls) and 'default' + // (feature codes, outbound routes) so all calls are visible. + if (!empty($domain) && !empty($channels)) { + $channels = array_filter($channels, function ($ch) use ($domain) { + $context = $ch['caller_context'] ?? ''; + $presence = $ch['channel_presence_id'] ?? ''; + + // Some channel snapshots omit context; do not drop those rows. + if ($context === '') { + return true; + } + + if ($context === $domain || $context === 'default') { + return true; + } + + return strpos((string) $presence, '@' . $domain) !== false; + }); + $channels = array_values($channels); + } + + $response = new websocket_message(); + $response + ->payload($channels ?? []) + ->service_name(self::get_service_name()) + ->topic('calls_active') + ->status_string('ok') + ->status_code(200) + ->request_id($message->request_id()) + ->resource_id($message->resource_id()) + ; + + websocket_client::send($this->ws_client->socket(), $response); + } + + /** + * Respond to a conferences_active snapshot request. + * + * @param websocket_message $message + * + * @return void + */ + protected function request_conferences_active(websocket_message $message): void { + $this->debug('conferences_active snapshot requested'); + + $payload = $message->payload(); + $domain_name = $payload['domain_name'] ?? ''; + + $json_str = trim(event_socket::api('conference json_list')); + $conferences = json_decode($json_str, true); + + if (is_array($conferences)) { + foreach ($conferences as &$conf) { + $conf_name = $conf['conference_name'] ?? ''; + if (!empty($conf_name)) { + $conf['conference_display_name'] = $this->lookup_conference_display_name($conf_name); + } + } + unset($conf); + + if (!empty($domain_name)) { + $conferences = array_filter($conferences, function ($c) use ($domain_name) { + return strpos($c['conference_name'] ?? '', '@' . $domain_name) !== false; + }); + $conferences = array_values($conferences); + } + } + + $response = new websocket_message(); + $response + ->payload($conferences ?? []) + ->service_name(self::get_service_name()) + ->topic('conferences_active') + ->status_string('ok') + ->status_code(200) + ->request_id($message->request_id()) + ->resource_id($message->resource_id()) + ; + + websocket_client::send($this->ws_client->socket(), $response); + } + + /** + * Respond to an agents_active snapshot request. + * + * The payload is shaped by {@see operator_panel_agent_filter} according to + * the subscriber's role (supervisor vs. regular agent). + * + * @param websocket_message $message + * + * @return void + */ + protected function request_agents_active(websocket_message $message): void { + $this->debug('agents_active snapshot requested'); + + $payload = $message->payload(); + $domain_name = $payload['domain_name'] ?? ''; + $permissions = $message->get_permissions(); + + $agents = $this->get_all_agent_stats($domain_name); + + $is_supervisor = !empty($permissions['operator_panel_manage']); + $agent_name = $this->get_agent_name_for_permission($permissions, $domain_name); + $filter = new operator_panel_agent_filter($is_supervisor, $agent_name); + $filtered = $filter->filter($agents); + + $response = new websocket_message(); + $response + ->payload($filtered) + ->service_name(self::get_service_name()) + ->topic('agents_active') + ->status_string('ok') + ->status_code(200) + ->request_id($message->request_id()) + ->resource_id($message->resource_id()) + ; + + websocket_client::send($this->ws_client->socket(), $response); + } + + /** + * Handle all client-initiated mutations over WebSocket. + * + * Validates the required permission from {@see self::permission_map}, then + * executes the corresponding FreeSWITCH API command or database update. + * + * @param websocket_message $message + * + * @return void + */ + protected function handle_action(websocket_message $message): void { + $payload = $message->payload(); + $action = $payload['action'] ?? ''; + $uuid = $payload['uuid'] ?? ''; + $permissions = $message->get_permissions(); + + $this->debug("Action request: $action"); + + // Debug permissions logging + if ($this->debug_show_permissions_mode === 'full') { + $this->debug("Permission check — action: $action, required: " . (self::permission_map[$action] ?? 'unknown')); + $this->debug("Permissions: " . json_encode($permissions)); + } elseif ($this->debug_show_permissions_mode === 'bytes') { + $this->debug("Permissions: " . count($permissions) . " items, " . strlen(json_encode($permissions)) . " bytes"); + } + + // Validate action name + if (!array_key_exists($action, self::permission_map)) { + $this->send_action_response($message, false, 'Invalid action: ' . $action); + return; + } + + // Check permission + $required = self::permission_map[$action]; + if (empty($permissions[$required])) { + $this->warning("Permission denied: $required for action: $action"); + $this->send_action_response($message, false, 'Permission denied'); + return; + } + + $result = $this->execute_action($action, $payload, $permissions); + $status_message = isset($result['message']) ? (string)$result['message'] : ''; + $success = (bool)($result['success'] ?? false); + $extra_payload = $result; + unset($extra_payload['success'], $extra_payload['message']); + $this->send_action_response($message, $success, $status_message, $extra_payload); + } + + /** + * Execute a validated action. + * + * @param string $action Action name (e.g. 'hangup', 'transfer', 'eavesdrop', etc.) + * @param array $payload Full request payload from the client. + * + * @return array ['success' => bool, 'message' => string] + */ + private function execute_action(string $action, array $payload): array { + $uuid = $payload['uuid'] ?? ''; + $destination = $payload['destination'] ?? ''; + $context = $payload['context'] ?? 'default'; + $domain_name = $payload['domain_name'] ?? ''; + + try { + switch ($action) { + case 'hangup': + if (empty($uuid)) { + return ['success' => false, 'message' => 'UUID required']; + } + event_socket::api("uuid_kill $uuid"); + return ['success' => true, 'message' => 'Call terminated']; + + case 'transfer': + if (empty($uuid) || empty($destination)) { + return ['success' => false, 'message' => 'UUID and destination required']; + } + // Sanitize destination: digits, *, #, + only + if (!preg_match('/^[0-9*#+]+$/', $destination)) { + return ['success' => false, 'message' => 'Invalid destination']; + } + event_socket::api("uuid_transfer $uuid $destination XML $context"); + return ['success' => true, 'message' => 'Call transferred']; + + case 'eavesdrop': + if (empty($uuid) || empty($destination)) { + return ['success' => false, 'message' => 'UUID and destination extension required']; + } + $dest_ext = $payload['destination_extension'] ?? $destination; + if (!preg_match('/^[0-9*#+]+$/', $dest_ext)) { + return ['success' => false, 'message' => 'Invalid destination extension']; + } + if (empty($domain_name)) { + return ['success' => false, 'message' => 'domain_name required']; + } + + $api_cmd = "bgapi originate {origination_caller_id_name=eavesdrop,origination_caller_id_number=$dest_ext}user/$dest_ext@$domain_name &eavesdrop($uuid)"; + $reply = trim((string)event_socket::api($api_cmd)); + $this->info("Eavesdrop request: uuid=$uuid destination=$dest_ext@$domain_name"); + $this->debug("Eavesdrop command reply: $reply"); + + if (stripos($reply, '-ERR') !== false) { + return ['success' => false, 'message' => $reply]; + } + + return ['success' => true, 'message' => 'Eavesdrop started']; + + case 'whisper': + case 'barge': + if (empty($uuid) || empty($destination)) { + return ['success' => false, 'message' => 'UUID and destination extension required']; + } + $dest_ext = $payload['destination_extension'] ?? $destination; + if (!preg_match('/^[0-9*#+]+$/', $dest_ext)) { + return ['success' => false, 'message' => 'Invalid destination extension']; + } + if (empty($domain_name)) { + return ['success' => false, 'message' => 'domain_name required']; + } + + $mode_token = $action === 'whisper' ? 'whisper' : 'barge'; + $api_cmd = "bgapi originate {origination_caller_id_name=$mode_token,origination_caller_id_number=$dest_ext}user/$dest_ext@$domain_name &eavesdrop($uuid $mode_token)"; + $reply = trim((string)event_socket::api($api_cmd)); + $this->info(ucfirst($action) . " request: uuid=$uuid destination=$dest_ext@$domain_name"); + $this->debug(ucfirst($action) . " command reply: $reply"); + + if (stripos($reply, '-ERR') !== false) { + return ['success' => false, 'message' => $reply]; + } + + return ['success' => true, 'message' => ucfirst($action) . ' started']; + + case 'record': + if (empty($uuid)) { + return ['success' => false, 'message' => 'UUID required']; + } + $stop = !empty($payload['stop']) && ($payload['stop'] === true || $payload['stop'] === 'true' || $payload['stop'] === 1 || $payload['stop'] === '1'); + if ($stop) { + $reply = trim((string) event_socket::api("uuid_record $uuid stop")); + if (stripos($reply, '-ERR') !== false) { + return ['success' => false, 'message' => $reply]; + } + $this->info("Recording stopped: uuid=$uuid"); + return ['success' => true, 'message' => 'Recording stopped']; + } + $recordings_path = event_socket::api('global_getvar recordings_dir'); + $recordings_path = trim($recordings_path ?: '/var/lib/freeswitch/recordings'); + if (!empty($domain_name)) { + $recordings_path .= '/' . $domain_name; + } + $recordings_path .= '/archive/' . date('Y') . '/' . date('M') . '/' . date('d'); + $file = $recordings_path . '/' . $uuid . '.wav'; + $reply = trim((string) event_socket::api("uuid_record $uuid start $file")); + if (stripos($reply, '-ERR') !== false) { + return ['success' => false, 'message' => $reply]; + } + $this->info("Recording started: uuid=$uuid file=$file"); + return ['success' => true, 'message' => 'Recording started']; + + case 'recording_state': + $uuids = $payload['uuids'] ?? []; + if (!is_array($uuids)) { + return ['success' => false, 'message' => 'uuids must be an array']; + } + + $states = []; + foreach ($uuids as $id) { + $id = trim((string) $id); + if ($id === '' || !preg_match('/^[a-f0-9-]{32,36}$/i', $id)) { + continue; + } + + $buglist = trim((string) event_socket::api("uuid_buglist $id")); + $lower = strtolower($buglist); + $is_recording = ( + strpos($lower, 'record') !== false + && strpos($lower, '-err') === false + && strpos($lower, 'no bugs') === false + ); + $states[$id] = $is_recording; + } + + return ['success' => true, 'message' => 'Recording state updated', 'states' => $states]; + + case 'registrations_state': + if (empty($domain_name)) { + return ['success' => false, 'message' => 'domain_name required']; + } + $states = []; + $reg_json = trim((string) event_socket::api('show registrations as json')); + $reg_data = json_decode($reg_json, true); + if (is_array($reg_data) && !empty($reg_data['rows']) && is_array($reg_data['rows'])) { + foreach ($reg_data['rows'] as $row) { + $ext_num = trim((string)($row['reg_user'] ?? '')); + $reg_domain = trim((string)($row['realm'] ?? '')); + if ($ext_num === '' || $reg_domain !== $domain_name) continue; + $states[$ext_num] = ($states[$ext_num] ?? 0) + 1; + } + } + return ['success' => true, 'message' => 'Registrations state updated', 'states' => $states]; + + case 'originate': + $source = $payload['source'] ?? ''; + $dest = $payload['destination'] ?? ''; + if (empty($source) || empty($dest)) { + return ['success' => false, 'message' => 'Source and destination required']; + } + // Sanitize source and destination: digits, *, #, + only + if (!preg_match('/^[0-9*#+]+$/', $source) || !preg_match('/^[0-9*#+]+$/', $dest)) { + return ['success' => false, 'message' => 'Invalid source or destination']; + } + // Prevent self-calls + if ($source === $dest) { + return ['success' => false, 'message' => 'Cannot call self']; + } + $context = trim((string)($payload['context'] ?? $domain_name ?? 'default')); + if ($context === '') { + $context = 'default'; + } + $context = preg_replace('/[^a-zA-Z0-9_\-.]/', '', $context); + if ($context === '') { + $context = 'default'; + } + + // Route destination through dialplan so extension features (forward_all, + // follow_me, voicemail, etc.) are evaluated like a normal extension call. + $destination_route = 'loopback/' . $dest . '/' . $context; + event_socket::api("bgapi originate {sip_auto_answer=true,origination_caller_id_number=$source,sip_h_Call-Info=_undef_}user/$source@$domain_name $destination_route"); + return ['success' => true, 'message' => 'Call originated']; + + case 'user_status': + $status = $payload['status'] ?? ''; + $user_uuid = $payload['user_uuid'] ?? ''; + $allowed = ['Available', 'Available (On Demand)', 'On Break', 'Do Not Disturb', 'Logged Out']; + if (!in_array($status, $allowed, true)) { + return ['success' => false, 'message' => 'Invalid status value']; + } + if (empty($user_uuid)) { + return ['success' => false, 'message' => 'user_uuid required']; + } + $database = database::new(['config' => parent::$config]); + $database->execute( + "UPDATE v_users SET user_status = :status WHERE user_uuid = :user_uuid", + [':status' => $status, ':user_uuid' => $user_uuid] + ); + return ['success' => true, 'message' => 'Status updated']; + + case 'agent_status': + $agent_name = $payload['agent_name'] ?? ''; + $status = $payload['status'] ?? ''; + $allowed_cc = ['Available', 'Available (On Demand)', 'On Break', 'Do Not Disturb', 'Logged Out']; + if (empty($agent_name) || !in_array($status, $allowed_cc, true)) { + return ['success' => false, 'message' => 'agent_name and valid status required']; + } + // Sanitize agent name + if (!preg_match('/^[a-zA-Z0-9@._\-]+$/', $agent_name)) { + return ['success' => false, 'message' => 'Invalid agent name']; + } + event_socket::api("callcenter_config agent set status $agent_name '$status'"); + // Also update the database record for persistence + $database = database::new(['config' => parent::$config]); + $database->execute( + "UPDATE v_call_center_agents SET agent_status = :status WHERE agent_name = :agent_name", + [':status' => $status, ':agent_name' => $agent_name] + ); + return ['success' => true, 'message' => 'Agent status updated']; + + default: + return ['success' => false, 'message' => 'Unknown action']; + } + } catch (\Exception $e) { + $this->error('Action failed: ' . $e->getMessage()); + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + /** + * Respond to a ping with a pong. + * + * @param websocket_message $message + * + * @return void + */ + protected function handle_ping(websocket_message $message): void { + $this->debug('Ping received — sending pong'); + + $response = new websocket_message(); + $response + ->payload(['pong' => time()]) + ->service_name(self::get_service_name()) + ->topic('pong') + ->status_string('ok') + ->status_code(200) + ->request_id($message->request_id()) + ->resource_id($message->resource_id()) + ; + + websocket_client::send($this->ws_client->socket(), $response); + } + + /** + * Wildcard subscription — acknowledges receipt for debugging purposes. + * + * @param websocket_message $message + * + * @return void + */ + protected function subscribe_all(websocket_message $message): void { + $this->debug('Wildcard subscription requested'); + + $response = new websocket_message(); + $response + ->payload(['subscribed' => '*']) + ->service_name(self::get_service_name()) + ->topic('*') + ->status_string('ok') + ->status_code(200) + ->request_id($message->request_id()) + ->resource_id($message->resource_id()) + ; + + websocket_client::send($this->ws_client->socket(), $response); + } + + /** + * Collect agent statistics from FreeSWITCH and broadcast to all subscribers. + * + * Called by the timer loop. Returns the interval in seconds so that + * {@see base_websocket_system_service::run()} auto-reschedules the timer. + * + * @return int Seconds until next broadcast (self::$agent_stats_interval). + */ + public function broadcast_agent_stats(): int { + $this->debug('Broadcasting agent stats'); + + // Retrieve all queues from the database (we need the domain for context) + $agents = $this->get_all_agent_stats(); + + if (empty($agents)) { + return $this->agent_stats_interval; + } + + $message = new websocket_message(); + $message + ->service_name(self::get_service_name()) + ->topic('agent_stats') + ->payload($agents) + ; + + websocket_client::send($this->ws_client->socket(), $message); + + // Return the interval so the timer reschedules itself + return $this->agent_stats_interval; + } + + /** + * Retrieve live agent statistics for all call-center queues. + * + * Queries the FreeSWITCH callcenter_config command for each queue found + * in the database, then merges the live stats into a unified array. + * + * @param string $domain_name Optional domain to scope the query. + * + * @return array Array of agent stat rows. + */ + private function get_all_agent_stats(string $domain_name = ''): array { + try { + $database = database::new(['config' => parent::$config]); + + $sql = "SELECT q.queue_extension, q.queue_name, d.domain_name " + . "FROM v_call_center_queues AS q " + . "LEFT JOIN v_domains AS d ON q.domain_uuid = d.domain_uuid "; + $params = []; + if (!empty($domain_name)) { + $sql .= "WHERE d.domain_name = :domain_name "; + $params[':domain_name'] = $domain_name; + } + $queues = $database->select($sql, $params, 'all'); + } catch (\Exception $e) { + $this->error('Failed to query queues: ' . $e->getMessage()); + return []; + } + + if (empty($queues)) { + return []; + } + + $all_agents = []; + foreach ($queues as $queue) { + $ext = $queue['queue_extension'] ?? ''; + $domain = $queue['domain_name'] ?? ''; + + if (empty($ext) || empty($domain)) { + continue; + } + + $raw = event_socket::api("callcenter_config queue list agents $ext@$domain"); + if (empty($raw) || strpos($raw, '-ERR') === 0) { + continue; + } + + // Parse pipe-delimited output: name|status|state|uuid|contact|... + foreach (explode("\n", trim($raw)) as $line) { + $line = trim($line); + if (empty($line) || strpos($line, 'name|') === 0) { + continue; // skip header + } + $parts = explode('|', $line); + if (count($parts) < 16) { + continue; + } + $all_agents[] = [ + 'agent_name' => $parts[0], + 'status' => $parts[1], + 'state' => $parts[2], + 'uuid' => $parts[3], + 'contact' => $parts[4], + 'max_no_answer' => $parts[5], + 'wrap_up_time' => $parts[6], + 'reject_delay_time' => $parts[7], + 'busy_delay_time' => $parts[8], + 'last_bridge_start' => $parts[9], + 'last_bridge_end' => $parts[10], + 'last_status_change' => $parts[11], + 'no_answer_count' => $parts[12], + 'calls_answered' => $parts[13], + 'talk_time' => $parts[14], + 'ready_time' => $parts[15], + 'queue_name' => $queue['queue_name'] ?? '', + 'queue_extension' => $ext, + 'domain_name' => $domain, + ]; + } + } + + return $all_agents; + } + + /** + * Process a conference::maintenance event and broadcast to subscribers. + * + * @param event_message $event_message + * + * @return void + */ + private function on_conference_maintenance(event_message $event_message): void { + $action = str_replace('-', '_', $event_message->action ?? ''); + $conference_name = $this->extract_conference_name($event_message); + + switch ($action) { + case 'start_talking': + case 'stop_talking': + case 'mute_member': + case 'unmute_member': + case 'deaf_member': + case 'undeaf_member': + case 'floor_change': + case 'conference_destroy': + case 'lock': + case 'unlock': + case 'kick_member': + case 'energy_level': + case 'gain_level': + case 'volume_level': + $this->broadcast_call_event($event_message, $action); + break; + + case 'add_member': + $enriched = $this->enrich_member_event($event_message, $conference_name); + $this->broadcast_enriched_event($enriched, $action); + break; + + case 'del_member': + $enriched = $this->enrich_del_member_event($event_message, $conference_name); + $this->broadcast_enriched_event($enriched, $action); + break; + + case 'conference_create': + $enriched = $this->enrich_conference_create_event($event_message, $conference_name); + $this->broadcast_enriched_event($enriched, $action); + break; + + default: + $this->debug("Unknown conference action: $action"); + break; + } + } + + /** + * Trigger an immediate agent stats broadcast when a call-center maintenance + * event is received (e.g. agent status change, call answered). + * + * @param event_message $event_message + * + * @return void + */ + private function on_callcenter_maintenance(event_message $event_message): void { + $this->debug('callcenter::maintenance — triggering immediate agent stats broadcast'); + $this->broadcast_agent_stats(); + // Note: the periodic timer continues independently; no reschedule needed here. + } + + /** + * Handle a sofia::register or sofia::unregister event and broadcast + * a registration_change message so connected UIs can update the + * extension's registered status in real time. + * + * @param event_message $reg_message Parsed event (unfiltered). + * @param string $topic 'sofia::register' or 'sofia::unregister'. + * + * @return void + */ + protected function on_registration_event(event_message $reg_message, string $topic): void { + $this->debug('Registration trace [raw]: ' . json_encode($reg_message->to_array())); + + $ext_num = ''; + foreach (['from_user', 'to_user', 'user', 'username', 'auth_username'] as $k) { + $v = trim((string)($reg_message->{$k} ?? '')); + if ($v !== '') { $ext_num = $v; break; } + } + + $domain_name = ''; + foreach (['realm', 'from_host', 'to_host', 'presence_host', 'sip_host'] as $k) { + $v = trim((string)($reg_message->{$k} ?? '')); + if ($v !== '') { $domain_name = $v; break; } + } + + // Fallback parse from SIP-style fields (e.g. sip:1001@example.com). + if ($ext_num === '' || $domain_name === '') { + foreach (['contact', 'from', 'to', 'sip_contact_uri', 'network_ip'] as $k) { + $raw = trim((string)($reg_message->{$k} ?? '')); + if ($raw === '') continue; + + if ($ext_num === '' && preg_match('/(?:sip:)?([^@;>\s]+)@/i', $raw, $m)) { + $ext_num = trim((string)$m[1]); + } + if ($domain_name === '' && preg_match('/@([^;>\s:]+(?:\:[0-9]+)?)/', $raw, $m)) { + $domain_name = trim((string)$m[1]); + } + if ($ext_num !== '' && $domain_name !== '') break; + } + } + + // Normalize ext and domain value forms. + if (strpos($ext_num, '@') !== false) { + $parts = explode('@', $ext_num, 2); + $ext_num = trim((string)$parts[0]); + if ($domain_name === '' && !empty($parts[1])) $domain_name = trim((string)$parts[1]); + } + $domain_name = preg_replace('/:\d+$/', '', $domain_name ?? ''); + + $registered = ($topic === 'sofia::register'); + $this->debug('Registration trace [parsed]: ' + . 'topic=' . $topic + . ' ext=' . $ext_num + . ' domain=' . $domain_name + . ' registered=' . ($registered ? 'true' : 'false')); + + if (empty($ext_num) || empty($domain_name)) { + $this->debug('Registration trace [drop]: missing ext/domain after parse'); + return; + } + + $this->debug("Registration change: $ext_num@$domain_name registered=" . ($registered ? 'true' : 'false')); + + $message = new websocket_message(); + $message + ->service_name(self::get_service_name()) + ->topic('registration_change') + ->status_string('ok') + ->status_code(200) + ->request_id('') + ->resource_id('') + ->payload([ + 'extension' => $ext_num, + 'domain_name' => $domain_name, + 'registered' => $registered, + ]) + ; + $this->debug('Registration trace [broadcast]: ' . json_encode([ + 'extension' => $ext_num, + 'domain_name' => $domain_name, + 'registered' => $registered, + 'service_name' => self::get_service_name(), + 'topic' => 'registration_change', + 'request_id' => $message->request_id(), + 'resource_id' => $message->resource_id(), + ])); + websocket_client::send($this->ws_client->socket(), $message); + } + + /** + * Broadcast a call or conference event to all subscribers. + * + * @param event_message $event_message + * @param string $action Topic name to use. + * + * @return void + */ + private function broadcast_call_event(event_message $event_message, string $action): void { + $message = new websocket_message(); + $message + ->service_name(self::get_service_name()) + ->topic($action) + ->payload($event_message->to_array()) + ; + websocket_client::send($this->ws_client->socket(), $message); + } + + /** + * Broadcast an already-enriched event payload. + * + * @param array $event_data + * @param string $action + * + * @return void + */ + private function broadcast_enriched_event(array $event_data, string $action): void { + $message = new websocket_message(); + $message + ->service_name(self::get_service_name()) + ->topic($action) + ->payload($event_data) + ; + websocket_client::send($this->ws_client->socket(), $message); + } + + /** + * Send an action response back to the requesting client. + * + * @param websocket_message $message + * @param bool $success + * @param string $status_message + * + * @param array $extra_payload + * + * @return void + */ + private function send_action_response(websocket_message $message, bool $success, string $status_message, array $extra_payload = []): void { + $payload = array_merge(['success' => $success, 'message' => $status_message], $extra_payload); + + $response = new websocket_message(); + $response + ->payload($payload) + ->service_name(self::get_service_name()) + ->topic('action_response') + ->status_string($success ? 'ok' : 'error') + ->status_code($success ? 200 : 400) + ->request_id($message->request_id()) + ->resource_id($message->resource_id()) + ; + websocket_client::send($this->ws_client->socket(), $response); + } + + /** + * Enrich an add_member conference event with full member data from json_list. + * + * @param event_message $event_message + * @param string $conference_name + * + * @return array + */ + private function enrich_member_event(event_message $event_message, string $conference_name): array { + $event_data = $event_message->to_array(); + $member_id = $event_data['member_id'] ?? ''; + $found = false; + + if (!empty($conference_name)) { + $json_str = trim(event_socket::api("conference '$conference_name' json_list")); + $conferences = json_decode($json_str, true); + + if (is_array($conferences) && !empty($conferences)) { + $conference = $conferences[0]; + $members = $conference['members'] ?? []; + $event_data['member_count'] = $conference['member_count'] ?? count($members); + + foreach ($members as $member) { + if ((string)($member['id'] ?? '') === (string)$member_id) { + $event_data['member'] = $member; + $found = true; + break; + } + } + } + } + + if (!$found && !empty($member_id)) { + $event_data['member'] = [ + 'id' => $member_id, + 'uuid' => $event_data['unique_id'] ?? '', + 'caller_id_name' => $event_data['caller_id_name'] ?? $event_data['caller_caller_id_name'] ?? '', + 'caller_id_number' => $event_data['caller_id_number'] ?? $event_data['caller_caller_id_number'] ?? '', + 'flags' => [ + 'can_hear' => ($event_data['hear'] ?? 'true') === 'true', + 'can_speak' => ($event_data['speak'] ?? 'true') === 'true', + 'talking' => ($event_data['talking'] ?? 'false') === 'true', + 'has_video' => ($event_data['video'] ?? 'false') === 'true', + 'has_floor' => ($event_data['floor'] ?? 'false') === 'true', + 'is_moderator' => ($event_data['member_type'] ?? '') === 'moderator', + ], + ]; + } + + $event_data['conference_name'] = $conference_name; + $event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name); + return $event_data; + } + + /** + * Enrich a del_member conference event with updated member count. + * + * @param event_message $event_message + * @param string $conference_name + * + * @return array + */ + private function enrich_del_member_event(event_message $event_message, string $conference_name): array { + $event_data = $event_message->to_array(); + $member_count = isset($event_data['conference_size']) ? (int)$event_data['conference_size'] : null; + + if ($member_count === null && !empty($conference_name)) { + $json_str = trim(event_socket::api("conference '$conference_name' json_list")); + $conferences = json_decode($json_str, true); + $member_count = (is_array($conferences) && !empty($conferences)) + ? ($conferences[0]['member_count'] ?? 0) + : 0; + } + + $event_data['member_count'] = $member_count ?? 0; + $event_data['conference_name'] = $conference_name; + $event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name); + return $event_data; + } + + /** + * Enrich a conference_create event with name and display name. + * + * @param event_message $event_message + * @param string $conference_name + * + * @return array + */ + private function enrich_conference_create_event(event_message $event_message, string $conference_name): array { + $event_data = $event_message->to_array(); + $event_data['conference_name'] = $conference_name; + $event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name); + $event_data['member_count'] = 0; + return $event_data; + } + + /** + * Extract the conference identifier (name@domain) from an event message, + * trying several common header locations in priority order. + * + * @param event_message $event_message + * + * @return string + */ + private function extract_conference_name(event_message $event_message): string { + $name = $event_message->conference_name ?? ''; + if (!empty($name)) return $name; + + $presence = $event_message->channel_presence_id ?? ''; + if (!empty($presence) && strpos($presence, '@') !== false) return $presence; + + $channel = $event_message->caller_channel_name ?? ''; + if (!empty($channel) && preg_match('/conference\+([^\/]+)/', $channel, $m)) return $m[1]; + + $dest = $event_message->caller_destination_number ?? ''; + $context = $event_message->caller_context ?? ''; + if (!empty($dest) && !empty($context)) return "$dest@$context"; + + return ''; + } + + /** + * Lookup a human-readable conference display name from cache or database. + * + * @param string $conference_identifier Full name such as "uuid@domain" or "3001@domain". + * + * @return string + */ + private function lookup_conference_display_name(string $conference_identifier): string { + if (empty($conference_identifier)) return ''; + + $parts = explode('@', $conference_identifier); + $key = $parts[0] ?? ''; + $domain_name = $parts[1] ?? ''; + + if (empty($key)) return $conference_identifier; + + if (isset($this->conference_name_cache[$key])) { + return $this->conference_name_cache[$key]; + } + + try { + $database = database::new(['config' => parent::$config]); + + if ($this->is_uuid($key)) { + $row = $database->select( + "SELECT conference_room_name FROM v_conference_rooms WHERE conference_room_uuid = :uuid", + [':uuid' => $key], + 'row' + ); + if (!empty($row['conference_room_name'])) { + $this->conference_name_cache[$key] = $row['conference_room_name']; + return $row['conference_room_name']; + } + } + + if (is_numeric($key)) { + $sql = "SELECT c.conference_name FROM v_conferences AS c " + . "LEFT JOIN v_domains AS d ON c.domain_uuid = d.domain_uuid " + . "WHERE c.conference_extension = :ext "; + $params = [':ext' => $key]; + if (!empty($domain_name)) { + $sql .= "AND d.domain_name = :domain "; + $params[':domain'] = $domain_name; + } + $row = $database->select($sql, $params, 'row'); + if (!empty($row['conference_name'])) { + $this->conference_name_cache[$key] = $row['conference_name']; + return $row['conference_name']; + } + } + } catch (\Exception $e) { + $this->debug('DB error in lookup_conference_display_name: ' . $e->getMessage()); + } + + return $key; + } + + /** + * Return true if the given string looks like a UUID. + * + * @param string $string + * + * @return bool + */ + private function is_uuid(string $string): bool { + return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $string); + } + + /** + * Look up the call-center agent name for the connected user. + * + * Compares the domain permissions context against v_call_center_agents. + * Returns empty string when the user is not a registered agent. + * + * @param array $permissions Subscriber permission map. + * @param string $domain_name Domain to scope the lookup. + * + * @return string + */ + private function get_agent_name_for_permission(array $permissions, string $domain_name): string { + // user_uuid should be in the permission metadata attached to the subscriber + $user_uuid = $permissions['_user_uuid'] ?? ''; + if (empty($user_uuid) || empty($domain_name)) return ''; + + try { + $database = database::new(['config' => parent::$config]); + $row = $database->select( + "SELECT a.agent_name FROM v_call_center_agents AS a " + . "LEFT JOIN v_domains AS d ON a.domain_uuid = d.domain_uuid " + . "WHERE a.user_uuid = :user_uuid AND d.domain_name = :domain_name", + [':user_uuid' => $user_uuid, ':domain_name' => $domain_name], + 'row' + ); + return $row['agent_name'] ?? ''; + } catch (\Exception $e) { + $this->debug('DB error in get_agent_name_for_permission: ' . $e->getMessage()); + return ''; + } + } +} diff --git a/app/operator_panel/resources/images/barge.svg b/app/operator_panel/resources/images/barge.svg new file mode 100644 index 000000000..6d8c65bf0 --- /dev/null +++ b/app/operator_panel/resources/images/barge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/operator_panel/resources/images/eavesdrop.png b/app/operator_panel/resources/images/eavesdrop.png new file mode 100644 index 0000000000000000000000000000000000000000..af19bfe8138a7d22aaf4d727d7c0b9b09b527767 GIT binary patch literal 372 zcmV-)0gL{LP)An1#t;kLc0UAgYF>iK$p-Zq$@BWg5aR-*hz7$zMflR+6b+h9yq=q?svHNA&DOf zf?$K?0S@6iaj7@>0H;}&y$qK$Y<%C}6Hz2)NGTQPdA>Ubc%HY%5+UFjjwj#<-eMaU zMX@8taW;UyS}V))aZU%W>n1!Zf(*niMCygHXuYJg- zFqh*9=MyO=Hixqc%>OQ*5m*qxETEmh&1}I?mCh*l{s?oSZ5(rfB-0Y+1&y){DclcC z8QK*;253%r#*nhLr{xff$HMBTOc$7(_pmw4<-9b9ztqagfJLf^akY5EEbfLY)e7&9 zFPzDkWe_LM;_z3t;cx@L!y1l~qwFz_I~Y5d9lIJTm_nG18rap8H9L7;X|b=kz`Q`U st0!ei7;A<^0}oHi0+t3IW)1^}l(4{cPhvOn0sY0`>FVdQ&MBb@0Fl&To&W#< literal 0 HcmV?d00001 diff --git a/app/operator_panel/resources/images/keypad_call.png b/app/operator_panel/resources/images/keypad_call.png new file mode 100644 index 0000000000000000000000000000000000000000..b3050e33a4fe03de0084e4f62436b9d07f36f884 GIT binary patch literal 483 zcmV<90UZ8`P)`@QGA`|i26wauuv#O(0nS2(b)fC7tf5h%9sDeL?yFbb0V#(EKI2ag~D+JxT# zZh-+tsK7MPyr+N&P5>7(F8o1I!E0uyz=fDR7h3vfc`~P(Kd+w~Y4yh`oPam5DEu)0 zNc!R>+tzMOxl29jKf_EKxl?GO*0jjffm#c{hFd*bV1;|(3bcsOFzu1|KOlT+=}Qo& zyFvy0;>*;>igN>NJkqgW6NMPY35oN*?naqq&Z=ibwM z&VBFwzVF;T|7<$uoLhi<=z}Ns6AiGT`2d`1UI9B$1%8A}3hJN@#@%nMK$Eg$^dz)E z6t>0S!xYmd57TY_?s4gYDY%1WQ%va6Bz&WbRtA+YF9yfZ>HdZo_6F!_HP1vu@5PWG z&|C;b0lM^-WDUmPP2U(bO&#z8&(Lo4dU$|W7!`wA@N`WH(>MX8O48`VP_EC+iNUog zUYXqLmmxhDo_^k-4pJ)T6wcuj){MTY8!2DBE(VV<6SCWj0lFA&=AJ^Mj%IGuLQQ~P zt)reX*wR_Jg=W(Yxt6osEV^UjRpVtYoi;@Y7~LRe&jFj9m-qKYa-YSa};OXKtj*fy4{qoWAn&Wv73U}_fDwFwVsWtlSjHut|=*5d541$L%jmD!-02{enC0i_( zvNvuJY;B=?J)+@|cr-$pCKAV_u3X{$?k=bM`~GU9Q62*}a=Eo^vG_}RZjSEuHeshj zyV3APTGvaHJ1+#Gw^>~-V#7_n`DssU^c-o1k;!ib{MdZAFffP8s+ z>MNznmStgE7LMao=5O69$<7X;2r+CLz>{EaFGyXzit6=H6BCukQ&VHeLZjhZN|kNf zCgC`EJ3AOBCj<~6^2GeL+YP_7?V&R{xg57zBom3xQ`fHjc+c}@Rz&1pIz8XX=fBR} zyje_jyV(6ce+B_vt$)5>tNlKX@_IgB8EIXzTP-rKi+B4rlF48?4ij1C_)p02p-LDgk$)+iA&5yQC4+|{=%WMABHqZ3D=G|2 zgc>a5J46>864(0`yXSzD!(!$u+gNli2|KbVPu#Y!ncv|yt0Rw^&ZHKT<^xO;%@a=? zaANOr5Vtw-l*xcW>juLTv!sbu43~SZGc4eCh?~h@`i@CuW$TGoGdMEYZcXc0!YNY0 z{Xwe1)%b)pd&3pxh%1WBVotLzKB{Hj=Q5e$$p6L)J4?xCrL&AF*==k}8Vy_(&U9uvo^$CW#>A7H+&lN4@4Mf5j1T^??r?Z=u&e7{Djpw-ghDYNTB%Yw+Rf)T zH#3=)tZBXrN26BZ-~c2^0-py|B&qFUZ}0Ofy8ghgD5~u^;@rX2=enRM3YfNCU*FnV z-6$3pc5}HM$Fj}{R7q;RIXL)sT-PVdCnwO6Ou{)$14UJ#W}0xE&qHQ=8%l)&G)1HE zYj1Dkx8vN2_4NFZ5YH|rlP|7~j@~X6i!d-g4(Er5fy*+~ZCfayeIynWigM{Rw70Z$ zr~q1@uD_R(t*zImM@C*+hQU)qL(rB;KgCeBHu4ZFCD$v1&D7SU4AJBH?{4m%Gb5!r@`c zQ{^1Wn4>6~U~mPALunvE4Z%cUR;z-P%qSN7IRc|}aEdQe(Ny$00)2wR6<{K<8Y)b~ z01OZ@b<+%U+%RBF$Sv&eLnsmfPBJloRFEQ1g^er4QU4iY9E{;EV`T|H=+MOg1KY?k z9h@IvzQ&l4BHVlD$aJ>Ka+P7v(^MoP@R&t#ek}>`N7kqj)5!fK>deiV`ByDt02FX+N*d z%)G`smcGBR@H7~YXUdg|VE>P)?OmvnK8|DXIonY&jhFrahYI?Y(7)qT+fU_;?=R&Vm3WS%!-5`ybZV zUp-Tld*4f?HE1-B0PO(QL(6)3vA;iC-`@v0nFKAJ2BvA?SF6z2+JegJD)_s*pjsAu z+Syrs9*tg?dwRZ!=+EwG+T7K?zH3!&4B zbexJb8Yy{X{0*Lxq>Sr0 z07&;RGNsIjfJOuWGz?X*Lx2DeZ3#+mmy5*&*BR^Z(7?mUlBxnnQJMk)a6btk30PEy z;IKQ6qjNM412T;4B7~VHaK=JsfdF_;VhHhRM@NqR4+J5E@FwSF5u*k8HZp=zDSRhG z1J;@m%E&_-fd&W9DI=eDMRAyvN^QD$AYxP^bG#5y{P>@Q^^p)tHX#^y53`ZGx(X~Y zGxH873?%GzSbcOjhLMwuqHh{oa-^(iDy!6miHWx?F+QGCMn_jFSYf)L;)PKtlNLy9 z{nH&spo5(V1_pk1Pfk8%c)6LLo4bF|-TmhfFG2|Xzy3L67xqlkK081EK+^RuVV(3? zY07*qo IM6N<$g120Ai~s-t literal 0 HcmV?d00001 diff --git a/app/operator_panel/resources/images/refresh_active.gif b/app/operator_panel/resources/images/refresh_active.gif new file mode 100644 index 0000000000000000000000000000000000000000..305457831c3f47939d634650660e859f50f18224 GIT binary patch literal 1079 zcmZ?wbhEHb6krfw_{_lY|NsA0U~uKimGkG%KYaM`#fum3-o5+$`SYz?x4wP*cIM0( z1|*>PpWDwhB-q(8z|~04fSC~}rT9$q3n zO>tew&yZDRa$!Y@S0^yIF#{CID4C^TqZC^&!;E6k-dQ&1{8}lV<;)-2jBdSFW47#` z1vIJ`t5L{)gBT?kmRz|^`|7L}S{*99_jCT;vIy5ToB7wlqaU~5kYZS7nL_(UfrqBk z(zDj8X?3K$2`jK(z145C(S#4nG^P4aV2xolq_Ej0;4GAuT$!{|vttSOYJI8o;oR@$ zJ^Oz^L6r$xSRh*s)EXo~qSeR&uoa}AU5JbTK#mcJDN5wV2$E5AwtyYvL~c4k^4lFJ R9?wLX4uT2S8kB9p001&weI)<@ literal 0 HcmV?d00001 diff --git a/app/operator_panel/resources/images/refresh_paused.png b/app/operator_panel/resources/images/refresh_paused.png new file mode 100644 index 0000000000000000000000000000000000000000..38937ec1f222094efb05cd3d3c634f752f84deb2 GIT binary patch literal 501 zcmVwBx2>F4R;%NMVn_aJxg_miiOUzd`-l3gn!7VkA&I7c*+DB!%r3QpAguz80Ve)s0dLW`~5+e4hT7Q6`Q>6PhY?hE(21}udC6bM0N(*gquPL7X19qhk(2T)25%jM!% r*LBxEygxiz%}l?3`LprpRloCJ0R6KCvd?rv00000NkvXXu0mjfpda3S literal 0 HcmV?d00001 diff --git a/app/operator_panel/resources/images/status_available.png b/app/operator_panel/resources/images/status_available.png new file mode 100644 index 0000000000000000000000000000000000000000..fc4024d2e0f1b251dc2dc51c08b20b3863bf6ca6 GIT binary patch literal 1894 zcmV-s2buVZP)fYOw+`py2*Hix5se* zOE|~FTr9mQd1*HRZBPgagAhSysQ8`s`=Fte&TTYTnUPli|JFt;fej~+uaz8^cS4Wx z2}3f5ATK2wzH1fusd~Tp{jqfcTt_vUFrENU{bz8}bZHrB{@Mo<=X=KIj)f|_;8b|X zkR^py=mTw$(=JDfCCnv7(8^~1ES-VW{*D6$HV>Vemp0GGoJ zMN$#gL(q&cf{_p^>MAgH=?ei;a3CpoJZ=F&rc8%|6(7I2#Orpbq)v!nK!B{66K;n~ zfU=6b>4t{v#B97h?wthk%Z+q>oA^bm19S8H7r1(*bQh`!03o~~Oc~a7p_p#!v2#JK z5wG@oNkRtFXlhdY;1m=V=ZsQWF$NHW)Cuchp<*x+z@2ad9R~!wVQOLxpPSHovWL_g z5f?Zzi9#u&JF9j_3qaEuiiU9)EI{-Au%7@MtvM|u)-h;7YCiv)f$_?7nQ$SZNQ#qh z9F{exN)Sp!w*AY1rf4y9GPtIP1Q;usmDq(QkrgjE&RGsthfPV4A`qH`g(3=MnltFT zZgR9*fUIt1{W-vi%W;9@Oi%gkTt!`F&#c5uAQ})hSlCbnFgqp(ho-YnS|;U)_FXOK zx4yWz(mY=Z?2FTC&IH39AZ)%{q1~Jd;*vy;HZXx@kBz|xaEb<*H zZ@O})mdvNz1IB^CP&_P1o^KGB{dj(diF3GxU-3L3>w{fM75NvOrZa zUsinxv)0T3Zqj5QywdKIbU7}{QBCbj(YELPDf8P?L6AQ@xEQz?fa}R549^^b{M0;T zv`t6lt#deWy#ghbJAf;6VQ1>8@?XYxC)WmX4SP_FappP-<$nu~67V2+v2D|g&7Oj^ zzS5@3EhxEIhFurR5U9G&`OCIt%r`l02(bLj0T|a~45mKyI*Qg62TzqB|C!bmnO~a0 zhfXEZ9I}y$Z#PU|@9mM2i)(?a==VfF;Y+%qz~}Oz`eqeyTeG0p?Wpd%l2XyPV^3OJ zfakJ@Qq`!(o=Mx?gY{w0R{AOy(5l%Ft(^3hG_za3yhS@FZgpR|Qv;_=-G}O;(iFzQ zD@Spr?kvvNU&f8bTFQ002n-LM@9a%Se#bl%=ZqAc3O|gh$b!DlygosCklyA>v#Ql_ zlysA5cj@rF!)Lb>j%Ynx7+pHG?JR2O*2-<7${I&8ZO^m{PLjxp6!m!N;0?E~>0$W=)i}T~&K=x#2RSRnx7MmMX^=cyI>gtB$SF3euhyk*Vpn ziRh{S3dnwD?VBGr2eYotXqPVD$d>z7>hR;#g9?HCfO#hlVYlxQK0%Fo<$&#CFW31+~Nw zYzZ7I$3N1fiIGKs#h5wa`N_^PxuYF2PA-^XPP(*~p<;-IRyc^0dWuZfhD z?ly1|tEplC99G}rCx%FiDuk_`hemrE?PzpGA;yxH!^q&NB#0X(l)r!Zpg$Qw`n-ut z*o|G-K(nW5aAx<@(C*iOKDc~Yk;Kgp|BRs8wv$@#DvvxGobw#%}- zck70`4OLc<9UH;WO=RRt0Zq{$fFOxU8VnqWg&JYY&0E5nsvW`W12qGuD9TT7?7AV? zY+NIN(eQ|89u#yXF~&Lbz~P+jT5v<#TB)_MC4q@cOer%%dRH(QeqG=Vy+?Bfcti5 zt-w*uQi(MJf<+f(SMtFyOacXDhTKT9_)q6J%oWTLi&--ZSbkD9i8-qDiX@-Q6td#M z#0UYk92>W#ij|!5))L8JIhKga$=M0IO@GZ2!sYCZWH#lL%Luy315gGjtON_TCAM_j z!Q>z}kA8Y?!LqGC)efBN?ioD$UMXKHVDXhM0!CR%tdV_w+!EJ~X3p`{)cDNDW9NTY zGjQnIWOh3H*wOcnxjGhHRaX6M^jKQ#UiwdC0RbLAbHY6{cxLxse*WiT&A@&4>F;$! zLZKfr#-+pkFTkvshmaN&Ac;xlml9G7I-ZCs>zW`MjKXwr8q)bJoc(kZ(lc3TjYzD1_O}6vt9Sfr$5ZAN*SUwD|A`h* z)aSmxd6yA4;xJ#zL0f&hC?%Xr5YPfJSD1r*abCn9stD+KtUg>1P0<)6>RX_zsS9J! zgzaD59y-7e+&p}KZ9(?9I4T>Cj zeCv%{L-SVtqPZZlvpT*C5_LJ4Db9dt7qO;!%3M!xoH>w6_xq+RV)^-PURfF7uDOiyeS+qh55D-NGXsv7ZWeWs5G%sX}mzNK$ z(iG%NdFWogQTgN0@vlFyzvrgk^!5!_RZ3Jv>0X&wrBi{W2c%}l{TZ)a;(psi8p?&< zdJO>Qj*zUy=Xh6f0h-k1rmnAOT!ve#u5*}qYZBNZkxr)P(?D&x_i*lsyI_QotOzNRIah@x>VXyh*;vNK)q^&EyLepe@bJgH5h_8(X8iYQMPM*U!id8xedPq5*81tG< zaw8e-Gan!&i7?y`>tQ&X{usk$0W-dh_+MWYI1Fs83Q8_-~Tx93*zx2_aF+VM02#eP&{A;`X9tVy#+E5hH?K zrhxaC4qW11GEi4sc~>URE3>@#WdbN`W0^6Kn`Am+93Q1{?x*u5GN+(LKKaM^>GxAZ zc1yGsvrrZ8@e+WGcyh}o8cODG7wuQ(-O8FH8mxo*Py?J!4!Q-?99gPj$V(j8-+K1# z;{&7bnz&ZR`V_gyMP8TdRzTRkJ1^SzyWqtk(9zfd5sbZeKJ0e}`cLf_>n6`rmkf?# z!7cv1kbn2o;1h>V{#FPztwDooP%NZ5FP8;Y0pT^Db_F!eiC9C$2x|t!jV8QD433vSHWEc_xbotfB^vhb=wZ`x?pDj0000< KMNUMnLSTY7JjFQx literal 0 HcmV?d00001 diff --git a/app/operator_panel/resources/images/status_do_not_disturb.png b/app/operator_panel/resources/images/status_do_not_disturb.png new file mode 100644 index 0000000000000000000000000000000000000000..db5ab5860fd34f8b461b13df4d9bb333e28ba2b4 GIT binary patch literal 1934 zcmV;92XXj`P)v;P(6F z_gFLnQ`gZP(QxfdDOSuVxZof+GXmHVlP?#~V1jj&K z#3UbWAfg@MGJ}O1+(mnSv0v3^TF_ZtB(oCH!suL)~0Y?sw zgfV2ne9;F8GVyT(N6|DUb6TN?8M922fEkjV7y)9ih89M1I3xkFOJinKuH*H&Pvj*K zdr!wjO2{STJY+I!(Qp_l*nLr$WX+1hqG39W*$gsZQ>XJZ_8guEakw}?UOLyC^vocL zs-e+Cqbigrv!%2GQV3{^tC>wx*Ca4zGI>CM=s=%%fxAREcQ+Gj>@Gub%S32lOd+r? zMU!*3%b7W1R*y&)uUD26+o+8TI9B>1A*vhBoEendzdy}HZeuJ);B2tA&T(>BrtMM} zOBu&cNC_cb9G{U>BRZLDeA&SA?}rXrgXpJRdH_HR_h$lcna7QH|EH-7cT?L zx4!?0sU{_o==#W=nv@U8R8t$H?|kTuA#C4w%-jVmJl_%v@hD$Ygy{?9Ct1^moy)$w(rEQ1I4&b5%C+aXAGO%~a`gI9CN9Ku&&(@=6_ij{|l%bkk=d5E&gq=7n8%SqMOu?X{Ss3=p zVyquBKr0TLCA8+*b(W>wbwVr@!b4?z^WwE12U2r$aigvlX$7OfLL4*Q@RgRZ+YoX< zaS)SOqbpf37{tTGTE9sXDFz!bui}Ew?3X^GSjTcYz2vT$n<2!)kqTDMJ$u#{zE~r( zkGl^eqN8^IL6GjDv7!o*`Z~mPJLc3xKN8b3&|~;eWKNqZI~9&g#mm{OV9~G<8a;I| z^>`OL4Q`$gDU<*gS037*$bu>>fdh^kOa{_aQ;WvRO5AL?O1ejqLURedg6Nu^gKjxl zR2`zSzREgFQ!Kb>F9!HNMl}sIgo!acItpXV$D!!?iUMjE8`^eJmqX zTfGcYb(%~?wn&QCEk)O^tq})Ygxsph4u&&2&^rT5GY3W}6h?2ftW%xRIST6htiqY9 z9=ayO{SvvcCG=(eltODt0Je;9opstMX4+Z9=UTfJ$)bi)x!E3~<;hNqFlE)FJtzsd z4Aq}11-IokAVWUba*SJY254xu*=*^uw%R~VnJx{d&&X@J#Py#t-Q)e6{;orCBoXQC z0)OPWZd*$qXLrVSha97ke_ZvKJ7q;lqbw%ohIa~_5Q4V{ zO)MQ(PnJ>k^=)NMKA78a!yaR;AT`IL_MEduvPYwbvbJXL9)zlD?63g;{Y#xAV63HG zNXE;q6dxNKslQ5YnCJwwy*Dr&a1Tp$m{cwFJ2R!02CMPLp8R_;bsA+cgY2 zecV{OR}lLy)EU&NtYIP?PyI8UqqY2NQpqxi!U+q2*A{=ZL-o+cxjFoY9cPAJyIpQo zO2{pjqK(|xqua-C1Oo7;BvSdS$ExwqX+O|D_Bd@dX+gc!PMvKJ95cr+X?U+0Od^1j zsmwlKkf&zNn(0m*oI{zHCI#4j{cz`DzrL|0hm(qBr0m^Q#y3^9G1~U8C9k|k3+l+4 ztae`Zv|Aw@_7a>Lp2VHeFkn(Y5>7hNov!l5?xgEZkn~53n3?OpQka|ozM#)PJH0{^ zH`rD?AUZ5xoJmgis{;Vy!3*mL;2waxb^Iy70LWot UK0M;4tpET307*qoM6N<$f}hQeeES%uE%k^G3Zf>OnE2$2JP;H@LQIG#G}?w>6b$uv@j;FdjPZdR3RJ2> z1f?MKfY8$JZjWtuyPbA-4!>_^XOB`UU0g#<|H;>x{&!~nzyEj7Fq)>p<2qP)jK?(G z{}PUWl$$@P+iAzc>!Kj8Vi;zbtjKPj=i3xTJul0VM*Qu_md#t`RG-hJ8m$Pnn&*Y@ z-R@cT*~KN9d9HlO%CdvsKLowKKCQLAH8eQr?}|hsuOi^}{|s)|?z%lZ$G=@&z1mh> zR17T3MTG&>AYP$?qG)jAuNLk6g>xgSs=mMJy)FCF4)T2o4MNkAs zkfcQ#nx=xyW`~t4sxk#ZID+?Qqzw)i-@0(YLQ8g5mJVhB=zn!lRZZ8JX$?9kiVS6? zrA(eHFCVAgNFN-_t#Z2y1p*@gL)4fF2?V25KN{BZ4U$jy5o~Z2-J{B zOmG9yqa5`?V>AANetOK$X$uHNRU5rNuWY1|_~OYD2c-Y$8Zz_UuFlbLID963aEemf za=m3tl16p&B|;I9DT$yox_i2{?(S}Xdt1l;w85ci@AmigZ*RKTq?i?fd@9kbi9&;5 zn}2Cme*5*(hOfW+b|h_ZJ9h0Tw%M%fRYhZtpF9Df&gTMO^OoLhYu%2I-u%dLLqY$$^XAUWbvkoFl1B8!1%ZeB z`~vhf9!+V5;o%UBjg9L0k9?2kIj~wYbvU=%t!t4sL)T!3V!74Yy1u^tUJ(l84k@;iP0#U4&T?;m$aBECNz~=v#y%$k}2c673>QI zjfLuz;V|-^G+U%0Ob^tSCoY%>XDn(Kdo>CiholWrU8ZwN(6eS03IZqAqzs2G&TMjQ zio$#M?&%RY5mdZ)Q(|qBF|Q>FZLX)sqr*`Yp8IIy*17x3@3*$^_qqGe$BWD5((~YztC!^q=Ni8uMKESd{g80v3yRO@ z3pO=1e|GHHQN2*pjs-!~tAwG6WGxx*3(0eauW=X*7NGJ+&uBb3yX^QEQdp1%zST{Xqbtb)}^4LH~)@~cKP?K zSJXSV?|irCi!VRN3xbpsrred_q6BV3aiU}`Uc9($`HISy3kve9Ii7XlZC1RO`={DW zT0wu6KvM_M5~rFkUOL>=)N})X8bk>Y+=DbXY0DIiBoT!^BM8wzVN(2}r9~}5pTx$f qI?}l|2>>(w!Fn2Q8ZJKm7GMDBuj*)ya&3VC0000QLrpfS-< ztrjd=q4Y(IJPKPn+nwH-@tk{ix77gJ#Wln*$;r&#xij~h?|kQRS94t#7wh4{MSNTW z_rC;ZT*$-E6>1?m-Qx>ln&!b|VPS+3z;CvNuS7R?)4eUrT3nIt`?YkVm4MA4An%VX zN2uhw3Blo!IT#R*;6y_;j@DJV2e#LBooqx)kBPep;LraVxaDKD)m|SeXFpe&T|DVw z=tdA8X$CY+rx`Zv9)a4ePr5r+Z8sf(r zxiH9dZ;*cb;fO9cQ_xa`Gq%|^~ z81um(@p%I|#9+3Z?>ZJjLnFBH>Dz*Ydka19&lot}hehM=Kp;OnUIJ49Al)ckDQsdb zPGcf<&$c>o+0=R3um}ohXl~}f8JJdBevem>j~vIQ#cjzB1F~t%wxa0i?x5>V3EpuO zP`(~LwJe+1o0*YRLN3wCrDO8L6Ck=fNn?p<{?u6lV%t3$0^_?x=L7O>Ai% zBgL4<@Td9qcyuctdIjTvOdlBNJhC4*8AKWua<7kB*9hK(5mN3Lx; z)_`TZUfE!CvehZ+I;1If>Di1Ai@@$#U1NXKjQJk|X2!rhQ>u*&<>3jZ2ihl#U&NVX zhu{wlmLQXXAZsF(Fn?$W+Uh>T#|zisOdC)NJoxVY{af!J*VgBYv~ivQcHXwEG9L~+ zHJjF>wH7Fv2n@fX82Lrz$R1RRXj?5hnyYd6+v7M^4^Rfd*clU0|7A^#Sl@ZN0e7v6 zxz@i1ZgHVDIW!QT-S_5Ow0?!w`0YDrtNj>9eyRrrFS$<5b4d7_(xyz(1(%aWbd+58 z5XRoL2%j$>9ozpY_G%7hFaFJqU2vXz`FhRk58#8jZ@eDLE1ZDXsU{2_eS<7Xu4BNL zT}NzzT&zHG>hn|G&lv{f7YAwX6pR>uCk4lG+_Lm=mLp0g?*9VI=+z4@n7O4T+WaeT z#fqDk)Ml9{56NW98sh}}`!CyZy0r$K?e*yHY^C53QhP=qh+uvx1{Rm2uzap;sRYrh z8@lA<)#;Piu~#l>avRbH_rh2$k37}%c-6KXR~B(Q1&$j`t_WSno6vc*4)HVX5-@|y z${U89f->Zei1ewyri|>YyfF4{U1)DxRkiKe1MV$pyVdH(qmv&i_VbrBYpOiysWyqD zk^$rsL&WZR?l|IWP zK|x42szW5|r!v*U*|PC;pT^IN7C)yFKi64NOlv&r#%+*w0Z9a(orPA zgmROe&{MM6WH++OYdLPqCc%+r!=F=tmYU77tZ@{R+OJIuoTh0W(sq7t3u*+&`Z$V|dOp?t2f|@xw=mY4mv3 zfG}J)E;lWG7for-GS7Pbr#WN=k=Y1P9^AVjDt1QkHWQKX)ISF2B=&oqyPEOJ)&&b> zq2@341qaJSte7xUu1$jK1!ksn$rZz&8wQs@e#f?zD`V}A*i;Q1<_zPWqY|98LU1rJ z7Bb#z7R&-}oiYaVD;_QL6-}x%1`NN7(n?tE8_ppa^;#9x98_H9>UXXb-)-n{4mMyN zdF3^laD*&M7(~Cy7Xf4~@YX$_EVl$0MHzEWxB%1j0ESRC$RgAKu6WZNm!dvKzBt+5 ziXD4^O$UHFx^;?yamMNwbA15v;yyC|8^N0h6fMnvwA_STm?hprvQ&h79st^ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/operator_panel/resources/javascript/operator_panel.js b/app/operator_panel/resources/javascript/operator_panel.js new file mode 100644 index 000000000..c323076b9 --- /dev/null +++ b/app/operator_panel/resources/javascript/operator_panel.js @@ -0,0 +1,2368 @@ +/** + * Live Operator Panel — JavaScript controller + * + * Three-tab, fully WebSocket-driven panel: Calls | Conferences | Agents. + * NO Ajax is used anywhere. All data arrives as WebSocket events, and all + * operator actions (hangup, transfer, eavesdrop, record, user status, agent + * status) are sent as WebSocket action requests. + * + * Depends on: + * websocket_client.js — ws_client class + * ws_config — PHP-injected WS settings + * status_colors — PHP-injected theme colours + * status_icons — PHP-injected FA icon classes + * status_tooltips — PHP-injected translated strings + * status_show_icon — PHP-injected boolean for icon visibility + * permissions — PHP-injected permission booleans + * text — PHP-injected translated strings + * domain_name — PHP-injected session domain + * user_uuid — PHP-injected session user UUID + * token — PHP-injected WS auth token {name, hash} + * user_statuses — PHP-injected array of allowed status strings + */ + +'use strict'; + +/** @type {ws_client|null} */ +let ws = null; + +let reconnect_attempts = 0; +let ping_interval_timer = null; +let ping_timeout = null; +let auth_timeout = null; +let refresh_interval_timer = null; +let pong_failure_count = 0; +let duration_tick_timer = null; +let extensions_reconcile_timer = null; +let recording_state_timer = null; +let registrations_state_timer = null; + +/** + * Live call map: uuid → call object. + * Maintained incrementally from channel events. + * @type {Map} + */ +const calls_map = new Map(); + +/** + * Live conference map: conference_name → conference object. + * @type {Map} + */ +const conferences_map = new Map(); + +/** + * Extension directory: extension_number → extension object (from DB snapshot). + * Registration status is included. Call state is derived dynamically from calls_map. + * @type {Map} + */ +const extensions_map = new Map(); + +/** + * Current agent stats list (last broadcast). + * @type {Array} + */ +let agents_list = []; + +/** Debounce timer for extensions re-render triggered by channel events. */ +let extensions_render_debounce = null; + +/** UUID of the call being dragged from the Calls tab onto an extension block. */ +let dragged_call_uuid = null; + +/** Source extension number for a dragged call (when available). */ +let dragged_call_source_extension = null; + +/** Extension number being dragged (for origination). */ +let dragged_extension = null; + +/** UUID of the call being dragged from eavesdrop icon onto an extension block. */ +let dragged_eavesdrop_uuid = null; + +/** Calls flagged as actively recording in UI state. */ +const recording_call_uuids = new Set(); + +/** Active group filters (set of lowercase group keys; empty = show all). */ +const active_group_filters = new Set(); + +/** Whether edit mode (drag to reorder cards) is active. */ +let edit_mode_active = false; + +/** SortableJS instance for edit mode. */ +let sortable_instance = null; + +/** Saved card order (array of group keys). Persisted to localStorage. */ +let saved_card_order = null; +let all_group_keys_for_filters = []; +let tabs_initialized = false; + +function is_lop_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. + } + return false; +} + +function is_lop_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. + } + return false; +} + +function lop_debug(label, data) { + const is_reg_trace = typeof label === 'string' && label.indexOf('[OP_REG_TRACE]') !== -1; + if (!is_lop_debug_enabled() && !(is_reg_trace && is_lop_reg_trace_enabled())) { + return; + } + const now = new Date(); + const ts = now.toISOString().replace('T', ' ').replace('Z', ''); + if (typeof data === 'undefined') { + console.debug(`[${ts}] ${label}`); + } else { + console.debug(`[${ts}] ${label}`, data); + } +} + +function activate_tab(button) { + if (!button) return; + + // Prefer Bootstrap Tab API when available. + if (typeof bootstrap !== 'undefined' && bootstrap.Tab) { + try { + bootstrap.Tab.getOrCreateInstance(button).show(); + return; + } catch (err) { + console.warn('[OP] Bootstrap tab show failed, using fallback:', err); + } + } + + // Fallback tab activation when Bootstrap JS API is unavailable. + const target_selector = button.getAttribute('data-bs-target'); + if (!target_selector) return; + const target = document.querySelector(target_selector); + if (!target) return; + + document.querySelectorAll('#lop_tabs .nav-link').forEach(link => { + link.classList.remove('active'); + link.setAttribute('aria-selected', 'false'); + }); + document.querySelectorAll('#lop_tab_content .tab-pane').forEach(pane => { + pane.classList.remove('show', 'active'); + }); + + button.classList.add('active'); + button.setAttribute('aria-selected', 'true'); + target.classList.add('show', 'active'); +} + +function init_tab_navigation() { + if (tabs_initialized) return; + + const buttons = document.querySelectorAll('#lop_tabs .nav-link[data-bs-target]'); + if (!buttons.length) return; + + buttons.forEach(btn => { + btn.addEventListener('click', function (event) { + event.preventDefault(); + activate_tab(btn); + }); + }); + + tabs_initialized = true; +} + +function normalize_group_key(raw_group) { + const key = ((raw_group || '') + '').trim().toLowerCase(); + return key || ''; +} + +function get_extension_group_key(ext_number) { + const ext = extensions_map.get((ext_number || '').toString()); + if (!ext) return ''; + return normalize_group_key(ext.call_group || ''); +} + +function get_call_group_key(call) { + const presence = ((call.channel_presence_id || '').split('@')[0] || '').trim(); + const dest = ((call.caller_destination_number || '') + '').trim(); + const cid = ((call.caller_caller_id_number || call.caller_id_number || '') + '').trim(); + + const candidates = [presence, dest, cid].filter(Boolean); + for (const ext of candidates) { + const key = get_extension_group_key(ext); + if (key !== '' || extensions_map.has(ext)) return key; + } + return ''; +} + +/** + * Resolve the opposite-party extension/number for a call leg. + * Prefers direct per-leg fields; if those are ambiguous (self/self), + * falls back to scanning sibling rows in calls_map for the same extension. + */ +function resolve_peer_number_for_leg(ch, ext_number, direction_raw) { + const ext = ((ext_number || '') + '').trim(); + const cid = (((ch || {}).caller_caller_id_number || (ch || {}).caller_id_number || '') + '').trim(); + const dest = (((ch || {}).caller_destination_number || '') + '').trim(); + + // Direct per-leg interpretation. + if (ext) { + if (dest === ext && cid && cid !== ext) return cid; + if (cid === ext && dest && dest !== ext) return dest; + } + + // Direction-guided fallback when one side is known and non-self. + if ((direction_raw || '') === 'inbound') { + if (cid && cid !== ext) return cid; + if (dest && dest !== ext) return dest; + } + if ((direction_raw || '') === 'outbound') { + if (dest && dest !== ext) return dest; + if (cid && cid !== ext) return cid; + } + + // Cross-leg fallback: find another row that references this extension + // and exposes a non-self opposite party. + if (ext) { + for (const other of calls_map.values()) { + if (other === ch) continue; + const ocid = ((other.caller_caller_id_number || other.caller_id_number || '') + '').trim(); + const odest = ((other.caller_destination_number || '') + '').trim(); + if (ocid === ext && odest && odest !== ext) return odest; + if (odest === ext && ocid && ocid !== ext) return ocid; + } + } + + // Last resort: return whichever side is not self, then any known side. + if (dest && dest !== ext) return dest; + if (cid && cid !== ext) return cid; + return dest || cid || ext; +} + +function set_filter_bar_visibility(show) { + ['extensions_filter_bar', 'calls_filter_bar', 'conferences_filter_bar', 'agents_filter_bar'].forEach(id => { + const bar = document.getElementById(id); + if (bar) bar.style.display = show ? '' : 'none'; + }); +} + +/** + * Connect (or reconnect) to the WebSocket server. + * Called once on page load from index.php. + */ +function connect_websocket() { + const ws_url = `wss://${window.location.host}/websockets/`; + + try { + ws = new ws_client(ws_url, token); + + // Authentication + ws.on_event('authenticated', on_authenticated); + ws.on_event('authentication_failed', on_authentication_failed); + + ws.ws.addEventListener('open', () => { + console.log('[OP] WebSocket open'); + reconnect_attempts = 0; + update_connection_status('connecting'); + + auth_timeout = setTimeout(() => { + console.error('[OP] Authentication timeout'); + update_connection_status('disconnected'); + redirect_to_login(); + }, ws_config.auth_timeout); + }); + + ws.ws.addEventListener('close', (ev) => { + console.warn('[OP] WebSocket closed, code:', ev.code); + _clear_timers(); + update_connection_status('disconnected'); + + const delay = Math.min( + ws_config.reconnect_delay * Math.pow(2, reconnect_attempts), + ws_config.max_reconnect_delay + ); + reconnect_attempts++; + console.log(`[OP] Reconnecting in ${delay}ms (attempt ${reconnect_attempts})`); + setTimeout(connect_websocket, delay); + }); + + ws.ws.addEventListener('error', (err) => { + console.error('[OP] WebSocket error:', err); + }); + + } catch (err) { + console.error('[OP] Failed to create WebSocket:', err); + update_connection_status('disconnected'); + } +} + +function on_authentication_failed() { + console.error('[OP] Authentication failed'); + update_connection_status('disconnected'); + redirect_to_login(); +} + +/** + * Called once authentication succeeds. + * Subscribes to the service topics and requests initial snapshots. + */ +function on_authenticated() { + init_tab_navigation(); + + console.log('[OP] Authenticated'); + pong_failure_count = 0; + update_connection_status('warning'); + + if (auth_timeout) { clearTimeout(auth_timeout); auth_timeout = null; } + + // Start keep-alive + send_ping(); + if (ping_interval_timer) clearInterval(ping_interval_timer); + ping_interval_timer = setInterval(send_ping, ws_config.ping_interval); + + // Start 1-second duration ticker + if (duration_tick_timer) clearInterval(duration_tick_timer); + duration_tick_timer = setInterval(tick_durations, 1000); + + // Star-code/auto-recording does not always produce consumable events, + // so poll recording state by UUID. + if (recording_state_timer) clearInterval(recording_state_timer); + recording_state_timer = setInterval(sync_recording_state, 2000); + + // Reconcile registration flags in case registration_change events are missed. + if (registrations_state_timer) clearInterval(registrations_state_timer); + if (typeof registrations_reconcile_enabled !== 'undefined' && registrations_reconcile_enabled) { + registrations_state_timer = setInterval(sync_registrations_state, 3000); + sync_registrations_state(); + } else { + lop_debug('[OP][reg][reconcile] disabled by setting registrations_reconcile_enabled'); + } + + // Register incremental event handlers before subscribing so we do not + // miss the first pushed event that can arrive immediately after subscribe. + // Call events + const call_topics = [ + 'channel_create', 'channel_callstate', 'call_update', + 'channel_destroy', 'channel_park', 'channel_unpark', 'valet_info', + ]; + call_topics.forEach(t => ws.on_event(t, on_call_event)); + ws.on_event('calls_active', on_calls_snapshot_event); + + // Conference events + const conf_topics = [ + 'conference_create', 'conference_destroy', + 'add_member', 'del_member', + 'start_talking', 'stop_talking', + 'mute_member', 'unmute_member', + 'deaf_member', 'undeaf_member', + 'floor_change', 'lock', 'unlock', + 'kick_member', 'energy_level', 'gain_level', 'volume_level', + ]; + conf_topics.forEach(t => ws.on_event(t, on_conference_event)); + + // Agent stats broadcast + ws.on_event('agent_stats', on_agent_stats); + + // Registration events (extension register / unregister) + ws.on_event('registration_change', on_registration_change); + + // Action responses + ws.on_event('action_response', on_action_response); + + // Pong handler: resolves keep-alive pings that arrive as server-pushed events + // (e.g. after reconnect when the pending request map was cleared) + ws.on_event('pong', on_pong); + + // Fire subscribe first, then immediately request initial snapshots. + // Do not wait on subscribe promise resolution because some deployments + // do not send an explicit subscribe response, which would block loading. + ws.subscribe('active.operator.panel').catch((err) => { + console.error('[OP] Failed to subscribe to service:', err); + }); + + load_extensions_snapshot(); + load_calls_snapshot(); + load_conferences_snapshot(); + load_agents_snapshot(); + sync_recording_state(); + + // One-time reconciliation shortly after auth to capture any early + // registration changes that may race initial subscription startup. + if (extensions_reconcile_timer) clearTimeout(extensions_reconcile_timer); + extensions_reconcile_timer = setTimeout(() => { + load_extensions_snapshot(); + extensions_reconcile_timer = null; + }, 500); +} + +/** Tear down all recurring timers. */ +function _clear_timers() { + if (auth_timeout) { clearTimeout(auth_timeout); auth_timeout = null; } + if (ping_timeout) { clearTimeout(ping_timeout); ping_timeout = null; } + if (ping_interval_timer) { clearInterval(ping_interval_timer); ping_interval_timer = null; } + if (refresh_interval_timer) { clearInterval(refresh_interval_timer); refresh_interval_timer = null; } + if (duration_tick_timer) { clearInterval(duration_tick_timer); duration_tick_timer = null; } + if (extensions_reconcile_timer) { clearTimeout(extensions_reconcile_timer); extensions_reconcile_timer = null; } + if (recording_state_timer) { clearInterval(recording_state_timer); recording_state_timer = null; } + if (registrations_state_timer) { clearInterval(registrations_state_timer); registrations_state_timer = null; } +} + +/** Redirect to the FusionPBX login page. */ +function redirect_to_login() { + const base = (typeof PROJECT_PATH !== 'undefined') ? PROJECT_PATH : ''; + window.location.href = base + '/?path=' + encodeURIComponent(window.location.pathname); +} + + +// Keep-alive ping / pong +function send_ping() { + if (!ws || !ws.ws || ws.ws.readyState !== WebSocket.OPEN) return; + + const had_failures = pong_failure_count > 0; + + ping_timeout = setTimeout(() => { + pong_failure_count++; + if (pong_failure_count >= ws_config.pong_timeout_max_retries) { + update_connection_status('disconnected'); + window.location.reload(); + } else { + update_connection_status('warning'); + } + }, ws_config.pong_timeout); + + ws.request('active.operator.panel', 'ping', {}) + .then(() => { + if (ping_timeout) { clearTimeout(ping_timeout); ping_timeout = null; } + pong_failure_count = 0; + update_connection_status('connected'); + if (had_failures) { + // Refresh all four snapshots after a reconnect + load_extensions_snapshot(); + load_calls_snapshot(); + load_conferences_snapshot(); + load_agents_snapshot(); + } + }) + .catch(console.error); +} + +/** + * Handle a pong that arrives as a server-pushed event rather than as a + * response to a pending request (e.g. after reconnect clears _pending). + */ +function on_pong() { + if (ping_timeout) { clearTimeout(ping_timeout); ping_timeout = null; } + pong_failure_count = 0; + update_connection_status('connected'); +} + +function update_connection_status(state) { + const el = document.getElementById('connection_status'); + if (!el) return; + + const color = (status_colors && status_colors[state]) || '#6c757d'; + const tooltip = (status_tooltips && status_tooltips[state]) || state; + + el.title = tooltip; + el.style.backgroundColor = color; + el.style.color = '#fff'; + + const icon_el = document.getElementById('connection_status_icon'); + if (icon_el) { + const icon = (status_icons && status_icons[state]) || ''; + icon_el.className = icon; + icon_el.style.marginRight = '5px'; + } + + const text_el = document.getElementById('connection_status_text'); + if (text_el) { + text_el.textContent = tooltip; + } +} + +function esc(text) { + if (text === null || text === undefined) return ''; + return text.toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Format a Unix microsecond timestamp as elapsed time hh:mm:ss */ +function format_elapsed(us_timestamp) { + if (!us_timestamp || us_timestamp === '0') return '--:--:--'; + const start = Math.floor(Number(us_timestamp) / 1000000); + const now = Math.floor(Date.now() / 1000); + let sec = Math.max(0, now - start); + const h = Math.floor(sec / 3600); sec -= h * 3600; + const m = Math.floor(sec / 60); sec -= m * 60; + return [h, m, sec].map(n => String(n).padStart(2, '0')).join(':'); +} + +/** Update all visible duration elements every second. */ +function tick_durations() { + document.querySelectorAll('[data-created]').forEach(el => { + const ts = el.getAttribute('data-created'); + if (ts && ts !== '0') { + el.textContent = format_elapsed(ts); + } + }); +} + +/** Format a Unix-second timestamp as HH:MM */ +function format_time(unix_seconds) { + if (!unix_seconds || unix_seconds === '0') return '--:--'; + const d = new Date(Number(unix_seconds) * 1000); + return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); +} + +/** Normalize the call UUID field across snapshot and event payload variants. */ +function get_call_uuid(ch) { + if (!ch || typeof ch !== 'object') return ''; + return ch.unique_id || ch.uuid || ch.channel_uuid || ''; +} + +/** + * Request a full calls snapshot from the service. + * Snapshot response arrives as a topic='calls_active' event. + */ +function load_calls_snapshot() { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) return; + ws.request('active.operator.panel', 'calls_active', {domain_name: domain_name}) + .then(response => { + apply_calls_snapshot(response.payload || []); + }) + .catch(console.error); +} + +/** Apply a full calls snapshot array/object to calls_map and refresh UI. */ +function apply_calls_snapshot(payload) { + const channels = Array.isArray(payload) + ? payload + : ((payload && Array.isArray(payload.rows)) ? payload.rows : []); + + calls_map.clear(); + channels.forEach(ch => { + const uuid = get_call_uuid(ch); + if (uuid) calls_map.set(uuid, ch); + }); + + render_calls_tab(); + // Re-render extensions so their active/idle state reflects calls snapshot. + schedule_extensions_render(); +} + +/** Handle server-pushed full calls snapshot event (topic='calls_active'). */ +function on_calls_snapshot_event(event) { + const payload = event.payload || event.data || event; + apply_calls_snapshot(payload); +} + +/** + * Handle incremental call / channel events from FreeSWITCH. + * @param {object} event + */ +function on_call_event(event) { + const name = (event.event_name || event.topic || '').toLowerCase(); + const uuid = get_call_uuid(event); + + if (!uuid) return; + + switch (name) { + case 'channel_create': + case 'channel_callstate': + case 'call_update': + case 'channel_park': + case 'channel_unpark': + case 'valet_info': + // Merge/upsert into the map + calls_map.set(uuid, Object.assign(calls_map.get(uuid) || {}, event)); + break; + + case 'channel_destroy': + recording_call_uuids.delete(uuid); + calls_map.delete(uuid); + break; + + default: + calls_map.set(uuid, Object.assign(calls_map.get(uuid) || {}, event)); + break; + } + + schedule_extensions_render(); + render_calls_tab(); +} + +function call_is_recording(ch, uuid) { + if (ch && (ch.is_recording === true || ch.is_recording === 'true' || ch.is_recording === 1 || ch.is_recording === '1')) return true; + const app = ((ch && (ch.variable_current_application || ch.application)) || '').toString().toLowerCase(); + const app_data = ((ch && ch.application_data) || '').toString().toLowerCase(); + if (app.indexOf('record') !== -1 || app_data.indexOf('record') !== -1) return true; + if (uuid && recording_call_uuids.has(uuid)) return true; + return false; +} + +function sync_recording_state() { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) return; + const uuids = Array.from(calls_map.keys()); + if (!uuids.length) return; + + send_action('recording_state', { uuids }) + .then((response) => { + const payload = response && response.payload ? response.payload : response; + const states = payload && payload.states ? payload.states : null; + if (!states || typeof states !== 'object') return; + + // If either leg reports recording, treat all linked UUIDs as recording. + const effective_recording = new Set(); + Object.entries(states).forEach(([id, is_rec]) => { + if (!is_rec) return; + get_conversation_call_uuids(id).forEach(linked_id => effective_recording.add(linked_id)); + effective_recording.add(id); + }); + + let changed = false; + uuids.forEach((id) => { + const ch = calls_map.get(id); + if (!ch) return; + const next = effective_recording.has(id); + if (!!ch.is_recording !== next) { + ch.is_recording = next; + changed = true; + } + if (next) recording_call_uuids.add(id); + else recording_call_uuids.delete(id); + }); + + if (changed) { + render_calls_tab(); + schedule_extensions_render(); + } + }) + .catch(() => { + // Silent polling failure handling. + }); +} + +function sync_registrations_state() { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) return; + if (extensions_map.size === 0) return; + + send_action('registrations_state', {}) + .then((response) => { + const payload = response && response.payload ? response.payload : response; + const states = payload && payload.states ? payload.states : null; + if (!states || typeof states !== 'object') { + lop_debug('[OP][reg][reconcile][drop] no states payload', { payload_keys: payload ? Object.keys(payload) : [] }); + return; + } + lop_debug('[OP][reg][reconcile][states]', { + state_count: Object.keys(states).length, + has_555: Object.prototype.hasOwnProperty.call(states, '555'), + has_102: Object.prototype.hasOwnProperty.call(states, '102'), + }); + + let changed = false; + extensions_map.forEach((ext, ext_num) => { + const count = Number(states[ext_num] || 0); + const next_registered = count > 0; + const prev_registered = !!ext.registered; + const prev_count = Number(ext.registration_count || 0); + if (prev_registered !== next_registered || prev_count !== count) { + ext.registered = next_registered; + ext.registration_count = count; + changed = true; + lop_debug('[OP][reg][reconcile]', { extension: ext_num, prev_registered, next_registered, prev_count, count }); + } + }); + + if (changed) { + schedule_extensions_render(); + } + }) + .catch(() => { + // silent polling failure + }); +} + +function get_linked_call_uuids(uuid) { + const linked = new Set(); + if (!uuid) return linked; + linked.add(uuid); + + const ch = calls_map.get(uuid); + if (ch) { + const direct = [ch.other_leg_unique_id, ch.variable_bridge_uuid, ch.bridge_uuid] + .map(v => ((v || '') + '').trim()) + .filter(Boolean); + direct.forEach(v => linked.add(v)); + } + + // Reverse lookup for legs that reference this UUID. + for (const [other_uuid, other_ch] of calls_map.entries()) { + if (other_uuid === uuid) continue; + const refs = [other_ch.other_leg_unique_id, other_ch.variable_bridge_uuid, other_ch.bridge_uuid] + .map(v => ((v || '') + '').trim()); + if (refs.includes(uuid)) linked.add(other_uuid); + } + + return linked; +} + +function get_conversation_key(ch) { + if (!ch) return ''; + const ext = (((ch.channel_presence_id || '').split('@')[0]) || '').trim(); + const cid = ((ch.caller_caller_id_number || ch.caller_id_number || '') + '').trim(); + const dest = ((ch.caller_destination_number || '') + '').trim(); + let direction_raw = (ch.call_direction || ch.variable_call_direction || '').toString().toLowerCase(); + if (ext && cid && dest) { + if (ext === cid && ext !== dest) direction_raw = 'outbound'; + else if (ext === dest && ext !== cid) direction_raw = 'inbound'; + } + const peer = resolve_peer_number_for_leg(ch, ext, direction_raw); + if (!ext || !peer || ext === peer) return ''; + return [ext, peer].sort().join('|'); +} + +function get_conversation_call_uuids(uuid) { + const all = new Set(get_linked_call_uuids(uuid)); + const seed = calls_map.get(uuid); + if (!seed) return all; + const key = get_conversation_key(seed); + if (!key) return all; + for (const [id, ch] of calls_map.entries()) { + if (get_conversation_key(ch) === key) all.add(id); + } + return all; +} + +/** + * Render the Calls tab from the in-memory calls_map. + */ +function render_calls_tab() { + const container = document.getElementById('calls_container'); + if (!container) return; + + const calls = Array.from(calls_map.values()); + + // Update badge + const badge = document.getElementById('calls_count'); + if (badge) badge.textContent = calls.length; + + if (calls.length === 0) { + container.innerHTML = `

${esc(text['label-no_calls_active'] || 'No active calls.')}

`; + return; + } + + let html = "
\n"; + html += "\n"; + html += "\n"; + html += `\n`; + html += `\n`; + html += `\n`; + html += `\n`; + html += `\n`; + html += `\n`; + html += "\n"; + + calls.forEach(ch => { + const uuid_raw = get_call_uuid(ch); + if (!uuid_raw) return; + const uuid = esc(uuid_raw); + const group_key = esc(get_call_group_key(ch)); + const ext_number = ((ch.channel_presence_id || '').split('@')[0] || '').trim(); + const raw_cid_name = (ch.caller_caller_id_name || ch.caller_id_name || '').toString(); + const raw_cid_num = (ch.caller_caller_id_number || ch.caller_id_number || '').toString().trim(); + const raw_dest_num = (ch.caller_destination_number || '').toString().trim(); + + // Derive per-leg direction so B-leg rows do not incorrectly show outbound. + let direction_raw = (ch.call_direction || ch.variable_call_direction || '').toString().toLowerCase(); + if (ext_number && raw_cid_num && raw_dest_num) { + if (ext_number === raw_cid_num && ext_number !== raw_dest_num) { + direction_raw = 'outbound'; + } + else if (ext_number === raw_dest_num && ext_number !== raw_cid_num) { + direction_raw = 'inbound'; + } + } + + const peer_number = resolve_peer_number_for_leg(ch, ext_number, direction_raw); + let caller_number_raw = (ext_number || raw_cid_num || '').toString().trim(); + + // If this leg is ambiguous (same number on both CID and destination), + // show the inferred peer as the leg owner for inbound perspective. + if (direction_raw === 'inbound' && raw_cid_num && raw_dest_num && raw_cid_num === raw_dest_num && peer_number && raw_cid_num !== peer_number) { + caller_number_raw = peer_number; + } + if (caller_number_raw === peer_number) { + if (raw_dest_num && raw_dest_num !== peer_number) caller_number_raw = raw_dest_num; + else if (raw_cid_num && raw_cid_num !== peer_number) caller_number_raw = raw_cid_num; + } + + const cid_name = esc(raw_cid_name); + const cid_number = esc(caller_number_raw); + const dest = esc(peer_number || raw_dest_num || ext_number); + const state = esc(ch.channel_call_state || ch.answer_state || ''); + const direction = esc(direction_raw); + const direction_icon = direction_raw === 'inbound' + ? '../operator_panel/resources/images/inbound.png' + : (direction_raw === 'outbound' ? '../operator_panel/resources/images/outbound.png' : ''); + const created_ts = ch.caller_channel_created_time || '0'; + const elapsed = esc(format_elapsed(created_ts)); + const is_recording = call_is_recording(ch, uuid_raw); + const record_icon = is_recording + ? '../operator_panel/resources/images/recording.png' + : '../operator_panel/resources/images/record.png'; + + html += `\n`; + const show_cid_name = raw_cid_name && raw_cid_name.toLowerCase() !== 'outbound call' && raw_cid_name.toLowerCase() !== 'inbound call'; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n"; + html += "\n"; + }); + + html += "
${esc(text['label-caller_id'] || 'Caller ID')}${esc(text['label-destination'] || 'Destination')}${esc(text['label-state'] || 'State')}${esc(text['label-duration'] || 'Duration')}${esc(text['label-actions'] || 'Actions')}
${direction_icon ? `${direction}` : ''}${show_cid_name ? `${cid_name}
${cid_number}` : cid_number}
${dest}${state}${elapsed}\n`; + + if (permissions.operator_panel_hangup) { + html += ` ` + + `${esc(text['button-hangup'] || 'Hangup')} `; + } + if (permissions.operator_panel_eavesdrop) { + html += ` ` + + `${esc(text['button-eavesdrop'] || 'Eavesdrop')} `; + } + if (permissions.operator_panel_coach) { + html += ` ` + + `${esc(text['button-whisper'] || 'Whisper')} `; + html += ` ` + + `${esc(text['button-barge'] || 'Barge')} `; + } + if (permissions.operator_panel_record) { + html += ` ` + + `${esc(text['button-record'] || 'Record')} `; + } + + html += "
\n
\n"; + container.innerHTML = html; + apply_calls_filters(); +} + +/** + * Request a full conferences snapshot. + */ +function load_conferences_snapshot() { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) return; + ws.request('active.operator.panel', 'conferences_active', {domain_name: domain_name}) + .then(response => { + const conferences = response.payload || []; + conferences_map.clear(); + conferences.forEach(conf => { + if (conf.conference_name) conferences_map.set(conf.conference_name, conf); + }); + render_conferences_tab(); + }) + .catch(console.error); +} + +/** + * Handle incremental conference events. + * @param {object} event + */ +function on_conference_event(event) { + const action = (event.event_name || event.topic || '').toLowerCase(); + const conference_name = event.conference_name || event.channel_presence_id || ''; + + if (!conference_name) return; + + switch (action) { + case 'conference_create': { + conferences_map.set(conference_name, { + conference_name: conference_name, + conference_display_name: event.conference_display_name || '', + member_count: 0, + members: [], + }); + break; + } + + case 'conference_destroy': { + conferences_map.delete(conference_name); + break; + } + + case 'add_member': { + const conf = conferences_map.get(conference_name) || { + conference_name, + conference_display_name: event.conference_display_name || '', + member_count: 0, + members: [], + }; + // Upsert member + const member = event.member || { id: event.member_id, uuid: event.unique_id, + caller_id_name: event.caller_id_name || event.caller_caller_id_name || '', + caller_id_number: event.caller_id_number || event.caller_caller_id_number || '', + flags: { + can_hear: (event.hear || 'true') === 'true', + can_speak: (event.speak || 'true') === 'true', + talking: (event.talking || 'false') === 'true', + has_video: (event.video || 'false') === 'true', + has_floor: (event.floor || 'false') === 'true', + is_moderator: (event.member_type || '') === 'moderator', + } + }; + const members = conf.members || []; + const idx = members.findIndex(m => String(m.id) === String(member.id)); + if (idx >= 0) members[idx] = member; else members.push(member); + conf.members = members; + conf.member_count = event.member_count || members.length; + conferences_map.set(conference_name, conf); + break; + } + + case 'del_member': { + const conf = conferences_map.get(conference_name); + if (!conf) break; + const members = (conf.members || []).filter(m => String(m.id) !== String(event.member_id)); + conf.members = members; + conf.member_count = event.member_count !== undefined ? event.member_count : members.length; + conferences_map.set(conference_name, conf); + break; + } + + case 'start_talking': + case 'stop_talking': + case 'mute_member': + case 'unmute_member': + case 'deaf_member': + case 'undeaf_member': + case 'floor_change': { + const conf = conferences_map.get(conference_name); + if (!conf) break; + const members = conf.members || []; + const member = members.find(m => String(m.id) === String(event.member_id)); + if (member && member.flags) { + if (action === 'start_talking') member.flags.talking = true; + if (action === 'stop_talking') member.flags.talking = false; + if (action === 'mute_member') member.flags.can_speak = false; + if (action === 'unmute_member') member.flags.can_speak = true; + if (action === 'deaf_member') member.flags.can_hear = false; + if (action === 'undeaf_member') member.flags.can_hear = true; + if (action === 'floor_change') { members.forEach(m => { if (m.flags) m.flags.has_floor = false; }); member.flags.has_floor = true; } + } + conferences_map.set(conference_name, conf); + break; + } + + default: + break; + } + + render_conferences_tab(); +} + +/** + * Render the Conferences tab from the in-memory conferences_map. + */ +function render_conferences_tab() { + const container = document.getElementById('conferences_container'); + if (!container) return; + + const conferences = Array.from(conferences_map.values()); + + const badge = document.getElementById('conferences_count'); + if (badge) badge.textContent = conferences.length; + + if (conferences.length === 0) { + container.innerHTML = `

${esc(text['label-no_conferences_active'] || 'No active conferences.')}

`; + return; + } + + let html = ''; + + conferences.forEach(conf => { + const name = conf.conference_name || ''; + const display = conf.conference_display_name || name.split('@')[0] || name; + const count = conf.member_count || (conf.members || []).length; + const members = conf.members || []; + const conf_group_keys = new Set(); + members.forEach(m => { + const n = ((m.caller_id_number || '') + '').trim(); + if (!n) return; + conf_group_keys.add(get_extension_group_key(n)); + }); + if (conf_group_keys.size === 0) conf_group_keys.add(''); + const conf_group_attr = esc(Array.from(conf_group_keys).join(',')); + + html += `
\n`; + html += `
\n`; + html += ` ${esc(display)}\n`; + html += ` ${count} ${esc(text['label-members'] || 'members')}\n`; + html += `
\n`; + + if (members.length > 0) { + html += `
\n`; + html += ` `; + html += ` `; + html += ` `; + html += ` `; + if (permissions.operator_panel_hangup || permissions.operator_panel_manage) { + html += ` `; + } + html += ` \n`; + + members.forEach(m => { + const mid = esc(String(m.id || '')); + const muuid = esc(m.uuid || ''); + const cid = esc(m.caller_id_name || ''); + const cid_num = esc(m.caller_id_number || ''); + const flags = m.flags || {}; + + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + + if (permissions.operator_panel_hangup || permissions.operator_panel_manage) { + html += ` \n`; + } + + html += ` \n`; + }); + + html += `
${esc(text['label-caller_id'] || 'Caller ID')}${esc(text['label-member_id'] || 'ID')}${esc(text['label-flags'] || 'Flags')}${esc(text['label-actions'] || 'Actions')}
${cid ? `${cid}
${cid_num}` : cid_num}
${mid}`; + html += ` ${esc(text['label-talking'] || 'Talking')}`; + html += ` ${esc(text['label-muted'] || 'Muted')}`; + html += ` ${esc(text['label-deaf'] || 'Deaf')}`; + if (flags.has_floor) html += ` ${esc(text['label-floor'] || 'Floor')}`; + if (flags.is_moderator) html += ` ${esc(text['label-moderator'] || 'Moderator')}`; + html += ` \n`; + if (permissions.operator_panel_hangup) { + html += ` ` + + `${esc(text['button-hangup'] || 'Hangup')} `; + } + if (permissions.operator_panel_manage) { + html += ` ` + + `${esc(text['button-transfer'] || 'Transfer')} `; + } + html += `
\n`; + } + + html += `
\n`; + }); + + container.innerHTML = html; + apply_conferences_filters(); +} + +/** + * Request a full agents snapshot. + */ +function load_agents_snapshot() { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) return; + ws.request('active.operator.panel', 'agents_active', {domain_name: domain_name}) + .then(response => { + agents_list = response.payload || []; + render_agents_tab(); + }) + .catch(console.error); +} + +/** + * Handle the server-pushed agent stats broadcast (timer-driven every N seconds). + * @param {object} event + */ +function on_agent_stats(event) { + agents_list = event.payload || event.data || []; + render_agents_tab(); +} + +/** + * Status → Bootstrap badge colour helper. + * @param {string} status + * @returns {string} + */ +function agent_status_class(status) { + switch ((status || '').toLowerCase()) { + case 'available': return 'bg-success'; + case 'available (on demand)': return 'bg-info'; + case 'on break': return 'bg-warning text-dark'; + case 'do not disturb': return 'bg-secondary'; + case 'logged out': return 'bg-dark'; + default: return 'bg-secondary'; + } +} + +/** + * Render the Agents tab from the in-memory agents_list. + */ +function render_agents_tab() { + const container = document.getElementById('agents_container'); + if (!container) return; + + const badge = document.getElementById('agents_count'); + if (badge) badge.textContent = agents_list.length; + + if (agents_list.length === 0) { + container.innerHTML = `

${esc(text['label-no_agents'] || 'No agents.')}

`; + return; + } + + let html = "
\n"; + html += "\n"; + html += "\n"; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + if (permissions.operator_panel_manage) { + html += ` \n`; + } + html += "\n"; + + agents_list.forEach(agent => { + const name = esc(agent.agent_name || ''); + const queue = esc(agent.queue_name || agent.queue_extension || ''); + const group_key = esc(get_extension_group_key(agent.queue_extension || '')); + const status = agent.status || ''; + const state = esc(agent.state || ''); + const answered = esc(agent.calls_answered || '0'); + const talk_time = esc(agent.talk_time || '0'); + const last_call = format_time(agent.last_bridge_start || 0); + const status_cls = agent_status_class(status); + + html += `\n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + html += ` \n`; + + if (permissions.operator_panel_manage) { + html += ` \n`; + } + + html += `\n`; + }); + + html += "
${esc(text['label-agent'] || 'Agent')}${esc(text['label-queue'] || 'Queue')}${esc(text['label-status'] || 'Status')}${esc(text['label-state'] || 'State')}${esc(text['label-calls_answered'] || 'Answered')}${esc(text['label-talk_time'] || 'Talk Time')}${esc(text['label-last_call'] || 'Last Call')}${esc(text['label-actions'] || 'Actions')}
${name}${queue}${esc(status)}${state}${answered}${talk_time}${esc(last_call)}\n`; + // Status change select + html += ` \n`; + html += `
\n
\n"; + container.innerHTML = html; + apply_agents_filters(); +} + +/** + * Send a generic action to the service. + * @param {string} action + * @param {object} extra Additional payload fields. + * @returns {Promise} + */ +function send_action(action, extra) { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) { + console.error('[OP] Cannot send action: WebSocket not connected'); + return Promise.reject(new Error('Not connected')); + } + return ws.request('active.operator.panel', 'action', Object.assign({ action, domain_name }, extra)); +} + +/** Handle action responses from the server. */ +function on_action_response(event) { + const payload = event.payload || event; + if (!payload.success) { + console.warn('[OP] Action failed:', payload.message); + show_toast(payload.message || 'Action failed', 'danger'); + } +} + +function action_hangup(uuid) { + if (!confirm(text['label-confirm_hangup'] || 'Hang up this call?')) return; + send_action('hangup', { uuid }).catch(console.error); +} + +/** Open the transfer modal for the given UUID. */ +function open_transfer_modal(uuid) { + const uuid_field = document.getElementById('transfer_uuid'); + const dest_field = document.getElementById('transfer_destination'); + if (!uuid_field || !dest_field) return; + + uuid_field.value = uuid; + dest_field.value = ''; + + const modal_el = document.getElementById('transfer_modal'); + if (!modal_el) return; + + const modal = bootstrap.Modal.getOrCreateInstance(modal_el); + modal.show(); + setTimeout(() => dest_field.focus(), 350); +} + +/** Called by the Transfer button inside the modal. */ +function confirm_transfer() { + const uuid = (document.getElementById('transfer_uuid') || {}).value || ''; + const destination = (document.getElementById('transfer_destination') || {}).value || ''; + + if (!uuid || !destination) { + show_toast(text['label-destination_required'] || 'Please enter a destination.', 'warning'); + return; + } + + // Close modal + const modal_el = document.getElementById('transfer_modal'); + if (modal_el) bootstrap.Modal.getInstance(modal_el)?.hide(); + + send_action('transfer', { uuid, destination, context: domain_name }).catch(console.error); +} + +function action_eavesdrop(uuid) { + if (!uuid) return; + + // If user has exactly one extension, use it directly (no prompt). + if (Array.isArray(user_own_extensions) && user_own_extensions.length === 1) { + send_action('eavesdrop', { uuid, destination: user_own_extensions[0], destination_extension: user_own_extensions[0] }) + .then(() => show_toast(text['button-eavesdrop'] || 'Eavesdrop started', 'success')) + .catch((err) => { + console.error(err); + show_toast((err && err.message) || 'Eavesdrop failed', 'danger'); + }); + return; + } + + const ext = prompt(text['label-your_extension'] || 'Your extension to receive the call:'); + if (!ext) return; + send_action('eavesdrop', { uuid, destination: ext, destination_extension: ext }) + .then(() => show_toast(text['button-eavesdrop'] || 'Eavesdrop started', 'success')) + .catch((err) => { + console.error(err); + show_toast((err && err.message) || 'Eavesdrop failed', 'danger'); + }); +} + +function action_whisper(uuid) { + action_monitor_mode('whisper', uuid, text['button-whisper'] || 'Whisper started'); +} + +function action_barge(uuid) { + action_monitor_mode('barge', uuid, text['button-barge'] || 'Barge started'); +} + +function action_monitor_mode(mode, uuid, success_message) { + if (!uuid) return; + + if (Array.isArray(user_own_extensions) && user_own_extensions.length === 1) { + const ext = user_own_extensions[0]; + send_action(mode, { uuid, destination: ext, destination_extension: ext }) + .then(() => show_toast(success_message, 'success')) + .catch((err) => { + console.error(err); + show_toast((err && err.message) || (mode + ' failed'), 'danger'); + }); + return; + } + + const ext = prompt(text['label-your_extension'] || 'Your extension to receive the call:'); + if (!ext) return; + + send_action(mode, { uuid, destination: ext, destination_extension: ext }) + .then(() => show_toast(success_message, 'success')) + .catch((err) => { + console.error(err); + show_toast((err && err.message) || (mode + ' failed'), 'danger'); + }); +} + +/** Called when dragging the eavesdrop icon onto an extension block. */ +function on_eavesdrop_dragstart(uuid, event) { + event.stopPropagation(); + dragged_eavesdrop_uuid = uuid; + dragged_call_uuid = null; + dragged_extension = null; + event.dataTransfer.setData('text/plain', uuid); + event.dataTransfer.setData('application/x-op-eavesdrop-uuid', uuid); + event.dataTransfer.effectAllowed = 'copy'; + set_drag_visual_state(true); +} + +function action_record(uuid) { + const ch = calls_map.get(uuid); + const stopping = call_is_recording(ch, uuid); + send_action('record', { uuid, stop: stopping }) + .then(() => { + const related = get_conversation_call_uuids(uuid); + related.forEach((id) => { + if (stopping) recording_call_uuids.delete(id); + else recording_call_uuids.add(id); + const leg = calls_map.get(id); + if (leg) leg.is_recording = !stopping; + }); + render_calls_tab(); + schedule_extensions_render(); + }) + .catch(console.error); +} + +/** + * Send user status change through WebSocket. + * Triggered by the status dropdown in the action bar. + * @param {string} status + */ +function send_user_status(status) { + send_action('user_status', { status, user_uuid }).catch(console.error); +} + +function action_agent_status(agent_name, status) { + send_action('agent_status', { agent_name, status }).catch(console.error); +} + +/** + * Show a brief Bootstrap toast notification. + * @param {string} message + * @param {string} [variant='info'] Bootstrap color variant. + */ +function show_toast(message, variant) { + variant = variant || 'info'; + let container = document.getElementById('lop_toast_container'); + if (!container) { + container = document.createElement('div'); + container.id = 'lop_toast_container'; + container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; + container.style.zIndex = '9999'; + document.body.appendChild(container); + } + + const toast_el = document.createElement('div'); + toast_el.className = `toast align-items-center text-bg-${variant} border-0`; + toast_el.setAttribute('role', 'alert'); + toast_el.setAttribute('aria-live', 'assertive'); + toast_el.innerHTML = ` +
+
${esc(message)}
+ +
`; + + container.appendChild(toast_el); + const toast = new bootstrap.Toast(toast_el, { delay: 4000 }); + toast.show(); + toast_el.addEventListener('hidden.bs.toast', () => toast_el.remove()); +} + +/** + * Request a full extensions snapshot from the service. + */ +function load_extensions_snapshot() { + if (!ws || ws.ws.readyState !== WebSocket.OPEN) return; + ws.request('active.operator.panel', 'extensions_active', {domain_name: domain_name}) + .then(response => { + extensions_map.clear(); + (response.payload || []).forEach(ext => { + if (ext.extension) extensions_map.set(ext.extension, ext); + }); + render_extensions_tab(); + }) + .catch(console.error); +} + +/** + * Derive call state for a given extension number by scanning calls_map. + * @param {string} ext_number + * @returns {{ state: string, call_uuid: string|null, call_info: object|null }} + */ +function get_extension_call_state(ext_number) { + const candidates = []; + + for (const [uuid, ch] of calls_map) { + const presence = (ch.channel_presence_id || '').split('@')[0]; + const dest = ch.caller_destination_number || ''; + const cid_num = ch.caller_caller_id_number || ''; + const context = ch.caller_context || ''; + + const matches = (presence === ext_number) + || (dest === ext_number) + || (cid_num === ext_number) + || ((dest === ext_number || cid_num === ext_number) && (context === domain_name || context === 'default')); + + if (!matches) continue; + + const cs = (ch.channel_call_state || '').toUpperCase(); + const as = (ch.answer_state || '').toUpperCase(); + + let state = 'active'; + if (cs === 'HELD' || as === 'HELD') { + state = 'held'; + } else if (cs.indexOf('RING') !== -1 || as.indexOf('RING') !== -1 || as === 'EARLY') { + // EARLY and RING* states map to offering/ringing from the panel perspective. + state = 'ringing'; + } + + // Pick the most relevant leg for this extension first. + let leg_score = 0; + if (presence === ext_number) leg_score = 4; + else if (dest === ext_number) leg_score = 3; + else if (cid_num === ext_number) leg_score = 2; + else leg_score = 1; + + const state_score = (state === 'ringing') ? 3 : (state === 'active' ? 2 : (state === 'held' ? 1 : 0)); + + candidates.push({ uuid, ch, state, leg_score, state_score }); + } + + if (candidates.length === 0) { + return { state: 'idle', call_uuid: null, call_info: null }; + } + + candidates.sort((a, b) => { + if (b.leg_score !== a.leg_score) return b.leg_score - a.leg_score; + return b.state_score - a.state_score; + }); + + const best = candidates[0]; + return { state: best.state, call_uuid: best.uuid, call_info: best.ch }; +} + +/** + * Render one extension block as an HTML string. + * @param {object} ext + * @param {boolean} is_mine True when this extension belongs to the logged-in user. + * @returns {string} + */ +function render_ext_block(ext, is_mine) { + const num = ext.extension || ''; + const raw_name = ext.effective_caller_id_name || ext.description || ''; + const show_name = raw_name && raw_name !== num; + const dnd = (ext.do_not_disturb || '') === 'true'; + const reg = ext.registered === true; + const voicemail_enabled = (ext.voicemail_enabled || '') === 'true'; + const { state, call_uuid, call_info } = get_extension_call_state(num); + + const user_status_raw = (ext.user_status || '').trim(); + + let css_state; + if (!reg) { + css_state = 'op-ext-unregistered'; + } else if (state === 'ringing') { + css_state = 'op-ext-ringing'; + } else if (state === 'active') { + css_state = 'op-ext-active'; + } else if (state === 'held') { + css_state = 'op-ext-held'; + } else if (dnd || user_status_raw === 'Do Not Disturb') { + css_state = 'op-ext-dnd'; + } else if (user_status_raw === 'On Break') { + css_state = 'op-ext-on-break'; + } else if (user_status_raw === 'Available' || user_status_raw === 'Available (On Demand)') { + css_state = 'op-ext-available'; + } else { + // Registered with no explicit/active status — blue + css_state = 'op-ext-registered'; + } + + // Icon color per state + const icon_colors = { + 'op-ext-available': '#1e7e34', + 'op-ext-on-break': '#8a6508', + 'op-ext-dnd': '#a71d2a', + 'op-ext-registered': '#2b6cb0', + 'op-ext-logged-out': '#6c757d', + 'op-ext-unregistered': '#6c757d', + 'op-ext-ringing': '#0e6882', + 'op-ext-active': '#2a7a2b', + 'op-ext-held': '#1a6c7a' + }; + const icon_color = icon_colors[css_state] || '#7a8499'; + + // Only show a state label when something notable is happening + let state_label = ''; + if (dnd && reg) { + state_label = text['label-do_not_disturb'] || 'Do Not Disturb'; + } else if (reg && state !== 'idle') { + switch (state) { + case 'ringing': state_label = text['label-ringing'] || 'Ringing\u2026'; break; + case 'active': state_label = text['label-on_call'] || 'On Call'; break; + case 'held': state_label = text['label-on_hold'] || 'On Hold'; break; + } + if (call_info) { + const call_dest = ((call_info.caller_destination_number || '') + '').trim(); + const call_cid = ((call_info.caller_caller_id_number || call_info.caller_id_number || '') + '').trim(); + const call_name = ((call_info.caller_caller_id_name || call_info.caller_id_name || '') + '').trim(); + + // Show the opposite party relative to this extension to avoid + // misleading CID values when both legs are internal extensions. + let peer = ''; + if (call_dest === num && call_cid && call_cid !== num) { + peer = call_cid; + } else if (call_cid === num && call_dest && call_dest !== num) { + peer = call_dest; + } else if (call_cid && call_cid !== num) { + peer = call_cid; + } else if (call_dest && call_dest !== num) { + peer = call_dest; + } else { + peer = call_name || call_cid || call_dest || ''; + } + + if (peer) state_label += ': ' + esc(peer); + } + } + + const mine_cls = is_mine ? ' op-ext-mine' : ''; + const mine_label = is_mine ? `` : ''; + const data_uuid = call_uuid ? ` data-call-uuid="${esc(call_uuid)}"` : ''; + // Always allow extension-to-extension drag originate; backend routing handles + // availability, call forwarding, follow_me, and voicemail decisions. + const can_receive_originate = true; + const can_manual_originate = reg && state === 'idle'; + const has_live_call = reg && state !== 'idle' && !!call_info; + const call_uuid_js = (call_uuid || '').replace(/'/g, "\\'"); + const call_dest = ((call_info || {}).caller_destination_number || '').trim(); + const call_cid = ((call_info || {}).caller_caller_id_number || '').trim(); + let direction_raw = ''; + if (call_dest === num && call_cid !== num) { + direction_raw = 'inbound'; + } else if (call_cid === num && call_dest !== num) { + direction_raw = 'outbound'; + } else { + direction_raw = (((call_info || {}).call_direction || (call_info || {}).variable_call_direction || '') + '').toLowerCase(); + } + const direction_icon = direction_raw === 'inbound' + ? '../operator_panel/resources/images/inbound.png' + : (direction_raw === 'outbound' ? '../operator_panel/resources/images/outbound.png' : ''); + const is_recording = call_is_recording(call_info, call_uuid); + const record_icon = is_recording + ? '../operator_panel/resources/images/recording.png' + : '../operator_panel/resources/images/record.png'; + const duration_raw = has_live_call ? format_elapsed((call_info || {}).caller_channel_created_time || '0') : ''; + const user_status = (ext.user_status || '').trim(); + let status_icon = 'status_logged_out'; + let status_hover = text['label-status_logged_out_or_unknown'] || text['label-status_logged_out'] || 'Logged Out'; + switch (user_status) { + case 'Available': + status_icon = 'status_available'; + status_hover = text['label-status_available'] || 'Available'; + break; + case 'Available (On Demand)': + status_icon = 'status_available_on_demand'; + status_hover = text['label-status_available_on_demand'] || 'Available (On Demand)'; + break; + case 'On Break': + status_icon = 'status_on_break'; + status_hover = text['label-status_on_break'] || 'On Break'; + break; + case 'Do Not Disturb': + status_icon = 'status_do_not_disturb'; + status_hover = text['label-status_do_not_disturb'] || 'Do Not Disturb'; + break; + default: + if (reg) { + // In this panel, registered-without-explicit-status uses the same icon as Available. + status_icon = 'status_available'; + status_hover = text['label-status_available'] || 'Available'; + } else { + status_icon = 'status_logged_out'; + status_hover = text['label-status_logged_out_or_unknown'] || text['label-status_logged_out'] || 'Logged Out'; + } + } + if (!reg) { + status_icon = 'status_logged_out'; + status_hover = text['label-status_logged_out_or_unknown'] || text['label-status_logged_out'] || 'Logged Out'; + } + if (dnd) { + status_icon = 'status_do_not_disturb'; + status_hover = text['label-status_do_not_disturb'] || 'Do Not Disturb'; + } + + // Allow dragging from idle extensions (originate) and live-call extensions (transfer). + const is_draggable = reg && (state === 'idle' || has_live_call); + const drag_attrs = is_draggable + ? ` draggable="true" ondragstart="on_ext_dragstart('${esc(num)}', event)" ondragend="on_drag_end()"` + : ''; + const dialpad_html = can_manual_originate + ? `
` + + `` + + `` + + `
` + : ''; + const live_call_meta_html = has_live_call + ? `
` + + (direction_icon ? `${esc(text['label-call_direction'] || 'Direction')}` : '') + + `${esc(duration_raw)}` + + `
` + : ''; + const live_actions_html = has_live_call + ? `
` + + (permissions.operator_panel_record + ? `${esc(text['button-record'] || 'Record')}` + : '') + + (permissions.operator_panel_eavesdrop + ? `${esc(text['button-eavesdrop'] || 'Eavesdrop')}` + : '') + + (permissions.operator_panel_coach + ? `${esc(text['button-whisper'] || 'Whisper')}` + : '') + + (permissions.operator_panel_coach + ? `${esc(text['button-barge'] || 'Barge')}` + : '') + + (permissions.operator_panel_hangup + ? `${esc(text['button-hangup'] || 'Hangup')}` + : '') + + `
` + : ''; + + return `
` + + `
${esc(status_hover)}
` + + `
` + + `${mine_label}` + + `
${esc(num)}
` + + dialpad_html + + (show_name ? `
${esc(raw_name)}
` : '') + + (state_label ? `
${state_label}
` : '') + + live_call_meta_html + + live_actions_html + + `
` + + `
`; +} + +/** + * Convert a string to Title Case. + */ +function to_title_case(str) { + if (!str) return ''; + return str.replace(/\S+/g, function(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }); +} + +/** + * Render a group card (dashboard-style widget card) containing extension blocks. + * @param {string} group_key Lowercase group key for data attribute + * @param {string} title The card title (call group name in Title Case) + * @param {Array} exts Array of extension objects + * @param {boolean} is_mine Whether these are the user's own extensions + * @returns {string} + */ +function render_group_card(group_key, title, exts, is_mine) { + const hidden = active_group_filters.size > 0 && !active_group_filters.has(group_key) ? ' op-hidden' : ''; + // Card label position from default settings (global for all cards). + const valid_positions = ['top', 'left', 'right', 'bottom', 'hidden']; + let position = (typeof card_label_position === 'string' ? card_label_position.toLowerCase() : 'left'); + if (!valid_positions.includes(position)) position = 'left'; + + let html = `
`; + + // For "My Extensions" card, hide the header text but keep grey shading + const is_my_card = is_mine || group_key === '__my__'; + if (is_my_card) { + html += `
`; + } else { + html += `
${esc(title)}
`; + } + + html += `
`; + html += '
'; + exts.forEach(ext => { html += render_ext_block(ext, is_mine); }); + html += '
'; + html += '
'; + return html; +} + +/** + * Build the group filter buttons in the filter bar. + * @param {Array} group_keys Sorted array of {key, display} objects + */ +function build_group_filter_buttons(group_keys) { + all_group_keys_for_filters = group_keys.slice(); + + const targets = [ + document.getElementById('group_filter_buttons'), + document.getElementById('group_filter_buttons_calls'), + document.getElementById('group_filter_buttons_conferences'), + document.getElementById('group_filter_buttons_agents'), + ].filter(Boolean); + if (targets.length === 0) return; + + let html = ``; + group_keys.forEach(g => { + const is_active = active_group_filters.has(g.key) ? ' active' : ''; + html += ``; + }); + targets.forEach(container => { + container.innerHTML = html; + }); +} + +/** + * Toggle a group filter button on/off. + */ +function toggle_group_filter(btn) { + const key = btn.getAttribute('data-group-key'); + + if (key === '__all__') { + // "All" button: clear all filters (show everything) + active_group_filters.clear(); + document.querySelectorAll('.op-group-filter-btn').forEach(b => { + b.classList.toggle('active', b.getAttribute('data-group-key') === '__all__'); + }); + } else { + // Toggle individual group + if (active_group_filters.has(key)) { + active_group_filters.delete(key); + } else { + active_group_filters.add(key); + } + btn.classList.toggle('active'); + // Update "All" button state + const all_btn = document.querySelector('.op-group-filter-btn[data-group-key="__all__"]'); + if (all_btn) all_btn.classList.toggle('active', active_group_filters.size === 0); + } + + apply_extension_filters(); + apply_calls_filters(); + apply_conferences_filters(); + apply_agents_filters(); +} + +function matches_group_filter(group_key) { + return active_group_filters.size === 0 || active_group_filters.has(group_key || ''); +} + +function apply_calls_filters() { + const container = document.getElementById('calls_container'); + if (!container) return; + const table = container.querySelector('table.list'); + if (!table) return; + + const filter_text = (((document.getElementById('calls_text_filter') || {}).value) || '').trim().toLowerCase(); + let visible_count = 0; + table.querySelectorAll('tr.list-row').forEach(row => { + const group_key = row.getAttribute('data-group-key') || ''; + const group_ok = matches_group_filter(group_key); + const text_ok = !filter_text || (row.textContent || '').toLowerCase().indexOf(filter_text) !== -1; + const show = group_ok && text_ok; + row.style.display = show ? '' : 'none'; + if (show) visible_count++; + }); + + let empty = container.querySelector('.op-empty-filter-result'); + if (visible_count === 0) { + if (!empty) { + empty = document.createElement('p'); + empty.className = 'text-muted op-empty-filter-result'; + empty.textContent = text['label-no_calls_active'] || 'No active calls.'; + container.appendChild(empty); + } + } else if (empty) { + empty.remove(); + } +} + +function apply_conferences_filters() { + const container = document.getElementById('conferences_container'); + if (!container) return; + const filter_text = (((document.getElementById('conferences_text_filter') || {}).value) || '').trim().toLowerCase(); + let visible_count = 0; + + container.querySelectorAll('.card.mb-3').forEach(card => { + const group_keys_raw = card.getAttribute('data-group-keys') || ''; + const group_keys = group_keys_raw ? group_keys_raw.split(',') : ['']; + const group_ok = active_group_filters.size === 0 || group_keys.some(k => active_group_filters.has(k || '')); + const text_ok = !filter_text || (card.textContent || '').toLowerCase().indexOf(filter_text) !== -1; + const show = group_ok && text_ok; + card.style.display = show ? '' : 'none'; + if (show) visible_count++; + }); + + let empty = container.querySelector('.op-empty-filter-result'); + if (visible_count === 0 && container.querySelector('.card.mb-3')) { + if (!empty) { + empty = document.createElement('p'); + empty.className = 'text-muted op-empty-filter-result'; + empty.textContent = text['label-no_conferences_active'] || 'No active conferences.'; + container.appendChild(empty); + } + } else if (empty) { + empty.remove(); + } +} + +function apply_agents_filters() { + const container = document.getElementById('agents_container'); + if (!container) return; + const table = container.querySelector('table.list'); + if (!table) return; + + const filter_text = (((document.getElementById('agents_text_filter') || {}).value) || '').trim().toLowerCase(); + let visible_count = 0; + table.querySelectorAll('tr.list-row').forEach(row => { + const group_key = row.getAttribute('data-group-key') || ''; + const group_ok = matches_group_filter(group_key); + const text_ok = !filter_text || (row.textContent || '').toLowerCase().indexOf(filter_text) !== -1; + const show = group_ok && text_ok; + row.style.display = show ? '' : 'none'; + if (show) visible_count++; + }); + + let empty = container.querySelector('.op-empty-filter-result'); + if (visible_count === 0) { + if (!empty) { + empty = document.createElement('p'); + empty.className = 'text-muted op-empty-filter-result'; + empty.textContent = text['label-no_agents'] || 'No agents.'; + container.appendChild(empty); + } + } else if (empty) { + empty.remove(); + } +} + +/** + * Apply group filter and text filter to show/hide cards and extension blocks. + */ +function apply_extension_filters() { + const text_val = (document.getElementById('extensions_text_filter') || {}).value || ''; + const filter_text = text_val.trim().toLowerCase(); + + document.querySelectorAll('.op-group-card').forEach(card => { + const key = card.getAttribute('data-group-key') || ''; + // Group filter + const group_visible = active_group_filters.size === 0 || active_group_filters.has(key); + card.classList.toggle('op-hidden', !group_visible); + + if (group_visible && filter_text) { + // Text filter within visible cards + let any_visible = false; + card.querySelectorAll('.op-ext-block').forEach(block => { + const ext_num = (block.getAttribute('data-extension') || '').toLowerCase(); + const ext_name = (block.querySelector('.op-ext-name') || {}).textContent || ''; + const matches = ext_num.indexOf(filter_text) !== -1 || ext_name.toLowerCase().indexOf(filter_text) !== -1; + block.style.display = matches ? '' : 'none'; + if (matches) any_visible = true; + }); + card.classList.toggle('op-hidden', !any_visible); + } else if (group_visible) { + card.querySelectorAll('.op-ext-block').forEach(block => { + block.style.display = ''; + }); + } + }); +} + +/** + * Toggle edit mode for rearranging group cards via drag-and-drop. + */ +function toggle_edit_mode() { + edit_mode_active = !edit_mode_active; + const btn = document.getElementById('edit_mode_btn'); + const container = document.getElementById('extensions_container'); + if (btn) btn.classList.toggle('active', edit_mode_active); + if (!container) return; + + if (edit_mode_active) { + container.classList.add('op-edit-mode'); + if (typeof Sortable !== 'undefined') { + sortable_instance = Sortable.create(container, { + animation: 150, + handle: '.op-group-card-header', + ghostClass: 'sortable-ghost', + onEnd: function() { + save_card_order(); + } + }); + } + } else { + container.classList.remove('op-edit-mode'); + if (sortable_instance) { + sortable_instance.destroy(); + sortable_instance = null; + } + } +} + +/** + * Save card order to localStorage. + */ +function save_card_order() { + const container = document.getElementById('extensions_container'); + if (!container) return; + const order = []; + container.querySelectorAll('.op-group-card').forEach(card => { + order.push(card.getAttribute('data-group-key')); + }); + saved_card_order = order; + try { + localStorage.setItem('op_card_order_' + domain_name, JSON.stringify(order)); + } catch(e) { /* ignore */ } +} + +/** + * Load saved card order from localStorage. + */ +function load_card_order() { + try { + const raw = localStorage.getItem('op_card_order_' + domain_name); + if (raw) saved_card_order = JSON.parse(raw); + } catch(e) { /* ignore */ } +} + +/** + * Select a user status button and send it. + */ +function select_user_status(btn) { + const status = btn.getAttribute('data-status'); + document.querySelectorAll('.op-status-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + send_user_status(status); +} + +/** + * Render the Extensions tab from extensions_map, with the logged-in user's + * extensions shown first, then other extensions grouped by call_group in cards. + */ +function render_extensions_tab() { + const container = document.getElementById('extensions_container'); + const my_container = document.getElementById('my_extensions_container'); + if (!container) return; + + const all = Array.from(extensions_map.values()); + const own = all.filter(e => user_own_extensions.includes(e.extension)); + const others = all.filter(e => !user_own_extensions.includes(e.extension)); + + const badge = document.getElementById('extensions_count'); + if (badge) badge.textContent = all.length; + + if (all.length === 0) { + if (my_container) my_container.innerHTML = ''; + container.innerHTML = `

${esc(text['label-no_extensions'] || 'No extensions found.')}

`; + set_filter_bar_visibility(false); + return; + } + + // Group remaining extensions by call_group (case-insensitive) + const groups = new Map(); + const ungrouped_label = text['label-ungrouped'] || 'Ungrouped'; + others.forEach(ext => { + const raw_group = (ext.call_group || '').trim(); + const key = raw_group.toLowerCase() || ''; + if (!groups.has(key)) { + groups.set(key, { display: raw_group ? to_title_case(raw_group) : ungrouped_label, exts: [] }); + } + groups.get(key).exts.push(ext); + }); + + // Sort groups: named groups alphabetically, ungrouped last + let sorted_keys = Array.from(groups.keys()).sort((a, b) => { + if (a === '' && b !== '') return 1; + if (a !== '' && b === '') return -1; + return a.localeCompare(b); + }); + + // Build the list of all group keys for filters (including "my_extensions") + const filter_keys = []; + if (own.length > 0) { + filter_keys.push({ key: '__my__', display: text['label-my_extensions'] || 'My Extensions' }); + } + sorted_keys.forEach(key => { + const g = groups.get(key); + filter_keys.push({ key, display: g.display }); + }); + + // Build filter buttons + build_group_filter_buttons(filter_keys); + + // Show the filter bar now that we have data + set_filter_bar_visibility(true); + + // Apply saved card order if available (only for other groups, not __my__) + if (!saved_card_order) load_card_order(); + if (saved_card_order && saved_card_order.length > 0) { + const all_keys_set = new Set(sorted_keys); + const ordered = []; + saved_card_order.forEach(k => { + if (k === '__my__') return; // skip — My Extensions is always in its own container + if (all_keys_set.has(k)) { + ordered.push(k); + all_keys_set.delete(k); + } + }); + // Append any new groups not in saved order + all_keys_set.forEach(k => ordered.push(k)); + sorted_keys = ordered; + } + + // Render My Extensions into its own container + if (my_container) { + if (own.length > 0) { + my_container.innerHTML = render_group_card('__my__', text['label-my_extensions'] || 'My Extensions', own, true); + } else { + my_container.innerHTML = ''; + } + } + + // Render other groups into the main container + let html = ''; + const was_edit = container.classList.contains('op-edit-mode'); + + if (saved_card_order && saved_card_order.length > 0) { + // Render in saved order (excluding __my__ which is separate) + const ordered_render = saved_card_order.filter(k => k !== '__my__' && groups.has(k)); + // Add any new keys not in saved order + const rendered_set = new Set(ordered_render); + sorted_keys.forEach(k => { if (!rendered_set.has(k)) ordered_render.push(k); }); + + ordered_render.forEach(k => { + if (groups.has(k)) { + const g = groups.get(k); + html += render_group_card(k, g.display, g.exts, false); + } + }); + } else { + // Default order: sorted groups + sorted_keys.forEach(key => { + const group = groups.get(key); + html += render_group_card(key, group.display, group.exts, false); + }); + } + + container.innerHTML = html; + if (was_edit) container.classList.add('op-edit-mode'); + + // Re-apply filters + apply_extension_filters(); + + // Re-init sortable if edit mode was active + if (edit_mode_active && typeof Sortable !== 'undefined') { + if (sortable_instance) sortable_instance.destroy(); + sortable_instance = Sortable.create(container, { + animation: 150, + handle: '.op-group-card-header', + ghostClass: 'sortable-ghost', + onEnd: function() { save_card_order(); } + }); + } +} + +/** + * Debounce the extensions re-render so rapid call-events don't thrash the DOM. + */ +function schedule_extensions_render() { + if (extensions_render_debounce) clearTimeout(extensions_render_debounce); + extensions_render_debounce = setTimeout(render_extensions_tab, 120); +} + +/** + * Called on dragstart of a call row; stores the dragged UUID. + * @param {string} uuid + * @param {DragEvent} event + */ +function on_drag_call(uuid, event) { + dragged_call_uuid = uuid; + dragged_call_source_extension = null; + dragged_extension = null; // Not dragging an extension + dragged_eavesdrop_uuid = null; + event.dataTransfer.setData('text/plain', uuid); + event.dataTransfer.effectAllowed = 'move'; + set_drag_visual_state(true); +} + +/** + * Called when dragging an idle extension (for call origination). + * @param {string} ext_number + * @param {DragEvent} event + */ +function on_ext_dragstart(ext_number, event) { + const call_uuid = (event.currentTarget && event.currentTarget.dataset) + ? (event.currentTarget.dataset.callUuid || '') + : ''; + + // If this extension currently has a live call, drag-and-drop performs transfer. + if (call_uuid) { + dragged_call_uuid = call_uuid; + dragged_call_source_extension = ext_number; + dragged_extension = null; + event.dataTransfer.setData('text/plain', call_uuid); + event.dataTransfer.effectAllowed = 'move'; + } else { + dragged_extension = ext_number; + dragged_call_uuid = null; // Not dragging a call + dragged_call_source_extension = null; + event.dataTransfer.setData('text/plain', ext_number); + event.dataTransfer.effectAllowed = 'copy'; + } + + dragged_eavesdrop_uuid = null; + set_drag_visual_state(true); +} + +function on_drag_end() { + set_drag_visual_state(false); +} + +function set_drag_visual_state(is_dragging) { + if (!document || !document.body) return; + document.body.classList.toggle('op-dragging', !!is_dragging); +} + +/** Toggle inline dialpad input for an extension block. */ +function toggle_ext_dialpad(ext_number, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + const row = document.getElementById(`ext_block_${ext_number}`); + if (!row) return; + + const input = row.querySelector('.op-ext-dial-input'); + if (!input) return; + + const visible = !input.classList.contains('d-none'); + if (visible) { + input.classList.add('d-none'); + input.value = ''; + return; + } + + input.classList.remove('d-none'); + setTimeout(() => input.focus(), 10); +} + +/** Submit manual originate from extension to destination. */ +function submit_ext_dial(ext_number, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + const row = document.getElementById(`ext_block_${ext_number}`); + if (!row) return; + + const input = row.querySelector('.op-ext-dial-input'); + if (!input) return; + + const destination = (input.value || '').trim(); + if (!destination) { + show_toast(text['label-destination_required'] || 'Please enter a destination.', 'warning'); + input.focus(); + return; + } + + send_action('originate', { source: ext_number, destination }) + .then(() => { + input.value = ''; + input.classList.add('d-none'); + }) + .catch(console.error); +} + +/** + * Allow dropping onto an extension block and highlight it. + * @param {DragEvent} event + */ +function on_ext_dragover(event) { + const can_receive_originate = event.currentTarget.dataset.canReceiveOriginate === 'true'; + const can_drop = dragged_call_uuid || dragged_eavesdrop_uuid || (dragged_extension && can_receive_originate); + if (!can_drop) return; + + event.preventDefault(); + // Determine drop effect based on what's being dragged + event.dataTransfer.dropEffect = dragged_call_uuid ? 'move' : 'copy'; + event.currentTarget.classList.add('op-ext-drop-over'); +} + +/** + * Handle the drop: transfer the dragged call to the extension, or originate a new call. + * @param {string} ext_number + * @param {DragEvent} event + */ +function on_ext_drop(ext_number, event) { + event.preventDefault(); + event.currentTarget.classList.remove('op-ext-drop-over'); + set_drag_visual_state(false); + + // Determine what was dropped and perform the appropriate action + if (dragged_call_uuid) { + // Transfer an existing call to the destination extension + const uuid = dragged_call_uuid; + const source_ext = dragged_call_source_extension; + dragged_call_uuid = null; + dragged_call_source_extension = null; + dragged_eavesdrop_uuid = null; + dragged_extension = null; + if (!uuid || !ext_number) return; + if (source_ext && source_ext === ext_number) return; + send_action('transfer', { uuid, destination: ext_number, context: domain_name }) + .catch(console.error); + } else if (dragged_eavesdrop_uuid) { + // Eavesdrop an existing call using dropped extension as destination + const uuid = dragged_eavesdrop_uuid; + dragged_eavesdrop_uuid = null; + dragged_call_uuid = null; + dragged_call_source_extension = null; + dragged_extension = null; + if (!uuid || !ext_number) return; + send_action('eavesdrop', { uuid, destination: ext_number, destination_extension: ext_number }) + .then(() => show_toast(text['button-eavesdrop'] || 'Eavesdrop started', 'success')) + .catch((err) => { + console.error(err); + show_toast((err && err.message) || 'Eavesdrop failed', 'danger'); + }); + } else if (dragged_extension) { + // Originate a new call from dragged_extension to ext_number + const from_ext = dragged_extension; + dragged_extension = null; + dragged_eavesdrop_uuid = null; + dragged_call_source_extension = null; + const can_receive_originate = event.currentTarget.dataset.canReceiveOriginate === 'true'; + if (!from_ext || !ext_number || from_ext === ext_number) return; // Ignore self-drop + if (!can_receive_originate) { + show_toast(text['label-extension_unavailable'] || 'Destination extension is unavailable.', 'warning'); + return; + } + send_action('originate', { source: from_ext, destination: ext_number }) + .catch(console.error); + } +} + +/** + * Handle a registration_change event pushed by the service when an + * extension registers or unregisters with FreeSWITCH. + * Updates extensions_map in place and re-renders; if the extension is + * unknown (brand-new registration), reloads the full snapshot. + * @param {object} event + */ +function on_registration_change(event) { + lop_debug('[OP_REG_TRACE] [OP][reg][recv] registration_change event:', event); + let ext_num = event.extension || (event.payload && event.payload.extension) || ''; + let evt_domain = event.domain_name || (event.payload && event.payload.domain_name) || ''; + const raw_reg = event.registered ?? (event.payload && event.payload.registered); + const registered = raw_reg === true || raw_reg === 'true'; + const ws_state = (ws && ws.ws) ? ws.ws.readyState : -1; + + lop_debug('[OP][reg][step1] raw extraction', { + extension_raw: ext_num, + event_domain_raw: evt_domain, + raw_registered: raw_reg, + ws_ready_state: ws_state, + }); + + ext_num = ((ext_num || '') + '').trim(); + evt_domain = ((evt_domain || '') + '').trim().replace(/:\d+$/, ''); + if (ext_num.indexOf('@') !== -1) { + const parts = ext_num.split('@'); + ext_num = (parts[0] || '').trim(); + if (!evt_domain && parts[1]) evt_domain = parts[1].trim().replace(/:\d+$/, ''); + } + lop_debug('[OP][reg][normalized]', { + extension: ext_num, + event_domain: evt_domain, + session_domain: domain_name, + registered: registered, + raw_registered: raw_reg, + }); + lop_debug('[OP][reg][step2] map pre-check', { + map_size: extensions_map.size, + has_extension: extensions_map.has(ext_num), + }); + + if (!ext_num) { + lop_debug('[OP][reg][drop] empty extension after normalization'); + return; + } + + // Ignore registration events for other domains + if (evt_domain && evt_domain !== domain_name) { + lop_debug('[OP][reg][drop] domain mismatch', { evt_domain, domain_name, extension: ext_num }); + return; + } + lop_debug('[OP][reg][step3] domain accepted', { + extension: ext_num, + event_domain: evt_domain || domain_name, + }); + + const ext = extensions_map.get(ext_num); + if (ext) { + lop_debug('[OP][reg][update] found extension in map', { extension: ext_num, registered }); + lop_debug('[OP][reg][step4] before update', { + extension: ext_num, + previous_registered: ext.registered, + previous_registration_count: ext.registration_count, + }); + ext.registered = registered; + ext.registration_count = registered ? Math.max(1, ext.registration_count || 0) : 0; + lop_debug('[OP][reg][step5] after update', { + extension: ext_num, + new_registered: ext.registered, + new_registration_count: ext.registration_count, + }); + schedule_extensions_render(); + lop_debug('[OP][reg][step6] render scheduled', { extension: ext_num }); + } else if (registered && extensions_map.size > 0) { + lop_debug('[OP][reg][miss] extension not found in map, requesting snapshot', { + extension: ext_num, + map_size: extensions_map.size, + }); + // Extension is new and map is loaded — reload the full snapshot + load_extensions_snapshot(); + lop_debug('[OP][reg][step4b] snapshot requested due to map miss', { extension: ext_num }); + setTimeout(() => { + const ext_after = extensions_map.get(ext_num); + lop_debug('[OP][reg][step5b] snapshot verify', { + extension: ext_num, + found_after_snapshot: !!ext_after, + registered_after_snapshot: ext_after ? ext_after.registered : null, + map_size_after_snapshot: extensions_map.size, + }); + }, 700); + } else { + lop_debug('[OP][reg][noop] extension missing and/or unregister event', { + extension: ext_num, + registered, + map_size: extensions_map.size, + }); + } + + setTimeout(() => { + const ext_final = extensions_map.get(ext_num); + lop_debug('[OP][reg][final] post-handler state', { + extension: ext_num, + in_map: !!ext_final, + registered: ext_final ? ext_final.registered : null, + registration_count: ext_final ? ext_final.registration_count : null, + }); + }, 250); + // If the map is empty the initial snapshot is still loading; + // it will include the current registration state when it arrives. +} diff --git a/app/operator_panel/resources/javascript/websocket_client.js b/app/operator_panel/resources/javascript/websocket_client.js new file mode 100644 index 000000000..a9664a288 --- /dev/null +++ b/app/operator_panel/resources/javascript/websocket_client.js @@ -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); } + } + } + } +} diff --git a/app/operator_panel/resources/service/debian-operator_panel.service b/app/operator_panel/resources/service/debian-operator_panel.service new file mode 100644 index 000000000..582ad9edd --- /dev/null +++ b/app/operator_panel/resources/service/debian-operator_panel.service @@ -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 diff --git a/app/operator_panel/resources/service/operator_panel.php b/app/operator_panel/resources/service/operator_panel.php new file mode 100755 index 000000000..fb5789fd8 --- /dev/null +++ b/app/operator_panel/resources/service/operator_panel.php @@ -0,0 +1,76 @@ +#!/usr/bin/env php + + Portions created by the Initial Developer are Copyright (C) 2008-2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + Tim Fry +*/ + +/** + * 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()); +} diff --git a/core/websockets/resources/classes/base_websocket_system_service.php b/core/websockets/resources/classes/base_websocket_system_service.php index d86a05b80..bea00a68d 100644 --- a/core/websockets/resources/classes/base_websocket_system_service.php +++ b/core/websockets/resources/classes/base_websocket_system_service.php @@ -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 diff --git a/core/websockets/resources/classes/websocket_service.php b/core/websockets/resources/classes/websocket_service.php index 273ebdff1..7fbbab472 100644 --- a/core/websockets/resources/classes/websocket_service.php +++ b/core/websockets/resources/classes/websocket_service.php @@ -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(); }