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).
742 lines
No EOL
34 KiB
JavaScript
742 lines
No EOL
34 KiB
JavaScript
import { simpleTimeAgo } from '../utils/simple-time-ago.js';
|
||
|
||
function showDeviceModal(identifier, deviceType, fullData = {}) {
|
||
let modal = document.getElementById('avDeviceModal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'avDeviceModal';
|
||
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 isSwitch = deviceType === 'switch' || fullData?.isInfra || /^SW/i.test(String(identifier));
|
||
titleEl.innerHTML = isSwitch
|
||
? `Switch / Infra: <span class="font-mono text-emerald-400">${identifier}</span>`
|
||
: `Device: <span class="font-mono text-emerald-400">${identifier}</span>`;
|
||
contentEl.innerHTML = getModalHTML(identifier, deviceType, fullData);
|
||
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
function getModalHTML(identifier, deviceType, data) {
|
||
const isInfra = deviceType === 'switch' || data?.isInfra || /^SW/i.test(String(identifier));
|
||
if (isInfra) {
|
||
return renderInfraSwitchModal(identifier, data || {});
|
||
}
|
||
|
||
const isAtlasAmp = !!data.isAtlasAmp || data.source === 'atlas';
|
||
const isWireless = data.meraki?.connectionType === 'Wireless' || !!data.meraki?.client?.ssid;
|
||
|
||
let html = '';
|
||
|
||
// MDM Section
|
||
if (data.source === 'mdm' && data.mdmData) {
|
||
html += renderMDMSection(data.mdmData);
|
||
}
|
||
|
||
// OptiSigns
|
||
if (data.optisigns) {
|
||
html += renderOptiSignsTopBlock(data);
|
||
}
|
||
|
||
// RED
|
||
if (data.red) {
|
||
html += renderREDSection(data.red);
|
||
}
|
||
|
||
// Atlas AMP
|
||
if (isAtlasAmp) {
|
||
html += renderAMPTopBlock(data);
|
||
}
|
||
|
||
// ====================== MERAKI GROUPED SECTION ======================
|
||
let merakiContent = '';
|
||
|
||
// Always show the Meraki Client Information section (even if no client data)
|
||
merakiContent += renderMerakiClientSection(data); // ← pass full device, not just client
|
||
|
||
// Wired devices: Show Switchport Status + Config
|
||
// Support both top-level (some shapes) and under .client (rich/build path where attach puts ports on client)
|
||
const portStatus = data.meraki?.portStatus || data.meraki?.client?.switchportStatus;
|
||
const portConfig = data.meraki?.portConfig || data.meraki?.client?.switchportConfig;
|
||
if (portStatus || portConfig) {
|
||
if (portStatus) {
|
||
merakiContent += renderSwitchportStatusSection(portStatus, portConfig);
|
||
}
|
||
if (portConfig) {
|
||
merakiContent += renderSwitchportConfigSection(portConfig);
|
||
}
|
||
}
|
||
// Wireless devices: Show Wireless Details
|
||
// Support top-level (rich) or under client
|
||
else if (data.meraki?.connectionType === 'Wireless' ||
|
||
data.meraki?.client?.ssid ||
|
||
data.meraki?.wirelessDetails ||
|
||
data.meraki?.client?.wirelessDetails) {
|
||
merakiContent += renderWirelessClientSection(data);
|
||
}
|
||
// No Meraki data at all (common for some wired MSC players)
|
||
else {
|
||
// Optional: You can add a small "No switchport data" message here if desired
|
||
}
|
||
|
||
html += sectionCard('Meraki Information', merakiContent);
|
||
|
||
return `<div class="space-y-8">${html}</div>`;
|
||
}
|
||
|
||
// ====================== CONSISTENT SECTION HELPER ======================
|
||
function sectionCard(title, content) {
|
||
return `
|
||
<div class="section-card">
|
||
<h3 class="text-lg font-semibold mb-6 text-emerald-400">${title}</h3>
|
||
${content}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ====================== RED SECTION ======================
|
||
function renderREDSection(red) {
|
||
if (!red) return '';
|
||
|
||
const parseUTCDate = (ts) => {
|
||
if (!ts) return null;
|
||
let str = String(ts).trim();
|
||
if (!/[Z+-]/.test(str)) str += 'Z';
|
||
const date = new Date(str);
|
||
return isNaN(date.getTime()) ? null : date;
|
||
};
|
||
|
||
const lastPing = parseUTCDate(red.LastPingTimeUTC) ? simpleTimeAgo(parseUTCDate(red.LastPingTimeUTC)) : '—';
|
||
const lastStartup = parseUTCDate(red.LastStartupDateUTC || red.LastStartupDate) ? simpleTimeAgo(parseUTCDate(red.LastStartupDateUTC || red.LastStartupDate)) : '—';
|
||
|
||
const isOnline = red.Connectivity === 'Online';
|
||
|
||
const statusBadge = `
|
||
<span class="inline-flex items-center gap-1.5 px-5 py-2 text-sm font-semibold rounded-2xl shadow-md
|
||
${isOnline ? 'bg-emerald-600 text-white' : 'bg-amber-900 text-amber-300'}">
|
||
<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-amber-400'} opacity-75"></span>
|
||
<span class="relative inline-flex rounded-full h-2 w-2 ${isOnline ? 'bg-emerald-300' : 'bg-amber-400'}"></span>
|
||
</span>
|
||
${red.Connectivity || 'Unknown'}
|
||
</span>`;
|
||
|
||
const content = `
|
||
<div class="flex justify-between items-start mb-6">
|
||
<h3 class="text-lg font-semibold text-emerald-400">RED Audio Player</h3>
|
||
${statusBadge}
|
||
</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">DEVICE ID</span>
|
||
<span class="font-mono">${red.DeviceID || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">NAME</span>
|
||
<span class="font-medium">${red.Name || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">DEPLOYMENT</span>
|
||
<span class="font-medium">${red.DeploymentStatusName || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">AVAILABILITY</span>
|
||
<span class="font-medium">${red.AvailabilityStatus || '—'}</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">LAST PING</span>
|
||
<span class="font-medium">${lastPing}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">LAST STARTUP</span>
|
||
<span class="font-medium">${lastStartup}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">VERSION</span>
|
||
<span class="font-medium">${red.CurrentNanopointVersionString || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">STATE</span>
|
||
<span class="font-medium">${red.StateTransitionStatus || '—'}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return sectionCard('', content); // Title handled inside
|
||
}
|
||
|
||
// ====================== OPTISIGNS SECTION ======================
|
||
function renderOptiSignsTopBlock(data) {
|
||
const opti = data.optisigns || {};
|
||
if (!opti) return '';
|
||
|
||
const playlistName = opti.currentPlaylistName || opti.currentPlaylistId || '—';
|
||
const assetName = opti.currentAssetName || '—';
|
||
const hasContent = !!(opti.currentPlaylistId || opti.currentAssetId);
|
||
const isRecent = opti.lastHeartBeat && (Date.now() - new Date(opti.lastHeartBeat).getTime()) < 24 * 60 * 60 * 1000;
|
||
const isActive = hasContent && isRecent;
|
||
|
||
const lastHeartbeat = opti.lastHeartBeat ? simpleTimeAgo(new Date(opti.lastHeartBeat)) : '—';
|
||
|
||
const statusBadge = `
|
||
<span class="inline-flex items-center gap-1.5 px-5 py-2 text-sm font-semibold rounded-2xl shadow-md
|
||
${isActive ? 'bg-emerald-600 text-white' : 'bg-amber-900 text-amber-300'}">
|
||
<span class="relative flex h-2 w-2">
|
||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full ${isActive ? 'bg-emerald-300' : 'bg-amber-400'} opacity-75"></span>
|
||
<span class="relative inline-flex rounded-full h-2 w-2 ${isActive ? 'bg-emerald-300' : 'bg-amber-400'}"></span>
|
||
</span>
|
||
${isActive ? 'Active' : 'Inactive'}
|
||
</span>`;
|
||
|
||
const content = `
|
||
<div class="flex justify-between items-start mb-6">
|
||
<h3 class="text-lg font-semibold text-emerald-400">OptiSigns</h3>
|
||
${statusBadge}
|
||
</div>
|
||
|
||
<div class="text-lg font-medium">"${playlistName}"</div>
|
||
${assetName !== '—' ? `<div class="text-sm text-gray-400 mt-1">Asset: ${assetName}</div>` : ''}
|
||
|
||
<div class="mt-8 pt-6 border-t border-gray-700 text-sm">
|
||
<span class="text-gray-400">Last Heartbeat:</span>
|
||
<span class="font-medium">${lastHeartbeat}</span>
|
||
</div>
|
||
`;
|
||
|
||
return sectionCard('', content);
|
||
}
|
||
|
||
// ====================== AMP SECTION ======================
|
||
function renderAMPTopBlock(data) {
|
||
let atlas = {};
|
||
if (Array.isArray(data.atlasData) && data.atlasData.length > 0) atlas = data.atlasData[0];
|
||
else if (data.atlasData && typeof data.atlasData === 'object') atlas = data.atlasData;
|
||
else if (data.atlas) atlas = data.atlas;
|
||
|
||
const state = atlas.state || {};
|
||
const model = atlas.model || {};
|
||
const firmware = atlas.firmware || {};
|
||
|
||
// Align status extraction with /avstatus command for consistency:
|
||
// prefer top-level status (from raw Atlas data) then state.status
|
||
const ampStatus = atlas.status || state.status || 'Unknown';
|
||
const isOnline = (ampStatus).toLowerCase() === 'online';
|
||
|
||
const cpuTemp = state.tempCpu ? Number(state.tempCpu).toFixed(1) + '°F' : '—';
|
||
const psuTemp = state.tempPsu ? Number(state.tempPsu).toFixed(1) + '°F' : '—';
|
||
const ioTemp = state.tempIo ? Number(state.tempIo).toFixed(1) + '°F' : '—';
|
||
const fanSpeed = state.fanSpeed ? Math.round(state.fanSpeed) + '%' : '—';
|
||
|
||
let ampHtml = '';
|
||
for (let i = 1; i <= 8; i++) {
|
||
const status = state[`ampStatus_${i}`] || '—';
|
||
const temp = state[`tempAmp_${i}`] ? Number(state[`tempAmp_${i}`]).toFixed(1) + '°F' : '—';
|
||
ampHtml += `
|
||
<div class="bg-gray-950 rounded-xl p-3 text-center">
|
||
<div class="text-xs text-gray-400">AMP ${i}</div>
|
||
<div class="font-medium text-sm mt-1 ${status === 'Ready' || status === 'Active' ? 'text-emerald-400' : 'text-amber-400'}">
|
||
${status}
|
||
</div>
|
||
<div class="text-xs text-gray-500 mt-1">${temp}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const content = `
|
||
<div class="flex justify-between items-start mb-6">
|
||
<div>
|
||
<div class="text-emerald-400 text-sm font-medium">Atlas IED Power Amplifier</div>
|
||
<div class="text-xl font-semibold mt-1">${atlas.name || 'US002477AMP'}</div>
|
||
</div>
|
||
<span class="inline-flex items-center gap-1.5 px-5 py-2 text-sm font-semibold rounded-2xl shadow-md
|
||
${isOnline ? 'bg-emerald-600 text-white' : 'bg-amber-900 text-amber-300'}">
|
||
<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-amber-400'} opacity-75"></span>
|
||
<span class="relative inline-flex rounded-full h-2 w-2 ${isOnline ? 'bg-emerald-300' : 'bg-amber-400'}"></span>
|
||
</span>
|
||
${ampStatus}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 text-sm mb-8">
|
||
<div><span class="text-gray-400">Model</span><br><span class="font-medium">${model.name || 'AZMP8'}</span></div>
|
||
<div><span class="text-gray-400">Serial</span><br><span class="font-mono">${atlas.sn || '—'}</span></div>
|
||
<div><span class="text-gray-400">Firmware</span><br><span class="font-medium">${firmware.version || '4.5.16'}</span></div>
|
||
<div><span class="text-gray-400">IP</span><br><span class="font-mono">${state.IpAddress || '—'}</span></div>
|
||
<div><span class="text-gray-400">CPU Usage</span><br><span>${state.cpuUsage || '—'}%</span></div>
|
||
<div><span class="text-gray-400">RAM Usage</span><br><span>${state.ramUsage ? state.ramUsage.toFixed(1) : '—'}%</span></div>
|
||
<div><span class="text-gray-400">Voltage</span><br><span>${state.voltageMonitor || '120'}V</span></div>
|
||
<div><span class="text-gray-400">Last Seen</span><br><span class="font-medium">${simpleTimeAgo(atlas.last_seen_at)}</span></div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-4 gap-6 text-sm mb-8">
|
||
<div><span class="text-gray-400">CPU Temp</span><br>${cpuTemp}</div>
|
||
<div><span class="text-gray-400">PSU Temp</span><br>${psuTemp}</div>
|
||
<div><span class="text-gray-400">Io Temp</span><br>${ioTemp}</div>
|
||
<div><span class="text-gray-400">Fan Speed</span><br>${fanSpeed}</div>
|
||
</div>
|
||
|
||
<div class="mb-8">
|
||
<span class="text-gray-400 text-sm">Last Log Entry</span><br>
|
||
<span class="text-xs text-gray-300">${state.lastLogEntry || '—'}</span>
|
||
</div>
|
||
|
||
<div class="pt-6 border-t border-gray-700">
|
||
<div class="text-xs uppercase tracking-widest text-gray-400 mb-4">Amplifier Channels</div>
|
||
<div class="grid grid-cols-4 md:grid-cols-8 gap-3">${ampHtml}</div>
|
||
</div>
|
||
`;
|
||
|
||
return sectionCard('Atlas IED Power Amplifier', content);
|
||
}
|
||
|
||
/**
|
||
* Renders the Meraki Client Information section
|
||
* Handles both cases gracefully: has Meraki data OR no Meraki client (common for wired MSC on local switches)
|
||
*/
|
||
function renderMerakiClientSection(device) {
|
||
const meraki = device.meraki || {};
|
||
const client = meraki.client || null;
|
||
const connectionType = meraki.connectionType || 'Unknown';
|
||
|
||
let isOnline = client?.status === 'Online' || false;
|
||
// Fallback using lastSeen recency if the Meraki client record lacks an explicit status (or for the most-recent picked record)
|
||
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;
|
||
// else leave false (will show Offline badge)
|
||
}
|
||
}
|
||
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 (3 decimal places)
|
||
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 && device.meraki?.clientUrl
|
||
? `<a href="${device.meraki.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 = '';
|
||
|
||
if (client) {
|
||
// Has Meraki client data
|
||
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>
|
||
|
||
<!-- Data Usage -->
|
||
<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>
|
||
`;
|
||
} else {
|
||
// No Meraki client found (common for wired MSC on local switches)
|
||
content = `
|
||
<div class="flex justify-between items-start mb-6">
|
||
<h3 class="text-lg font-semibold text-emerald-400">Meraki Client Information</h3>
|
||
${merakiLink}
|
||
</div>
|
||
|
||
<div class="bg-amber-950 border border-amber-800 rounded-2xl p-8 text-center">
|
||
<div class="text-amber-400 text-xl mb-2">⚠️ No Meraki Client Data</div>
|
||
<p class="text-amber-300">This device is likely connected to a local or non-Meraki switch/infrastructure.</p>
|
||
<p class="text-sm text-gray-400 mt-4">Connection Type: <span class="font-medium">${connectionType}</span></p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return sectionCard('', content);
|
||
}
|
||
|
||
// ====================== MDM SECTION ======================
|
||
function renderMDMSection(mdmData) {
|
||
if (!mdmData) return '';
|
||
|
||
const summary = mdmData.mdmDataSummary || {};
|
||
const lastSeen = mdmData.LastSeen || summary.lastSeen;
|
||
|
||
// MDM Console Link
|
||
const deviceId = mdmData.Id?.Value || summary.id || '';
|
||
const mdmLink = deviceId
|
||
? `<a href="https://cn1991.awmdm.com/AirWatch/#/AirWatch/Device/Details/Summary/${deviceId}"
|
||
target="_blank"
|
||
class="text-emerald-400 hover:text-emerald-300 text-sm font-medium flex items-center gap-1">
|
||
View in MDM Console →
|
||
</a>`
|
||
: '';
|
||
|
||
const content = `
|
||
<div class="flex justify-between items-start mb-6">
|
||
<h3 class="text-lg font-semibold text-emerald-400">MDM Device Information</h3>
|
||
${mdmLink}
|
||
</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">Username</span>
|
||
<span class="font-medium">${mdmData.UserName || summary.username || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">Serial Number</span>
|
||
<span class="font-mono text-emerald-300">${mdmData.SerialNumber || summary.serialNumber || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">MAC Address</span>
|
||
<span class="font-mono">${mdmData.MacAddress || summary.macAddress || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">Model</span>
|
||
<span class="font-medium">${mdmData.Model || summary.model || 'Apple TV'}</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">OS Version</span>
|
||
<span class="font-medium">${mdmData.OperatingSystem || summary.osVersion || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">Compliance</span>
|
||
<span class="${(mdmData.ComplianceStatus || summary.complianceStatus) === 'Compliant' ? 'text-green-400' : 'text-red-400'} font-medium">
|
||
${mdmData.ComplianceStatus || summary.complianceStatus || '—'}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">Enrollment</span>
|
||
<span class="font-medium">${mdmData.EnrollmentStatus || summary.enrollmentStatus || '—'}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">Location Group</span>
|
||
<span class="font-medium">${mdmData.LocationGroupName || summary.locationGroupName || '—'}</span>
|
||
</div>
|
||
|
||
<div class="col-span-2 md:col-span-1">
|
||
<span class="text-gray-400 block text-xs uppercase tracking-widest mb-1">Last Seen</span>
|
||
<span class="font-medium">${lastSeen ? simpleTimeAgo(lastSeen) : '—'}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return sectionCard('', content); // Title moved inside for link alignment
|
||
}
|
||
|
||
// ====================== WIRELESS SECTION ======================
|
||
function renderWirelessClientSection(data) {
|
||
const client = data.meraki?.client || {};
|
||
const wirelessDetails = data.meraki?.wirelessDetails || {};
|
||
const wirelessSummary = data.meraki?.wirelessSummary || {};
|
||
|
||
const rssi = wirelessSummary.rssi || wirelessDetails.signalQuality?.rssi || '—';
|
||
const snr = wirelessSummary.snr || wirelessDetails.signalQuality?.snr || '—';
|
||
const avgLatency = wirelessSummary.avgLatencyMs || wirelessDetails.latency?.avgLatencyMs || '—';
|
||
|
||
const health = wirelessDetails.healthScores || {};
|
||
const connStats = wirelessDetails.connectionStats || {};
|
||
const failed = wirelessDetails.failedConnections?.length || 0;
|
||
|
||
const content = `
|
||
<div class="grid grid-cols-2 gap-x-12 gap-y-6 text-sm mb-8">
|
||
<div><span class="text-gray-400">SSID</span><br><span class="font-medium">${wirelessSummary.ssid || client.ssid || '—'}</span></div>
|
||
<div><span class="text-gray-400">Access Point</span><br><span class="font-medium">${wirelessSummary.apName || '—'}</span></div>
|
||
<div><span class="text-gray-400">RSSI</span><br><span class="font-medium">${rssi} dBm</span></div>
|
||
<div><span class="text-gray-400">SNR</span><br><span class="font-medium">${snr} dB</span></div>
|
||
<div><span class="text-gray-400">Avg Latency</span><br><span class="font-medium">${avgLatency} ms</span></div>
|
||
</div>
|
||
|
||
<div class="mb-8">
|
||
<div class="text-xs uppercase tracking-widest text-gray-400 mb-3">Health Scores</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="bg-gray-950 rounded-xl p-4">
|
||
<div class="text-emerald-400 text-sm">Performance</div>
|
||
<div class="text-3xl font-semibold">${health.performance?.latest ?? '—'}</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded-xl p-4">
|
||
<div class="text-emerald-400 text-sm">Onboarding</div>
|
||
<div class="text-3xl font-semibold">${health.onboarding?.latest ?? '—'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-8">
|
||
<div class="text-xs uppercase tracking-widest text-gray-400 mb-3">Connection Stats</div>
|
||
<div class="grid grid-cols-5 gap-3 text-center">
|
||
<div class="bg-red-950 border border-red-900/50 rounded-xl p-3">
|
||
<div class="text-red-400 text-xl font-semibold">${connStats.assoc ?? 0}</div>
|
||
<div class="text-xs text-gray-400">Assoc Fail</div>
|
||
</div>
|
||
<div class="bg-red-950 border border-red-900/50 rounded-xl p-3">
|
||
<div class="text-red-400 text-xl font-semibold">${connStats.auth ?? 0}</div>
|
||
<div class="text-xs text-gray-400">Auth Fail</div>
|
||
</div>
|
||
<div class="bg-red-950 border border-red-900/50 rounded-xl p-3">
|
||
<div class="text-red-400 text-xl font-semibold">${connStats.dhcp ?? 0}</div>
|
||
<div class="text-xs text-gray-400">DHCP Fail</div>
|
||
</div>
|
||
<div class="bg-red-950 border border-red-900/50 rounded-xl p-3">
|
||
<div class="text-red-400 text-xl font-semibold">${connStats.dns ?? 0}</div>
|
||
<div class="text-xs text-gray-400">DNS Fail</div>
|
||
</div>
|
||
<div class="bg-emerald-950 border border-emerald-900/50 rounded-xl p-3">
|
||
<div class="text-emerald-400 text-xl font-semibold">${connStats.success ?? 0}</div>
|
||
<div class="text-xs text-gray-400">Success</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-6">
|
||
<div class="text-xs uppercase tracking-widest text-gray-400 mb-2">Failed Connections</div>
|
||
<div class="text-2xl font-semibold ${failed > 0 ? 'text-red-400' : 'text-emerald-400'}">
|
||
${failed}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return sectionCard('Wireless Details', content);
|
||
|
||
}
|
||
|
||
// ====================== SWITCHPORT STATUS ======================
|
||
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">
|
||
<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 ======================
|
||
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">
|
||
<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);
|
||
}
|
||
|
||
// ====================== INFRA / SWITCH MODAL (for topology infra nodes like SW02477R) ======================
|
||
function renderInfraSwitchModal(identifier, data) {
|
||
const tNode = data.tNode || {};
|
||
const dev = (tNode.device || data.meraki?.device || data || {});
|
||
const serial = dev.serial || identifier || '—';
|
||
const name = dev.name || 'Switch / Access Point';
|
||
const model = dev.model || dev.productType || '—';
|
||
const mStatus = (data.status || dev.status || 'Unknown').toString();
|
||
const isOnline = /online/i.test(mStatus);
|
||
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' : (mStatus.toLowerCase().includes('alert') ? 'bg-orange-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>
|
||
${mStatus || 'Unknown'}
|
||
</span>`;
|
||
|
||
const connected = Array.isArray(data.connectedAVs) ? data.connectedAVs : [];
|
||
let connectedHtml = '';
|
||
if (connected.length) {
|
||
connectedHtml = `
|
||
<div class="mt-8 pt-6 border-t border-gray-700">
|
||
<div class="text-xs uppercase tracking-widest text-gray-400 mb-3">Connected AV Devices (${connected.length})</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||
${connected.map(av => {
|
||
const c = av.meraki?.client || av.meraki || {};
|
||
const ip = c.ip || av.ip || '—';
|
||
const ago = c.lastSeen ? (typeof simpleTimeAgo === 'function' ? simpleTimeAgo(new Date(c.lastSeen)) : c.lastSeen) : '—';
|
||
return `
|
||
<div class="bg-gray-950 rounded-xl p-3 border border-gray-800">
|
||
<div class="font-mono text-emerald-300">${av.identifier || 'AV'}</div>
|
||
<div class="text-xs text-gray-400 mt-0.5">${av.source || '—'} • ${ip} • Last seen ${ago}</div>
|
||
${c.switchport || c.port ? `<div class="text-[11px] text-amber-400 mt-1">Port ${c.switchport || c.port} ${c.vlan ? 'VLAN ' + c.vlan : ''}</div>` : ''}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
<div class="text-[10px] text-gray-500 mt-2">Click the green AV leaf nodes in the diagram for their full MDM/RED/Opti/Atlas/Meraki modals.</div>
|
||
</div>`;
|
||
} else {
|
||
connectedHtml = `<div class="mt-4 text-sm text-gray-400">No AV devices currently attached in the enriched data.</div>`;
|
||
}
|
||
|
||
const merakiLink = data.deviceUrl
|
||
? `<a href="${data.deviceUrl}" target="_blank" class="text-emerald-400 hover:text-emerald-300 text-sm font-medium flex items-center gap-1">
|
||
View in Meraki →
|
||
</a>`
|
||
: '';
|
||
|
||
const content = `
|
||
<div class="flex justify-between items-start mb-6">
|
||
<div>
|
||
<div class="text-emerald-400 text-sm font-medium">Meraki Infrastructure Device</div>
|
||
<div class="text-xl font-semibold mt-1">${name}</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
${statusBadge}
|
||
${merakiLink}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6 text-sm mb-6">
|
||
<div><span class="text-gray-400 block text-xs uppercase tracking-widest">SERIAL</span><span class="font-mono">${serial}</span></div>
|
||
<div><span class="text-gray-400 block text-xs uppercase tracking-widest">MODEL</span><span class="font-medium">${model}</span></div>
|
||
<div><span class="text-gray-400 block text-xs uppercase tracking-widest">TYPE</span><span>${tNode.type || (model && model.toUpperCase().startsWith('MR') ? 'AP' : 'Switch')}</span></div>
|
||
<div><span class="text-gray-400 block text-xs uppercase tracking-widest">LAST REPORTED</span><span>${dev.lastReportedAt ? (typeof simpleTimeAgo === "function" ? simpleTimeAgo(new Date(dev.lastReportedAt)) : dev.lastReportedAt) : (dev.lastSeen ? (typeof simpleTimeAgo === "function" ? simpleTimeAgo(new Date(dev.lastSeen)) : dev.lastSeen) : "—")}</span></div>
|
||
</div>
|
||
|
||
${connectedHtml}
|
||
|
||
<div class="pt-6 border-t border-gray-700 text-xs text-gray-500">
|
||
This is a switch/AP-level modal (no AV source data like RED/Opti/Atlas/MDM). Use the diagram AV leaves for player details. Port-level info for attached AVs is shown above when available from Meraki client attachment.
|
||
</div>
|
||
`;
|
||
|
||
return sectionCard('', content);
|
||
}
|
||
|
||
export { showDeviceModal }; |