collabSupport/public/phone-store-dashboard.html
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

607 lines
No EOL
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phone Store Dashboard</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='0.9em' font-size='90'%3E📞%3C/text%3E%3C/svg%3E">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Mermaid for optional topology (phones/DECT attached to infra) -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
.section-card {
background-color: #1f2937;
border-radius: 16px;
padding: 24px;
border: 1px solid #374151;
}
/* Status coloring for Mermaid topology nodes (offline=red for not connected / floating; handsets use base conn for green) */
g.node.offline rect,
g.node.offline polygon,
g.node.offline ellipse,
g.node.offline .nodeLabel,
g.node.offline foreignObject {
fill: #450a0a !important;
stroke: #ef4444 !important;
stroke-width: 3px !important;
}
</style>
</head>
<body class="bg-gray-950 text-gray-200 p-8">
<div class="max-w-7xl mx-auto">
<h1 class="text-4xl font-bold mb-2">Phone Store Dashboard</h1>
<p class="text-gray-400 mb-8">Enter a store number to load Webex phones + DECT (basestations/handsets) with Meraki attachments. Topology is the primary view; click any node (infra, phone, base, or handset) for rich modal details. Mirrors the AV dashboard structure.</p>
<!-- Store Input -->
<div class="max-w-md mb-12">
<div class="flex gap-3">
<input id="storeInput"
type="text"
placeholder="Enter store number (e.g. 2477)"
class="flex-1 bg-gray-900 border border-gray-700 rounded-2xl px-6 py-4 text-lg focus:outline-none focus:border-emerald-500">
<button onclick="loadStore()"
class="bg-emerald-600 hover:bg-emerald-500 px-10 py-4 rounded-2xl font-semibold text-lg transition">
Load Store
</button>
</div>
</div>
<!-- Summary (compact) -->
<div id="loadSummary" class="mb-4 text-sm text-gray-400"></div>
<!-- Context (Timezone / PhoneNumber with ext / DECT) - prettier layout -->
<div id="contextBar" class="mb-8 hidden text-sm bg-gray-900 border border-gray-700 rounded-2xl px-6 py-4"></div>
<!-- Topology (primary / sole interactive view; top grids for desk/DECT removed) -->
<div id="topologySection" class="mt-4 hidden">
<div class="flex items-center justify-between mb-4">
<h2 id="topologyTitle" class="text-2xl font-semibold text-emerald-400">Phone Topology (Meraki linkLayer)</h2>
</div>
<div class="section-card">
<div id="mermaid-topology" class="mermaid w-full overflow-x-auto bg-gray-950 p-4 rounded-2xl min-h-[200px]"></div>
</div>
<p class="text-[10px] text-gray-500 mt-2">Real Meraki linkLayer topology (pruned to relevant). Phones/DECT bases attached via Meraki client (port+client shown). Handsets: green if wired to basestation (by baseStationId), red+⚠ floating if not (handsets lack direct Meraki connections). Unconnected float with no invalid edges. Click nodes for modals.</p>
</div>
</div>
<script type="module">
import { showPhoneModal } from './templates/phone-device-modal.js';
// Mermaid init (same as AV dashboard)
if (typeof mermaid !== 'undefined') {
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'loose',
flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' }
});
}
window.loadStore = async function() {
const storeNum = document.getElementById('storeInput').value.trim();
if (!storeNum) {
alert("Please enter a store number");
return;
}
const summaryEl = document.getElementById('loadSummary');
if (summaryEl) summaryEl.innerHTML = `<span class="text-gray-400">Loading store ${storeNum}...</span>`;
// Hide previous (only context + topology remain; top grids removed)
document.getElementById('topologySection').classList.add('hidden');
document.getElementById('contextBar').classList.add('hidden');
// Support subpath deployments (e.g. /CollabSupport/ behind NGINX reverse proxy).
// The static HTML may be served as /CollabSupport/phone-store-dashboard.html.
// In that case, the data fetch must use the same prefix so the proxy routes it
// to the backend's /phone/devices/build/... route. On bare paths (localhost) we use
// the root-relative path.
let apiPrefix = '';
const path = window.location.pathname || '';
const collabMatch = path.match(/^(.*\/CollabSupport)/i);
if (collabMatch) {
apiPrefix = collabMatch[1];
}
try {
const apiUrl = `${apiPrefix}/phone/devices/build/${storeNum}`;
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`HTTP ${response.status} (tried ${apiUrl})`);
const data = await response.json();
window.currentPhoneStoreData = data;
const phones = data.phones?.data || [];
const dectBases = data.dectBasestations || [];
const dectHands = data.dectHandsets || [];
const dectNet = data.dectNetwork || null;
const prof = data.telephonyProfile || {};
const mainNum = data.locationMainNumber;
const pers = data.person || {};
// Compact summary (no cards; topology is the view)
if (summaryEl) {
const total = phones.length + dectBases.length + dectHands.length;
summaryEl.innerHTML = `Store ${storeNum}: <strong>${phones.length}</strong> desk phones, <strong>${dectBases.length}</strong> DECT bases, <strong>${dectHands.length}</strong> handsets. Click nodes in the topology diagram for modals.`;
}
// Context bar - prettier, with 5-digit ext in () after Main/PhoneNumber
const contextBar = document.getElementById('contextBar');
let extPart = '';
if (pers.phoneNumbers && pers.phoneNumbers.length > 0) {
const nums = pers.phoneNumbers.map(n => n.value || n).filter(Boolean);
if (nums.length > 0) {
extPart = ` (${nums[0]})`;
}
}
const contextParts = [];
if (prof.timeZone) {
contextParts.push(`<strong>Timezone:</strong> ${prof.timeZone}`);
}
if (mainNum) {
contextParts.push(`<strong>PhoneNumber:</strong> ${mainNum}${extPart}`);
}
if (dectNet && dectNet.name) {
contextParts.push(`<strong>DECT Network:</strong> ${dectNet.name} (${dectNet.locationName || '—'})`);
}
if (contextParts.length > 0) {
// Prettier layout: flex row on md+, with separators
contextBar.innerHTML = `
<div class="flex flex-col md:flex-row md:items-center gap-x-6 gap-y-1">
${contextParts.map(p => `<div>${p}</div>`).join('<div class="hidden md:block text-gray-600">•</div>')}
</div>
`;
contextBar.classList.remove('hidden');
}
// Optional topology (if present in build response) - now the primary/only interactive surface
const topo = data.merakiTopology || { nodes: [], links: [] };
if (topo.nodes && topo.nodes.length > 0) {
const topoSection = document.getElementById('topologySection');
const totalPhoneDevices = phones.length + dectBases.length + dectHands.length;
document.getElementById('topologyTitle').textContent = `Phone Topology (Meraki linkLayer • ${topo.nodes.length} nodes, ${totalPhoneDevices} phone/DECT devices)`;
topoSection.classList.remove('hidden');
renderPhoneTopology(topo, phones, dectBases, dectHands);
}
} catch (err) {
if (summaryEl) summaryEl.innerHTML = `<span class="text-red-400">Error loading store ${storeNum}: ${err.message}</span>`;
console.error('Phone dashboard load failed:', err);
}
};
// Full topology using real merakiTopology (linkLayer) + attachments for phones, DECT bases, handsets.
// Mirrors AV: prune relevant infra, build from real nodes/links (ends[]), augment with phone/DECT nodes.
// Handsets connect to their basestation (by baseStationId).
// All use meraki client data (MAC matched in backend) for infra connections via recentDeviceSerial.
// Unconnected devices "float" (added with ⚠, no edge to avoid invalid mermaid).
async function renderPhoneTopology(topology, phones = [], dectBases = [], dectHands = []) {
const container = document.getElementById('mermaid-topology');
if (!container) return;
const def = buildMermaidPhoneTopology(topology, phones, dectBases, dectHands);
window.lastPhoneMermaidDef = def;
container.innerHTML = def;
try {
container.removeAttribute('data-processed');
if (typeof mermaid !== 'undefined' && mermaid.run) {
await mermaid.run({ nodes: [container], suppressErrors: true });
}
applyPhoneNodeStatusClasses(container, phones, dectBases, dectHands);
attachPhoneTopologyClicks(container, phones, dectBases, dectHands);
} catch (e) {
console.warn('Mermaid phone topology failed', e);
console.error('[phone-dashboard] Mermaid syntax error. Full def in window.lastPhoneMermaidDef');
container.innerHTML = `<pre class="text-[10px] text-gray-400 overflow-auto max-h-80">${def.replace(/</g,'&lt;')}</pre>`;
}
}
function buildMermaidPhoneTopology(topology, phones = [], dectBases = [], dectHands = []) {
let nodes = (topology.nodes || []).slice();
const links = topology.links || [];
if (nodes.length === 0) {
return 'flowchart LR\n empty["No topology nodes returned"]';
}
let def = 'flowchart LR\n';
// Sanitize for mermaid labels
function sanitizeForMermaid(str) {
return String(str || '')
.replace(/"/g, "'")
.replace(/[|\[\]]/g, "'")
.replace(/[\r\n\t]/g, ' ')
.replace(/[\u0000-\u001F\u007F]/g, '');
}
// Collect relevant serials from all devices' meraki (phones, bases, handsets) for pruning
const relevantSerials = new Set();
[...phones, ...dectBases, ...dectHands].forEach(dev => {
const c = dev.meraki?.client || dev.meraki || {};
if (c.recentDeviceSerial) relevantSerials.add(String(c.recentDeviceSerial));
if (c.switchSerial) relevantSerials.add(String(c.switchSerial));
if (c.apSerial) relevantSerials.add(String(c.apSerial));
});
// Prune to relevant infra only (like AV)
const firstForPrune = nodes[0] || {};
const looksLikeReal = !!(firstForPrune.derivedId || firstForPrune.mac || firstForPrune.type || (firstForPrune.device && (firstForPrune.device.serial || firstForPrune.device.name)));
if (looksLikeReal && relevantSerials.size > 0) {
const before = nodes.length;
nodes = nodes.filter(node => {
const d = node.device || {};
const ser = (d.serial || '').toString();
return ser && relevantSerials.has(ser);
});
if (nodes.length !== before) {
console.log('%c[phone-dashboard] Pruned topology to relevant infra only:', 'color:#10b981', `${nodes.length}/${before} nodes`);
}
}
if (nodes.length === 0) {
return 'flowchart LR\n empty["No relevant infra nodes with phone/DECT connections"]';
}
const keyToId = {};
const serialToId = {};
const macToId = {};
const indexToId = {};
const baseIdToId = {}; // for connecting handsets to bases
nodes.forEach((node, idx) => {
const d = node.device || {};
const disc = node.discovered || {};
const stk = node.stack || {};
const serial = (d.serial || '').toString();
const mac = (node.mac || d.mac || '').toString();
const derived = (node.derivedId || mac || serial || `n${idx}`).toString();
const base = (serial || derived || mac || `node${idx}`).replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24);
const safeId = `n${idx}_${base}`;
indexToId[idx] = safeId;
if (derived) keyToId[derived] = safeId;
if (serial) serialToId[serial] = safeId;
if (mac) macToId[mac] = safeId;
if (node.derivedId) keyToId[node.derivedId] = safeId;
const rawName = (d.name || disc.lldp?.systemName || stk.name || mac || serial || `node${idx}`).toString();
const rawModel = (d.model || node.type || '').toString();
const name = sanitizeForMermaid(rawName);
const model = sanitizeForMermaid(rawModel);
// count connected phones/bases for badge (handsets count under bases)
const connectedCount = [...phones, ...dectBases].filter(dev => {
const c = dev.meraki?.client || dev.meraki || {};
return (serial && (c.recentDeviceSerial === serial || c.switchSerial === serial || c.apSerial === serial)) ||
(rawName && (c.recentDeviceName === rawName || c.apName === rawName));
}).length;
const extra = connectedCount ? ` <small>(${connectedCount} attached)</small>` : '';
const rawLabel = `${name}<br/><small>${model || ''}${serial ? ' · ' + serial : ''}</small>${extra}`;
const label = sanitizeForMermaid(rawLabel);
const cls = /MS|switch/i.test(model || name) ? 'switch' :
/MR|AP|wireless/i.test(model || name) ? 'ap' : 'infra';
def += ` ${safeId}["${label}"]:::${cls}\n`;
});
// Process links (real topology shape with ends[])
links.forEach((link, i) => {
const ends = Array.isArray(link.ends) ? link.ends : [];
let srcId = null;
let tgtId = null;
let portLabel = '';
if (ends.length >= 2) {
const resolveEnd = (e) => {
if (!e) return null;
const nd = e.node || {};
const dv = e.device || {};
const cand = nd.derivedId || dv.serial || '';
if (cand && keyToId[cand]) return keyToId[cand];
if (dv.serial && serialToId[dv.serial]) return serialToId[dv.serial];
if (nd.derivedId && keyToId[nd.derivedId]) return keyToId[nd.derivedId];
return null;
};
srcId = resolveEnd(ends[0]);
tgtId = resolveEnd(ends[1]);
if (!srcId) {
const s0 = (ends[0] && ends[0].node && ends[0].node.derivedId) || '';
const n0 = Number(s0);
if (!isNaN(n0) && indexToId[n0] != null) srcId = indexToId[n0];
}
if (!tgtId) {
const s1 = (ends[1] && ends[1].node && ends[1].node.derivedId) || '';
const n1 = Number(s1);
if (!isNaN(n1) && indexToId[n1] != null) tgtId = indexToId[n1];
}
const getPort = (e) => {
const disc = e && e.discovered || {};
return disc.lldp?.portId || disc.cdp?.portId || disc.lldp?.portDescription || '';
};
const p0 = getPort(ends[0]);
const p1 = getPort(ends[1]);
if (p0 || p1) {
portLabel = (p0 && p1 && p0 !== p1) ? `${p0}${p1}` : (p0 || p1 || 'link');
}
}
if (!srcId) srcId = `s${i}`;
if (!tgtId) tgtId = `t${i}`;
const isRealSrc = srcId && !String(srcId).startsWith('s');
const isRealTgt = tgtId && !String(tgtId).startsWith('t');
if (isRealSrc && isRealTgt) {
const safePort = sanitizeForMermaid(portLabel);
const edge = safePort ? ` -->|"${safePort}"| ` : ' --> ';
def += ` ${srcId}${edge}${tgtId}\n`;
}
});
// Augment: add phones, bases, handsets as nodes.
// Use serial/mac maps for infra parents.
// For bases, record their node id for handset connections.
// Handsets primarily connect to base (by baseStationId), secondarily to infra via their meraki if present.
// Add phones first (as "device" leaves)
const baseNodeIds = {}; // base.id -> safeId
phones.forEach((dev, i) => {
const c = dev.meraki?.client || dev.meraki || {};
const parentSerial = (c.recentDeviceSerial || c.switchSerial || c.apSerial || '').toString().trim();
const port = (c.switchport || c.portNumber || c.port || '').toString().trim();
const isWireless = (c.recentDeviceConnection || '').toLowerCase().includes('wireless');
const ip = c.ip || dev.ip || '—';
const devName = sanitizeForMermaid(dev.displayName || dev.name || dev.mac || 'Phone');
const safeBase = (dev.mac || dev.name || `p${i}`).replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24);
const devSafe = `p${i}_${safeBase}`;
let clientInfo = c.mac ? ` MAC ${c.mac}` : '';
if (c.status) clientInfo += ` ${c.status}`;
let label = `${devName}<br/><small>${ip}${clientInfo} • Phone ${isWireless ? '📶' : '🔌'}${port ? ' Port:'+port : ''}</small>`;
if (!parentSerial) {
label = `${devName} ⚠<br/><small>no recent Meraki parent</small>`;
}
label = sanitizeForMermaid(label);
def += ` ${devSafe}["${label}"]:::phone\n`;
if (parentSerial) {
let parentId = serialToId[parentSerial] || keyToId[parentSerial] || macToId[parentSerial];
if (parentId) {
const edgeLabel = sanitizeForMermaid(port || (isWireless ? 'wireless' : 'wired'));
def += ` ${parentId} -->|"${edgeLabel}"| ${devSafe}\n`;
}
}
});
// Add bases (record node ids for handsets to connect to)
dectBases.forEach((dev, i) => {
const c = dev.meraki?.client || dev.meraki || {};
const parentSerial = (c.recentDeviceSerial || c.switchSerial || c.apSerial || '').toString().trim();
const port = (c.switchport || c.portNumber || c.port || '').toString().trim();
const isWireless = (c.recentDeviceConnection || '').toLowerCase().includes('wireless');
const ip = c.ip || dev.ip || '—';
const devName = sanitizeForMermaid(dev.name || dev.mac || 'DECT Base');
const safeBase = (dev.mac || dev.name || `b${i}`).replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24);
const devSafe = `b${i}_${safeBase}`;
let clientInfo = c.mac ? ` MAC ${c.mac}` : '';
if (c.status) clientInfo += ` ${c.status}`;
let label = `${devName}<br/><small>${ip}${clientInfo} • DECT Base ${isWireless ? '📶' : '🔌'}${port ? ' Port:'+port : ''}</small>`;
if (!parentSerial) {
label = `${devName} ⚠<br/><small>no recent Meraki parent</small>`;
}
label = sanitizeForMermaid(label);
def += ` ${devSafe}["${label}"]:::phone\n`;
if (parentSerial) {
let parentId = serialToId[parentSerial] || keyToId[parentSerial] || macToId[parentSerial];
if (parentId) {
const edgeLabel = sanitizeForMermaid(port || (isWireless ? 'wireless' : 'wired'));
def += ` ${parentId} -->|"${edgeLabel}"| ${devSafe}\n`;
}
}
if (dev.id) {
baseNodeIds[dev.id] = devSafe;
}
});
// Add handsets: connect to their base (by baseStationId), float if no base
dectHands.forEach((hand, i) => {
const c = hand.meraki?.client || hand.meraki || {};
const parentSerial = (c.recentDeviceSerial || c.switchSerial || c.apSerial || '').toString().trim(); // for direct if any
const ip = c.ip || hand.ip || '—';
const ext = hand.extension || '';
const handName = sanitizeForMermaid(hand.name || hand.mac || `Handset ${i}`);
const safeBase = (hand.mac || hand.id || `h${i}`).replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24);
const handSafe = `h${i}_${safeBase}`;
let clientInfo = c.mac ? ` MAC ${c.mac}` : '';
if (c.status) clientInfo += ` ${c.status}`;
let label = `${handName}<br/><small>${ip}${clientInfo} • Handset ${ext ? 'ext' + ext : ''}</small>`;
if (!hand.baseStationId && !parentSerial) {
label = `${handName} ⚠<br/><small>no base / no recent Meraki</small>`;
}
label = sanitizeForMermaid(label);
def += ` ${handSafe}["${label}"]:::handset\n`;
let connected = false;
// Primary: connect to basestation node
if (hand.baseStationId && baseNodeIds[hand.baseStationId]) {
const baseSafe = baseNodeIds[hand.baseStationId];
def += ` ${baseSafe} -->|"handset"| ${handSafe}\n`;
connected = true;
}
// Secondary: direct to infra via meraki (if has its own client connection)
if (parentSerial) {
let parentId = serialToId[parentSerial] || keyToId[parentSerial] || macToId[parentSerial];
if (parentId) {
const edgeLabel = sanitizeForMermaid(c.switchport || c.port || 'direct');
def += ` ${parentId} -->|"${edgeLabel}"| ${handSafe}\n`;
connected = true;
}
}
if (!connected) {
// float: node is added, no edge (valid in mermaid)
}
});
// classDefs
def += `
classDef switch fill:#1f2937,stroke:#4ade80,stroke-width:3px,color:#fff,rx:10,ry:10
classDef ap fill:#312e81,stroke:#6366f1,stroke-width:3px,color:#fff,rx:10,ry:10
classDef infra fill:#334155,stroke:#64748b,stroke-width:2px,color:#fff,rx:8,ry:8
classDef phone fill:#052e16,stroke:#4ade80,stroke-width:2px,color:#a3e635,rx:6,ry:6
classDef handset fill:#0f172a,stroke:#22c55e,stroke-width:1px,color:#86efac,rx:4,ry:4
`;
return def;
}
function attachPhoneTopologyClicks(container, phones, dectBases, dectHands = []) {
const svg = container.querySelector('svg');
if (!svg) return;
const allDevs = [...phones, ...dectBases, ...dectHands];
svg.querySelectorAll('g.node, .node').forEach(nodeEl => {
nodeEl.style.cursor = 'pointer';
nodeEl.addEventListener('click', () => {
const label = (nodeEl.textContent || '').trim().toUpperCase();
// Try match on devices by name/mac/id or extension
let match = allDevs.find(d => {
const candidates = [d.displayName, d.name, d.mac, d.extension, d.id].filter(Boolean).map(x => String(x).toUpperCase());
return candidates.some(c => label.includes(c) || c.includes(label));
});
if (match) {
const isHand = !!match.baseStationId || !!match.extension;
const isBase = dectBases.some(b => b.id === match.id || b.id === match.baseStationId);
const typ = isHand ? 'dect-handset' : (isBase ? 'dect-base' : 'phone');
const id = match.displayName || match.name || match.mac || match.extension || 'device';
showPhoneModal(id, typ, match);
return;
}
// fallback
alert(`Topology node: ${label}\n(Full details via the rich modal from topology nodes.)`);
});
});
}
// Auto-suggest a store on load (same as AV)
window.onload = () => {
document.getElementById('storeInput').value = "2477";
// loadStore(); // uncomment to auto-load
};
function getPhoneStatusClass(item) {
if (!item) return '';
const m = item.meraki?.client || item.meraki || {};
let st = String(m.status || m.Status || '').toLowerCase().trim();
if (!st && item.status) st = String(item.status).toLowerCase().trim();
// Handsets: connected to a basestation (baseStationId) means green/connected even with no Meraki client of their own.
// Floating handsets (!baseStationId) will carry ⚠ / _noParent and should be red.
if (item.baseStationId) {
const bad = /offline|off|dormant|disconnected/i.test(st);
if (!bad) {
return ''; // base-connected handset -> green via handset classDef (no 'offline' red)
}
}
// Recency from lastSeen (skip for base-connected handsets above)
if (!st && m.lastSeen) {
const d = new Date(m.lastSeen);
if (!isNaN(d.getTime())) {
const ageMins = (Date.now() - d.getTime()) / 60000;
if (ageMins > 30) st = 'offline';
}
}
if (st === 'offline' || st === 'off' || st === 'dormant' || st === 'disconnected' || st === 'unknown') return 'offline';
// Check for not connected (has ⚠ in its data representation).
// Guard the !recentDeviceSerial check: handsets without own Meraki are OK if they have baseStationId (handled above).
if (item._noParent || (!item.baseStationId && m && !m.recentDeviceSerial && !m.switchSerial)) return 'offline';
if (st && !/online|connected|registered/.test(st)) return 'offline';
return '';
}
function applyPhoneNodeStatusClasses(container, phones, dectBases, dectHands) {
const svg = container.querySelector('svg');
if (!svg) return;
const allDevs = [...(phones||[]), ...(dectBases||[]), ...(dectHands||[])];
svg.querySelectorAll('g.node, .node').forEach(nodeEl => {
let label = nodeEl.querySelector('title')?.textContent || '';
const fo = nodeEl.querySelector('foreignObject');
if (fo) {
const foText = (fo.textContent || fo.innerText || '').trim();
if (foText) label = foText;
}
if (!label) {
const tspans = nodeEl.querySelectorAll('tspan');
label = Array.from(tspans).map(t => t.textContent || '').join(' ').trim();
}
if (!label) label = (nodeEl.textContent || nodeEl.innerText || '').trim();
const group = nodeEl.closest('g[id]') || nodeEl;
const groupId = (group.id || '').trim();
if (!label && groupId) label = groupId;
const up = label.toUpperCase();
// Find matching device
let match = allDevs.find(d => {
const candidates = [d.displayName, d.name, d.mac, d.id].filter(Boolean).map(x => String(x).toUpperCase());
return candidates.some(c => up.includes(c) || c.includes(up));
});
// If label has ⚠ or "no base", treat as not connected / floating (esp. for handsets w/o Meraki)
if (!match && /⚠|no recent|no base|floating/i.test(label)) {
match = { _noParent: true, status: 'offline' };
}
// Extra: handset nodes whose label indicates connected to base (no "no base" warning) but no device match found
// should stay non-red (green stroke from classDef). If it *does* indicate floating, ensure red.
const isHandsetNode = /handset/i.test(label);
if (isHandsetNode && /⚠|no base/i.test(label)) {
match = match || { _noParent: true, baseStationId: null, status: 'offline' };
}
const statusCls = getPhoneStatusClass(match);
if (statusCls) {
nodeEl.classList.add(statusCls);
// Force red for offline / floating (not connected basestation etc). Covers htmlLabels + shapes.
nodeEl.querySelectorAll('rect, polygon, ellipse, path, .node-rect, foreignObject').forEach(shape => {
shape.style.setProperty('fill', '#450a0a', 'important');
shape.style.setProperty('stroke', '#ef4444', 'important');
shape.style.setProperty('stroke-width', '3px', 'important');
});
} else if (isHandsetNode && !/⚠|no base/i.test(label)) {
// Explicitly ensure connected handsets (green) don't get leftover red styles from prior renders
nodeEl.querySelectorAll('rect, polygon, ellipse, path, .node-rect, foreignObject').forEach(shape => {
// handset classDef provides the green; clear any prior forced red if present
if (shape.style.fill && shape.style.fill.includes('450a0a')) {
shape.style.removeProperty('fill');
shape.style.removeProperty('stroke');
shape.style.removeProperty('stroke-width');
}
});
}
});
}
</script>
</body>
</html>