/** * 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. }