collabSupport/public/templates/phone-device-modal.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

379 lines
No EOL
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { simpleTimeAgo } from '../utils/simple-time-ago.js';
function showPhoneModal(identifier, deviceType, fullData = {}) {
let modal = document.getElementById('phoneDeviceModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'phoneDeviceModal';
modal.className = 'hidden fixed inset-0 bg-black/80 flex items-center justify-center z-[100]';
modal.innerHTML = `
<div class="modal bg-gray-900 rounded-3xl w-full max-w-5xl max-h-[92vh] overflow-hidden shadow-2xl border border-gray-700">
<div class="px-8 py-5 border-b border-gray-700 bg-gray-950 flex items-center justify-between">
<h2 id="modalTitle" class="text-2xl font-semibold flex items-center gap-3"></h2>
<button id="modalClose" class="text-gray-400 hover:text-white text-3xl leading-none">×</button>
</div>
<div id="modalContent" class="p-8 overflow-y-auto max-h-[calc(92vh-80px)] space-y-8"></div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#modalClose').addEventListener('click', () => modal.classList.add('hidden'));
}
const titleEl = modal.querySelector('#modalTitle');
const contentEl = modal.querySelector('#modalContent');
const isDect = deviceType === 'dect-base' || deviceType === 'dect-handset' || fullData.isDect;
titleEl.innerHTML = isDect
? `DECT: <span class="font-mono text-emerald-400">${identifier}</span>`
: `Phone: <span class="font-mono text-emerald-400">${identifier}</span>`;
contentEl.innerHTML = getPhoneModalHTML(identifier, deviceType, fullData);
modal.classList.remove('hidden');
}
function getPhoneModalHTML(identifier, deviceType, data) {
let html = '';
const isDectBase = deviceType === 'dect-base' || data.isDectBase;
const isDectHandset = deviceType === 'dect-handset' || data.isDectHandset;
const isPhone = !isDectBase && !isDectHandset;
if (isPhone) {
html += renderPhoneSection(data);
} else if (isDectBase) {
html += renderDectBaseSection(data);
} else if (isDectHandset) {
html += renderDectHandsetSection(data);
}
// Always show Meraki if present
if (data.meraki) {
html += renderPhoneMerakiSection(data);
}
// Profile / location info if top level
if (data.telephonyProfile || data.person || data.dectNetwork || data.locationMainNumber) {
html += renderPhoneContextSection(data);
}
return `<div class="space-y-8">${html}</div>`;
}
function sectionCard(title, content) {
return `
<div class="section-card">
<h3 class="text-lg font-semibold mb-6 text-emerald-400">${title}</h3>
${content}
</div>
`;
}
function renderPhoneSection(phone) {
const lastSeen = phone.lastSeen ? simpleTimeAgo(new Date(phone.lastSeen)) : '—';
const statusBadge = `
<span class="inline-flex items-center gap-1.5 px-5 py-1.5 text-sm font-semibold rounded-2xl shadow-md
${phone.status === 'connected' || phone.status === 'Online' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'}">
${phone.status || 'Unknown'}
</span>`;
const content = `
<div class="flex justify-between items-start mb-6">
<div>
<div class="text-emerald-400 text-sm font-medium">Webex Desk Phone</div>
<div class="text-xl font-semibold mt-1">${phone.displayName || phone.name || 'Unknown'}</div>
</div>
${statusBadge}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6 text-sm">
<div><span class="text-gray-400 block text-xs">MODEL</span><span class="font-medium">${phone.model || phone.product || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">SERIAL</span><span class="font-mono">${phone.serial || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">FIRMWARE</span><span>${phone.firmware || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">IP</span><span class="font-mono">${phone.ipAddress || phone.meraki?.ip || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">LAST SEEN</span><span class="font-medium">${lastSeen}</span></div>
<div><span class="text-gray-400 block text-xs">MAC</span><span class="font-mono">${phone.mac || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">SIP URL</span><span class="font-mono text-xs">${phone.primarySipUrl || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">ERRORS</span><span class="${phone.errorCodes?.length ? 'text-red-400' : ''}">${phone.errorCodes?.length || 0}</span></div>
</div>
`;
return sectionCard('', content);
}
function renderDectBaseSection(base) {
const lastSeen = base.lastSeen ? simpleTimeAgo(new Date(base.lastSeen)) : '—';
const statusBadge = `
<span class="inline-flex items-center gap-1.5 px-5 py-1.5 text-sm font-semibold rounded-2xl shadow-md
${base.status === 'registered' || base.status === 'Online' ? 'bg-emerald-600 text-white' : 'bg-amber-600 text-white'}">
${base.status || 'Unknown'}
</span>`;
const content = `
<div class="flex justify-between items-start mb-6">
<div>
<div class="text-emerald-400 text-sm font-medium">DECT Basestation</div>
<div class="text-xl font-semibold mt-1">${base.name || 'Unknown Base'}</div>
</div>
${statusBadge}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6 text-sm">
<div><span class="text-gray-400 block text-xs">MAC</span><span class="font-mono">${base.mac || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">FIRMWARE</span><span>${base.firmware || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">MODEL</span><span>${base.model || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">IP</span><span class="font-mono">${base.ipAddress || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">LAST SEEN</span><span>${lastSeen}</span></div>
<div><span class="text-gray-400 block text-xs">LINES</span><span>${base.linesRegistered || 0}</span></div>
</div>
`;
return sectionCard('', content);
}
function renderDectHandsetSection(handset) {
const lastReg = handset.lastRegistrationTime ? simpleTimeAgo(new Date(handset.lastRegistrationTime)) : '—';
const content = `
<div>
<div class="text-emerald-400 text-sm font-medium mb-1">DECT Handset</div>
<div class="text-xl font-semibold">${handset.name || 'Unknown Handset'}</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6 text-sm mt-6">
<div><span class="text-gray-400 block text-xs">MAC</span><span class="font-mono">${handset.mac || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">FIRMWARE</span><span>${handset.firmware || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">EXTENSION</span><span class="font-mono">${handset.extension || '—'}</span></div>
<div><span class="text-gray-400 block text-xs">LAST REG</span><span>${lastReg}</span></div>
<div><span class="text-gray-400 block text-xs">BASE ID</span><span class="font-mono">${handset.baseStationId || '—'}</span></div>
</div>
`;
return sectionCard('', content);
}
function renderPhoneMerakiSection(device) {
const m = device.meraki || {};
const client = m.client || m;
const connectionType = m.connectionType || client.recentDeviceConnection || 'Unknown';
let isOnline = client?.status === 'Online' || client?.status === 'connected' || false;
if (!isOnline && client?.lastSeen) {
const d = new Date(client.lastSeen);
if (!isNaN(d.getTime())) {
const ageMins = (Date.now() - d.getTime()) / 60000;
if (ageMins < 5) isOnline = true;
}
}
const lastSeen = client?.lastSeen ? simpleTimeAgo(new Date(client.lastSeen)) : '—';
const cleanMac = client?.mac ? client.mac.toUpperCase().replace(/:/g, '') : '—';
const ip = client?.ip || '—';
// Data usage in MB
let sentMB = 0, recvMB = 0, totalMB = 0;
if (client?.usage) {
sentMB = (client.usage.sent / 1048576).toFixed(3);
recvMB = (client.usage.recv / 1048576).toFixed(3);
totalMB = (client.usage.total / 1048576).toFixed(3);
}
const statusBadge = `
<span class="inline-flex items-center gap-1.5 px-5 py-1.5 text-sm font-semibold rounded-2xl shadow-md
${isOnline ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'}">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full ${isOnline ? 'bg-emerald-300' : 'bg-red-300'} opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 ${isOnline ? 'bg-emerald-300' : 'bg-red-300'}"></span>
</span>
${isOnline ? 'Online' : 'Offline'}
</span>`;
const merakiLink = client?.id && (m.clientUrl || client.clientUrl)
? `<a href="${m.clientUrl || client.clientUrl}" target="_blank" class="text-emerald-400 hover:text-emerald-300 text-sm font-medium flex items-center gap-1">View in Meraki →</a>`
: '';
let content = `
<div class="flex justify-between items-start mb-6">
<h3 class="text-lg font-semibold text-emerald-400">Meraki Client Information</h3>
<div class="flex items-center gap-4">
${statusBadge}
${merakiLink}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-8 text-sm">
<div>
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">CONNECTION TYPE</span>
<span class="font-medium">${connectionType}</span>
</div>
<div>
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">MAC ADDRESS</span>
<span class="font-mono text-emerald-300">${cleanMac}</span>
</div>
<div>
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">IP ADDRESS</span>
<span class="font-mono">${ip}</span>
</div>
<div>
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">LAST SEEN</span>
<span class="font-medium">${lastSeen}</span>
</div>
</div>
`;
if (client?.usage) {
content += `
<div class="mt-10 pt-6 border-t border-gray-700">
<div class="text-xs uppercase tracking-widest text-gray-400 mb-3">DATA USAGE (SINCE LAST SEEN)</div>
<div class="grid grid-cols-3 gap-4 text-center">
<div class="bg-gray-950 rounded-2xl p-5">
<div class="text-emerald-400 text-xs font-medium">SENT</div>
<div class="text-2xl font-semibold mt-1">${sentMB} MB</div>
</div>
<div class="bg-gray-950 rounded-2xl p-5">
<div class="text-emerald-400 text-xs font-medium">RECEIVED</div>
<div class="text-2xl font-semibold mt-1">${recvMB} MB</div>
</div>
<div class="bg-gray-950 rounded-2xl p-5">
<div class="text-emerald-400 text-xs font-medium">TOTAL</div>
<div class="text-2xl font-semibold mt-1">${totalMB} MB</div>
</div>
</div>
</div>
`;
}
// Add detailed port status/config if present (like AV dashboard)
const portStatus = m.portStatus || client?.switchportStatus;
const portConfig = m.portConfig || client?.switchportConfig;
if (portStatus || portConfig) {
if (portStatus) {
content += renderSwitchportStatusSection(portStatus, portConfig);
}
if (portConfig) {
content += renderSwitchportConfigSection(portConfig);
}
}
return sectionCard('', content);
}
// ====================== SWITCHPORT STATUS (copied/adapted from AV for phone nodes)
function renderSwitchportStatusSection(portStatus, portConfig) {
const enabledText = portStatus?.enabled !== false ? 'Enabled' : 'Disabled';
const enabledColor = enabledText === 'Enabled' ? 'bg-green-600 text-white' : 'bg-red-600 text-white';
const statusText = portStatus?.status || 'Unknown';
const isConnected = statusText.toLowerCase() === 'connected' || statusText.toLowerCase() === 'online';
const statusColor = isConnected ? 'bg-green-600 text-white' : 'bg-red-600 text-white';
const speedDuplex = `${portStatus?.speed || '—'} / ${portStatus?.duplex || '—'}`;
const poeAllocated = portStatus?.poe?.isAllocated ? 'Yes' : 'No';
const usageKB = portStatus?.usageInKb?.total ? portStatus.usageInKb.total.toLocaleString() : '—';
const trafficKbps = portStatus?.trafficInKbps?.total ? portStatus.trafficInKbps.total.toFixed(1) : '—';
const content = `
<div class="section-card relative mt-6">
<div class="flex gap-3 absolute top-6 right-6">
<div class="px-4 py-1.5 rounded-2xl text-sm font-semibold ${enabledColor}">${enabledText}</div>
<div class="px-4 py-1.5 rounded-2xl text-sm font-semibold flex items-center gap-2 ${statusColor}">
<span class="w-3 h-3 rounded-full ${isConnected ? 'bg-green-300 animate-pulse' : 'bg-red-300'}"></span>
${statusText}
</div>
</div>
<div class="grid grid-cols-2 gap-x-12 gap-y-6 text-sm mt-2">
<div><span class="text-gray-400">Port ID</span><br><span class="font-medium">${portStatus?.portId || '—'}</span></div>
<div><span class="text-gray-400">Is Uplink</span><br>${portStatus?.isUplink ? 'Yes' : 'No'}</div>
<div><span class="text-gray-400">Speed / Duplex</span><br>${speedDuplex}</div>
<div><span class="text-gray-400">PoE Allocated</span><br><span class="${poeAllocated === 'Yes' ? 'text-green-400' : ''}">${poeAllocated}</span></div>
<div><span class="text-gray-400">Errors</span><br><span class="${(portStatus?.errors || 0) > 0 ? 'text-red-400' : ''}">${portStatus?.errors || 0}</span></div>
<div><span class="text-gray-400">Warnings</span><br>${portStatus?.warnings || 0}</div>
</div>
<div class="mt-8 pt-6 border-t border-gray-700 grid grid-cols-3 gap-4 text-center">
<div class="bg-gray-950 rounded-xl p-4">
<div class="text-gray-400 text-xs">Usage</div>
<div class="text-xl font-semibold">${usageKB} KB</div>
</div>
<div class="bg-gray-950 rounded-xl p-4">
<div class="text-gray-400 text-xs">Traffic</div>
<div class="text-xl font-semibold">${trafficKbps} Kbps</div>
</div>
<div class="bg-gray-950 rounded-xl p-4">
<div class="text-gray-400 text-xs">Power Usage</div>
<div class="text-xl font-semibold">${portStatus?.powerUsageInWh || '—'} Wh</div>
</div>
</div>
</div>
`;
return sectionCard('Switchport Status', content);
}
// ====================== SWITCHPORT CONFIG (adapted)
function renderSwitchportConfigSection(portConfig) {
const enabledText = portConfig?.enabled !== false ? 'Enabled' : 'Disabled';
const enabledColor = enabledText === 'Enabled' ? 'bg-green-600 text-white' : 'bg-red-600 text-white';
const poeText = portConfig?.poeEnabled ? 'PoE Enabled' : 'PoE Disabled';
const poeColor = portConfig?.poeEnabled ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300';
const stickyCount = (portConfig?.stickyMacAllowList || []).length;
const content = `
<div class="section-card relative mt-4">
<div class="flex gap-3 absolute top-6 right-6">
<div class="px-4 py-1.5 rounded-2xl text-sm font-semibold ${enabledColor}">${enabledText}</div>
<div class="px-4 py-1.5 rounded-2xl text-sm font-semibold ${poeColor}">${poeText}</div>
</div>
<div class="grid grid-cols-2 gap-x-12 gap-y-6 text-sm mt-2">
<div><span class="text-gray-400">Port ID</span><br><span class="font-medium">${portConfig?.portId || '—'}</span></div>
<div><span class="text-gray-400">Name</span><br><span class="font-medium">${portConfig?.name || '—'}</span></div>
<div><span class="text-gray-400">Type</span><br><span class="font-medium">${portConfig?.type || 'Access'}</span></div>
<div><span class="text-gray-400">VLAN</span><br><span class="font-medium">${portConfig?.vlan || '—'}</span></div>
<div><span class="text-gray-400">Voice VLAN</span><br><span class="font-medium">${portConfig?.voiceVlan || 'None'}</span></div>
<div><span class="text-gray-400">Access Policy</span><br><span class="font-medium">${portConfig?.accessPolicyType || '—'}</span></div>
<div><span class="text-gray-400">Sticky MAC Limit</span><br><span class="font-medium">${portConfig?.stickyMacAllowListLimit || '—'}</span></div>
<div><span class="text-gray-400">Sticky MAC List</span><br><span class="font-medium">${stickyCount} entries</span></div>
</div>
</div>
`;
return sectionCard('Switchport Configuration', content);
}
function renderPhoneContextSection(data) {
const prof = data.telephonyProfile || {};
const pers = data.person || {};
const dectNet = data.dectNetwork || {};
const mainNum = data.locationMainNumber;
let content = '<div class="grid grid-cols-1 md:grid-cols-2 gap-8 text-sm">';
if (prof.timeZone || mainNum) {
content += `
<div>
<div class="text-emerald-400 text-sm font-medium mb-3">Location / Profile</div>
${prof.timeZone ? `<div><span class="text-gray-400">Timezone:</span> <span class="font-medium">${prof.timeZone}</span></div>` : ''}
${mainNum ? `<div class="mt-1"><span class="text-gray-400">Main Number:</span> <span class="font-mono">${mainNum}</span></div>` : ''}
</div>
`;
}
if (dectNet.name || dectNet.locationName) {
content += `
<div>
<div class="text-emerald-400 text-sm font-medium mb-3">DECT Network</div>
<div><span class="font-medium">${dectNet.name || 'DECT Network'}</span></div>
${dectNet.locationName ? `<div class="text-gray-400 text-xs mt-0.5">${dectNet.locationName}</div>` : ''}
</div>
`;
}
content += '</div>';
return sectionCard('Context', content);
}
export { showPhoneModal };